feat: add init-config helper

This commit is contained in:
2025-11-14 22:15:10 +01:00
parent 13b3adf2e2
commit 50c74438a8
3 changed files with 85 additions and 5 deletions

View File

@@ -28,15 +28,16 @@ This document equips future agents with the current mental model for the `studip
## Runtime Flow ## 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. 1. `studip-sync init-config` writes a config template (optionally overriding `download_root` and guarded by `--force`).
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. 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 sync`: 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`. - 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`. - 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. - 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. - 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 nondry-run) deletes stray files/dirs with `walkdir`. - Records remote metadata + local path hints in state. `--dry-run` reports actions without touching disk; `--prune` (plus nondry-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 ## Configuration & State

View File

@@ -4,6 +4,7 @@
## Key Features ## 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). - `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. - `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. - `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**: 3. **First run**:
```bash ```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) # Store credentials (prompts for username/password by default)
cargo run -- auth cargo run -- auth
@@ -63,6 +67,7 @@ max_concurrent_downloads = 3 # placeholder for future concurrency control
| Subcommand | Description | Helpful flags | | 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` | | `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` | | `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)* | | `sync` | Download files for every enrolled course into the local tree. | `--dry-run`, `--prune`, `--since` *(reserved for future API filters)* |

View File

@@ -16,7 +16,7 @@ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
env, env,
fmt::Write as FmtWrite, fmt::Write as FmtWrite,
fs::File, fs::{File, OpenOptions},
io::{self, BufReader, Read, Write}, io::{self, BufReader, Read, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
@@ -56,6 +56,8 @@ pub struct Cli {
enum Command { enum Command {
/// Configure credentials and other persistent settings. /// Configure credentials and other persistent settings.
Auth(AuthArgs), Auth(AuthArgs),
/// Write a default config template to disk.
InitConfig(InitConfigArgs),
/// Perform a one-way sync from Stud.IP to the local filesystem. /// Perform a one-way sync from Stud.IP to the local filesystem.
Sync(SyncArgs), Sync(SyncArgs),
/// Show known courses, optionally refreshing from Stud.IP. /// Show known courses, optionally refreshing from Stud.IP.
@@ -72,6 +74,16 @@ pub struct AuthArgs {
pub password: Option<String>, pub password: Option<String>,
} }
#[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<PathBuf>,
}
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
pub struct SyncArgs { pub struct SyncArgs {
#[arg(long = "dry-run", action = ArgAction::SetTrue)] #[arg(long = "dry-run", action = ArgAction::SetTrue)]
@@ -134,6 +146,7 @@ impl Cli {
match self.command { match self.command {
Command::Auth(args) => args.execute(&mut ctx).await?, 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::Sync(args) => args.execute(&mut ctx).await?,
Command::ListCourses(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<String> { fn prompt_username(prompt: &str) -> Result<String> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
stdout.write_all(prompt.as_bytes())?; stdout.write_all(prompt.as_bytes())?;
@@ -1030,3 +1086,21 @@ fn hex_encode(bytes: &[u8]) -> String {
} }
out 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
"#
)
}