From 50c74438a83a4aeeb2205130e80933315678b9cc Mon Sep 17 00:00:00 2001 From: Matthias Puchstein Date: Fri, 14 Nov 2025 22:15:10 +0100 Subject: [PATCH] feat: add init-config helper --- AGENTS.md | 9 ++++--- README.md | 5 ++++ src/cli.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6d9792e..d95ff9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,15 +28,16 @@ This document equips future agents with the current mental model for the `studip ## Runtime Flow -1. `studip-sync auth` collects credentials (CL flags, env, or interactive prompts), Base64-encodes `username:password`, and persists it in the active profile. -2. `studip-sync list-courses` builds a `StudipClient`, resolves/caches the user ID via `/users/me`, paginates `/users/{id}/courses`, fetches missing semesters, upserts course metadata into `state.toml`, and prints a table sorted by semester/title. -3. `studip-sync sync`: +1. `studip-sync init-config` writes a config template (optionally overriding `download_root` and guarded by `--force`). +2. `studip-sync auth` collects credentials (CL flags, env, or interactive prompts), Base64-encodes `username:password`, and persists it in the active profile. +3. `studip-sync list-courses` builds a `StudipClient`, resolves/caches the user ID via `/users/me`, paginates `/users/{id}/courses`, fetches missing semesters, upserts course metadata into `state.toml`, and prints a table sorted by semester/title. +4. `studip-sync sync`: - Resolves download root (`config.download_root` or `$XDG_DATA_HOME/studip-sync/downloads`) and ensures directories exist unless `--dry-run`. - Refreshes course + semester info, then for each course performs a depth-first walk: `/courses/{id}/folders` ➜ `/folders/{id}/file-refs` ➜ `/folders/{id}/folders`. Pagination is handled by `fetch_all_pages`. - Normalizes path components and uses `NameRegistry` to avoid collisions, guaranteeing human-readable yet unique names. - Checks file state (size, modified timestamp, checksum) against `state.toml` to skip unchanged files; downloads stream to `*.part` before rename. - Records remote metadata + local path hints in state. `--dry-run` reports actions without touching disk; `--prune` (plus non–dry-run) deletes stray files/dirs with `walkdir`. -4. HTTP errors propagate via `anyhow`, but 401/403 currently surface as generic failures—production UX should point users to `studip-sync auth`. +5. HTTP errors propagate via `anyhow`, but 401/403 currently surface as generic failures—production UX should point users to `studip-sync auth`. ## Configuration & State diff --git a/README.md b/README.md index 215e417..abe8c94 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ ## Key Features +- `init-config` writes a ready-to-edit config template (respecting `--download-root` and `--force` to overwrite). - `auth` subcommand stores Base64-encoded credentials per profile (passwords are never logged). - `list-courses` fetches `/users/me`, paginates enrolled courses, infers semester keys, caches the metadata, and prints a concise table. - `sync` traverses every course folder/file tree, normalizes names, streams downloads to disk, tracks checksums/remote timestamps, and supports `--dry-run` plus `--prune` to delete orphaned files. @@ -27,6 +28,9 @@ ``` 3. **First run**: ```bash + # Optionally scaffold a config template (safe no-op if it exists) + cargo run -- init-config --download-root "$HOME/StudIP" + # Store credentials (prompts for username/password by default) cargo run -- auth @@ -63,6 +67,7 @@ max_concurrent_downloads = 3 # placeholder for future concurrency control | Subcommand | Description | Helpful flags | | --- | --- | --- | +| `init-config` | Write a default config template (fails if config exists unless forced). | `--force`, `--download-root` | | `auth` | Collect username/password, encode them, and save them to the active profile. | `--non-interactive`, `--username`, `--password` | | `list-courses` | List cached or freshly fetched courses with semester keys and IDs. | `--refresh` | | `sync` | Download files for every enrolled course into the local tree. | `--dry-run`, `--prune`, `--since` *(reserved for future API filters)* | diff --git a/src/cli.rs b/src/cli.rs index 3b3d3f4..221e50c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,7 +16,7 @@ use std::{ collections::{HashMap, HashSet}, env, fmt::Write as FmtWrite, - fs::File, + fs::{File, OpenOptions}, io::{self, BufReader, Read, Write}, path::{Path, PathBuf}, }; @@ -56,6 +56,8 @@ pub struct Cli { enum Command { /// Configure credentials and other persistent settings. Auth(AuthArgs), + /// Write a default config template to disk. + InitConfig(InitConfigArgs), /// Perform a one-way sync from Stud.IP to the local filesystem. Sync(SyncArgs), /// Show known courses, optionally refreshing from Stud.IP. @@ -72,6 +74,16 @@ pub struct AuthArgs { pub password: Option, } +#[derive(Debug, Parser)] +pub struct InitConfigArgs { + /// Overwrite an existing config file. + #[arg(long = "force", action = ArgAction::SetTrue)] + pub force: bool, + /// Optional download root to bake into the template. + #[arg(long = "download-root", value_hint = ValueHint::DirPath)] + pub download_root: Option, +} + #[derive(Debug, Parser)] pub struct SyncArgs { #[arg(long = "dry-run", action = ArgAction::SetTrue)] @@ -134,6 +146,7 @@ impl Cli { match self.command { Command::Auth(args) => args.execute(&mut ctx).await?, + Command::InitConfig(args) => args.execute(&mut ctx).await?, Command::Sync(args) => args.execute(&mut ctx).await?, Command::ListCourses(args) => args.execute(&mut ctx).await?, } @@ -349,6 +362,49 @@ impl AuthArgs { } } +impl InitConfigArgs { + async fn execute(&self, ctx: &mut CommandContext) -> Result<()> { + let config_path = ctx.paths().config_file(); + if config_path.exists() && !self.force { + bail!( + "Config file {} already exists. Use --force to overwrite.", + config_path.display() + ); + } + + let download_root = self + .download_root + .clone() + .unwrap_or_else(|| ctx.paths().data_dir.join("downloads")); + let template = render_default_config_template(&download_root); + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&config_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = file.metadata()?.permissions(); + perms.set_mode(0o600); + file.set_permissions(perms)?; + } + file.write_all(template.as_bytes())?; + + info!( + profile = ctx.profile_name(), + path = %config_path.display(), + "wrote default config template" + ); + Ok(()) + } +} + fn prompt_username(prompt: &str) -> Result { let mut stdout = io::stdout(); stdout.write_all(prompt.as_bytes())?; @@ -1030,3 +1086,21 @@ fn hex_encode(bytes: &[u8]) -> String { } out } + +fn render_default_config_template(download_root: &Path) -> String { + let download_root = download_root.to_string_lossy(); + format!( + r#"# studip-sync configuration +# Run `studip-sync auth` to set credentials for the active profile. +default_profile = "default" + +[profiles.default] +# Optional convenience field for display purposes only. +# username = "uni-id" +base_url = "https://studip.uni-trier.de" +jsonapi_path = "/jsonapi.php/v1" +download_root = "{download_root}" +max_concurrent_downloads = 3 +"# + ) +}