feat: add init-config helper
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)* |
|
||||
|
||||
76
src/cli.rs
76
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<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)]
|
||||
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<String> {
|
||||
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
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user