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
|
## 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 non–dry-run) deletes stray files/dirs with `walkdir`.
|
- 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
|
## Configuration & State
|
||||||
|
|
||||||
|
|||||||
@@ -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)* |
|
||||||
|
|||||||
76
src/cli.rs
76
src/cli.rs
@@ -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
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user