use crate::types::LogLevel; use clap::{Args, FromArgMatches, Parser, Subcommand}; use std::ffi::{OsStr, OsString}; use std::path::PathBuf; /// Reject empty strings at CLI parse time. fn non_empty_string(s: &str) -> Result { if s.is_empty() { Err("value must not be empty".to_string()) } else { Ok(s.to_string()) } } /// Parse a human-readable byte-rate into bytes per second. /// /// Accepts a non-negative number (integer or decimal, e.g. `1.5`) followed by /// an optional unit suffix: decimal `K`/`M`/`G` (x1000) or binary /// `Ki`/`Mi`/`Gi` (x1024). Suffix is case-insensitive. No suffix means /// bytes/sec. Values that round to zero bytes/sec are rejected. pub(crate) fn parse_bandwidth_limit(s: &str) -> Result { let trimmed = s.trim(); if trimmed.is_empty() { return Err("value must not be empty".to_string()); } // Numeric part is digits plus at most one decimal point; everything after // that is the unit suffix. Leading sign is not accepted here (negative // bandwidth is meaningless; `-5M` errors with "must start with a number"). let num_end = trimmed .find(|c: char| !c.is_ascii_digit() && c != '.') .unwrap_or(trimmed.len()); if num_end == 0 { return Err(format!( "invalid bandwidth value `{s}`: must start with a number" )); } let (num_str, suffix) = trimmed.split_at(num_end); let n: f64 = num_str .parse() .map_err(|_e| format!("invalid bandwidth number `{num_str}`"))?; if !n.is_finite() || n < 0.0 { return Err(format!( "invalid bandwidth number `{num_str}`: must be a finite non-negative number" )); } let multiplier: u64 = match suffix.trim().to_ascii_lowercase().as_str() { "" | "b" => 1, "k" | "kb" => 1_000, "m" | "mb" => 1_000_000, "g" | "gb" => 1_000_000_000, "ki" | "kib" => 1_024, "mi" | "mib" => 1_024 * 1_024, "gi" | "gib" => 1_024 * 1_024 * 1_024, other => { return Err(format!( "invalid bandwidth unit `{other}`: expected one of K, M, G, Ki, Mi, Gi" )); } }; // f64 multiplication is exact for the typical bandwidth range (KB/s to // GB/s) and off by less than a byte for extreme values; round to nearest // so inputs like `0.1K` land on 100 bytes/sec rather than 99. #[allow( clippy::cast_precision_loss, reason = "multiplier is a small constant (<= 2^30); u64::MAX is only a comparison bound where exact precision doesn't matter" )] let (max_f64, multiplier_f64) = (u64::MAX as f64, multiplier as f64); let total = n * multiplier_f64; if !total.is_finite() || total > max_f64 { return Err(format!("bandwidth value `{s}` overflows u64 bytes/sec")); } let rounded = total.round(); if rounded < 1.0 { return Err(format!( "bandwidth value `{s}` rounds to zero bytes/sec; must be at least 1 byte/sec" )); } #[allow( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "bounds checked above: 1.0 <= rounded <= u64::MAX as f64" )] Ok(rounded as u64) } /// Strip non-digit characters and validate that the result is exactly 6 digits. /// Accepts "123456", "123 456", "123-456", etc. fn parse_2fa_code(s: &str) -> Result { let digits: String = s.chars().filter(char::is_ascii_digit).collect(); if digits.len() == 6 { Ok(digits) } else { Err("must contain exactly 6 digits".to_string()) } } /// Limit on which assets a sync pass processes. /// /// `--recent 100` is a count limit (top N most-recent assets). `--recent 30d` /// is a days limit (assets created in the last 30 days) and translates to a /// `skip_created_before` cutoff at `Config::build` time. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RecentLimit { /// Take the N most-recent assets by creation date. Count(u32), /// Take assets created in the last N days. Days(u32), } /// Explicit legacy-state adoption. No automatic account inference or overwrite. #[derive(Debug, Clone, clap::Args)] pub struct MigrateStateArgs { /// Legacy SQLite database whose account ownership you have verified #[arg(long)] pub legacy_db: PathBuf, /// Attest that the legacy database belongs to the configured account #[arg(long, required = true)] pub confirm_ownership: bool, /// Verification code for the fresh login, if required #[arg(long)] pub code: Option, #[command(flatten)] pub password: PasswordArgs, } /// Where a count-form `--recent N` limit is applied when filters create /// multiple CloudKit streams. #[derive( Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum, serde::Deserialize, serde::Serialize, )] #[serde(rename_all = "kebab-case")] pub enum RecentScope { /// Apply `recent` once to the selected library, then narrow with filters. #[default] Global, /// Apply `recent` independently to each album, smart-folder, or unfiled pass. PerFilter, } /// Parse `--recent N` (count) or `--recent Nd` (days). Clap `value_parser`. /// /// Rejects zero, empty, and unknown suffixes. Only `d` (days) is supported /// today; this keeps the syntax open for future units without locking us in. pub(crate) fn parse_recent_limit(s: &str) -> Result { if s.is_empty() { return Err("must not be empty".to_string()); } let (num_str, is_days) = if let Some(stripped) = s.strip_suffix('d') { (stripped, true) } else { (s, false) }; let n: u32 = num_str .parse() .map_err(|_e| format!("expected a positive integer or `Nd` form (got `{s}`)"))?; if n == 0 { return Err(format!("must be greater than zero (got `{s}`)")); } Ok(if is_days { RecentLimit::Days(n) } else { RecentLimit::Count(n) }) } impl std::fmt::Display for RecentLimit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Count(n) => write!(f, "{n}"), Self::Days(n) => write!(f, "{n}d"), } } } impl serde::Serialize for RecentLimit { fn serialize(&self, s: S) -> Result { match self { // Count serializes as a bare integer so TOML round-trips cleanly // for the common case (`recent = 100`, not `recent = "100"`). Self::Count(n) => s.serialize_u32(*n), Self::Days(n) => s.serialize_str(&format!("{n}d")), } } } impl<'de> serde::Deserialize<'de> for RecentLimit { fn deserialize>(d: D) -> Result { use serde::de::Error; #[derive(serde::Deserialize)] #[serde(untagged)] enum Raw { Int(u32), Str(String), } match Raw::deserialize(d)? { Raw::Int(0) => Err(D::Error::custom("`recent` must be greater than zero")), Raw::Int(n) => Ok(Self::Count(n)), Raw::Str(s) => parse_recent_limit(&s).map_err(D::Error::custom), } } } /// Password arguments shared across commands that authenticate. /// Flattened only onto commands that actually need a password (sync, login, /// list, import-existing, password). #[derive(Parser, Debug, Clone, Default)] pub struct PasswordArgs { /// iCloud password (if not provided, will prompt). /// WARNING: passing via --password is visible in process listings. /// Prefer the `ICLOUD_PASSWORD` environment variable instead. #[arg(short = 'p', long, env = "ICLOUD_PASSWORD", value_parser = non_empty_string)] pub password: Option, /// Read password from a file on each auth attempt. /// Supports Docker secrets (e.g., /run/secrets/icloud_password). /// Trailing newline is stripped. #[arg(long, env = "KEI_PASSWORD_FILE", conflicts_with = "password")] pub password_file: Option, /// Execute a shell command to obtain the password on each auth attempt. /// Supports external secret managers (1Password, Vault, pass). /// Example: --password-command "op read 'op://vault/icloud/password'" #[arg(long, env = "KEI_PASSWORD_COMMAND", conflicts_with_all = ["password", "password_file"])] pub password_command: Option, } /// Arguments for the sync command. #[derive(Parser, Debug, Clone, Default)] pub struct SyncArgs { /// Limit to recent assets (e.g. `--recent 100`) or use a days window /// (e.g. `--recent 30d`). /// This is not a global exact file count - filters and live-photo parts /// can change the final number of downloaded files. Days form maps to /// `--skip-created-before` internally. #[arg(long, value_parser = parse_recent_limit)] pub recent: Option, /// Where count-form `--recent N` applies when filters create multiple streams. /// /// `global` means "take the library-wide recent N, then apply filters". /// `per-filter` means "take up to N from each album/smart-folder/unfiled pass". #[arg(long, value_enum)] pub recent_scope: Option, /// Do not modify local system or iCloud #[arg(long)] pub dry_run: bool, /// Disable progress bar #[arg(long)] pub no_progress_bar: bool, /// Skip assets before this cutoff. Date-only values compare the capture-local calendar date; /// explicit datetimes and relative intervals compare the instant (e.g., 2025-01-02 or 20d). #[arg(long)] pub skip_created_before: Option, /// Skip assets after this cutoff. Date-only values compare the capture-local calendar date; /// explicit datetimes and relative intervals compare the instant (e.g., 2025-01-02 or 20d). #[arg(long)] pub skip_created_after: Option, /// Only print filenames without downloading #[arg(long)] pub only_print_filenames: bool, /// After successful auth, persist the password to the credential store /// (OS keyring or encrypted file). #[arg(long)] pub save_password: bool, /// Re-sync only previously failed assets #[arg(long, conflicts_with = "dry_run")] pub retry_failed: bool, /// One-shot full-library repair that refreshes metadata for every downloaded version without re-downloading media. /// Requires a complete selected-library sweep and rewrites EXIF/XMP per the configured metadata outputs. /// Use with sync, not service run. A configured watch interval is ignored. #[arg( long, conflicts_with_all = ["dry_run", "retry_failed", "only_print_filenames", "recent"] )] pub refresh_metadata: bool, /// Replace existing embedded capture timestamps with Apple's capture-local timestamp and /// matching offset. This can overwrite camera metadata and requires --refresh-metadata /// and metadata.set_exif_datetime = true. #[arg(long, requires = "refresh_metadata")] pub repair_capture_timestamps: bool, /// Replace a recorded local file only when reconcile marked it truncated. /// The replacement is verified before it atomically takes the recorded path. /// Use with sync, not service run. A configured watch interval is ignored. #[arg( long, conflicts_with_all = ["dry_run", "only_print_filenames", "refresh_metadata"] )] pub repair_truncated: bool, /// Test-only durable config overrides used by unit tests that exercise /// resolver internals without rebuilding TOML snippets. #[cfg(test)] #[arg(skip)] pub(crate) config_overrides: crate::config::SyncConfigOverrides, } /// Runtime-friendly output toggles. /// /// These are deliberately not global options. They only affect `kei sync`; /// non-sync subcommands either do no long running terminal rendering or force /// friendly mode off. #[derive(Parser, Debug, Clone, Default)] pub struct FriendlyArgs { /// Use friendly terminal progress and summaries (default on interactive terminals) #[arg( long, overrides_with = "no_friendly", long_help = "Use friendly terminal progress and summaries. \ Default: on for plain TTYs, off in service/container/journal contexts and whenever a \ machine-output mode (`--only-print-filenames` or TOML report JSON) or an explicit \ `--log-level` / `RUST_LOG` is in play. `--friendly` overrides the TOML `[ui] friendly` \ setting and the auto-detected default; environmental hard-off contexts still win." )] pub friendly: bool, /// Disable friendly terminal progress and summaries #[arg( long, overrides_with = "friendly", long_help = "Force friendly terminal progress and summaries off and use the plain tracing output. \ Overrides `--friendly`, the TOML `[ui] friendly` setting, and the auto-detected default. \ Use this when piping kei output to a log aggregator on an interactive TTY where \ auto-detection would otherwise enable friendly mode." )] pub no_friendly: bool, } /// Arguments for the status command. #[derive(Parser, Debug, Clone)] pub struct StatusArgs { /// Show failed assets with error messages #[arg(long)] pub failed: bool, /// Show pending assets (known to the DB, not yet finalized this sync). /// Includes assets the current sync scope excludes via filters or album /// selection. #[arg(long)] pub pending: bool, /// Show downloaded assets #[arg(long)] pub downloaded: bool, } /// Arguments for local diagnostics. #[derive(Parser, Debug, Clone)] pub struct DoctorArgs { /// Inventory missing parent references using cached auth only; no media or catalogue access #[arg(long, requires_all = ["live", "json"])] pub missing_reference_inventory: bool, /// Emit a redacted JSON diagnostics report #[arg(long)] pub json: bool, /// Include live iCloud checks. The default doctor run is local-only. #[arg(long)] pub live: bool, } /// Arguments for a privacy-safe, offline support export. #[derive(Parser, Debug, Clone)] pub struct SupportExportArgs { /// Write one locally reviewable JSON file; existing files are never replaced #[arg(long, default_value = "kei-support.json")] pub output: std::path::PathBuf, } /// Manifest export format. #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] pub enum ManifestFormat { /// Pretty-printed JSON array Json, /// CSV with one row per asset version Csv, } /// Arguments for the manifest command. #[derive(Parser, Debug, Clone)] pub struct ManifestArgs { /// Output format #[arg(long, value_enum, default_value_t = ManifestFormat::Json)] pub format: ManifestFormat, } /// Arguments for the import-existing command. #[derive(Parser, Debug, Clone)] pub struct ImportArgs { #[command(flatten)] pub password: PasswordArgs, /// Library/libraries to import. Repeatable; default `primary`. Same value /// grammar as `[filters].libraries`: a CloudKit zone name (full UUID or /// the truncated 8-char `SharedSync-` prefix that `{library}` renders /// into paths), the sentinels `primary` / `shared` / `all` / `none`, or /// `!name` to exclude. #[arg(long = "library", value_parser = non_empty_string)] pub libraries: Vec, /// Number of recent photos to check (`--recent 100`). The `--recent Nd` /// days form is only supported in `sync`; import-existing errors on use. #[arg(long, value_parser = parse_recent_limit)] pub recent: Option, /// Scan and report matches without writing to the state DB. Useful for /// verifying that TOML path and filename settings match the tree you're /// importing before committing. #[arg(long)] pub dry_run: bool, /// Disable progress bar #[arg(long)] pub no_progress_bar: bool, /// Override the empty-library safety guard. Without this flag, /// `import-existing` aborts when a selected library returns zero /// assets while the state DB has prior asset rows -- often a /// transient iCloud permissions glitch or stale auth, where /// scanning would silently produce a misleading `matched: 0` /// report. Set this if you genuinely emptied the library, or if /// you're attaching a new sub-library to an account that already /// has data (the prior-row check is global, not per-zone). #[arg(long, env = "KEI_FORCE_EMPTY")] pub force_empty: bool, /// Verify a cloud prefix before adopting a same-name, same-size local file. #[arg(long)] pub strict: bool, } /// Arguments for the verify command. #[derive(Parser, Debug, Clone)] pub struct VerifyArgs { /// Verify checksums (slower but more thorough) #[arg(long)] pub checksums: bool, } /// Arguments for the reconcile command. #[derive(Parser, Debug, Clone)] pub struct ReconcileArgs { #[command(subcommand)] pub action: Option, /// Show what would change without updating the state database. #[arg(long)] pub dry_run: bool, } // ── New subcommand types ───────────────────────────────────────────── /// Login subcommands. #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum LoginCommand { /// Request a 2FA code be sent to your trusted devices GetCode, /// Submit a 2FA code non-interactively (for Docker / headless use) SubmitCode { /// 6-digit 2FA code from your trusted device #[arg(value_parser = parse_2fa_code)] code: String, }, } /// List subcommands. #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum ListCommand { /// List available albums Albums, /// List available libraries Libraries, } /// Password management actions. #[derive(Subcommand, Debug, Clone, Copy, PartialEq, Eq)] pub enum PasswordAction { /// Store a password from a prompt, password file, or password command #[command( after_help = "Headless examples:\n kei password --password-file set\n kei password --password-command set" )] Set, /// Remove a stored password Clear, /// Show which credential backend is active (keyring, encrypted-file, none) Backend, } /// Reset subcommands. #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum ResetCommand { /// Delete the state database and start fresh State { /// Skip confirmation prompt (required when stdin is not a terminal) #[arg(long, short = 'y')] yes: bool, }, /// Clear stored sync tokens so the next sync does a full enumeration. /// /// Without `--yes`, prompts for confirmation on a TTY (the next sync will /// re-enumerate every asset, which can be expensive on large libraries) /// and errors out under non-interactive use, matching `reset state`. SyncToken { /// Skip confirmation prompt #[arg(long, short = 'y')] yes: bool, }, /// Discard the local iCloud session, including trust tokens. /// /// Removes the cookie jar, persisted session, and response cache for the /// configured account, so the next `kei login` runs the full password + /// 2FA flow again. The stored password (`kei password`) and the state /// database are kept. Useful when Apple rejects 2FA pushes for a stale /// session. Without `--yes`, prompts for confirmation on a TTY and /// errors out under non-interactive use, matching `reset sync-token`. Session { /// Skip confirmation prompt #[arg(long, short = 'y')] yes: bool, }, } /// Config management actions. #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum ConfigAction { /// Dump resolved config as TOML and exit Show, /// Interactively generate a config file Setup { /// Output path (overrides --config) #[arg(short = 'o', long)] output: Option, }, } /// Arguments for `kei install`. /// /// Per-platform defaults: Linux installs a per-user systemd unit unless /// `--system` is passed; macOS installs a per-user launchd agent; Windows /// registers the service with the Service Control Manager under the /// current user's account. #[derive(Args, Debug, Clone)] pub struct InstallArgs { /// Install per-user (Linux/macOS default; ignored on Windows). #[arg(long, conflicts_with = "system")] pub user: bool, /// Install system-wide (Linux only; requires root). On macOS and /// Windows this is rejected with a pointer to the per-user install. #[arg(long, conflicts_with = "user")] pub system: bool, /// Render the service artifact and report what would happen, without /// writing files or invoking the platform service manager (no /// `systemctl daemon-reload`, no `launchctl bootstrap`, no SCM /// `CreateService`). #[arg(long)] pub dry_run: bool, } /// Arguments for `kei uninstall`. #[derive(Args, Debug, Clone)] pub struct UninstallArgs { /// Also remove the state database, configuration, and stored /// credentials. Default off: data is sacred, removal is opt-in. #[arg(long)] pub purge: bool, } /// Arguments for `kei service run`. /// /// Shares the reduced runtime [`SyncArgs`] with `kei sync`, but omits /// friendly-mode toggles because service mode forces friendly output off. /// Carried as its own struct so the surrounding [`ServiceAction`] enum /// does not balloon by ~600 bytes (the size of `SyncArgs`) on the /// unrelated `Status` variant. #[derive(Args, Debug, Clone)] pub struct ServiceRunArgs { #[command(flatten)] pub password: PasswordArgs, #[command(flatten)] pub sync: SyncArgs, } /// Subcommands under `kei service`. #[derive(Subcommand, Debug, Clone)] pub enum ServiceAction { /// Run the service worker (invoked by launchd / systemd / Windows SCM, /// or directly for testing). Equivalent to `kei sync` with service-mode /// defaults: when no other source provides a watch interval, defaults /// to once per day (24 hours / 86,400 seconds). Run(Box), /// Show whether kei is registered as a service on this host and when /// it last started. For a combined summary including photo library /// stats, use `kei status` instead. Status, } /// Subcommands for kei. #[allow( clippy::large_enum_variant, reason = "parsed once at startup; boxing flattened clap args would add command plumbing without runtime benefit" )] #[derive(Subcommand, Debug, Clone)] pub enum Command { /// Download photos from iCloud #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Sync")] Sync { #[command(flatten)] friendly: FriendlyArgs, #[command(flatten)] password: PasswordArgs, #[command(flatten)] sync: SyncArgs, }, /// Authenticate interactively (creates/refreshes session tokens) #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Login")] Login { #[command(flatten)] password: PasswordArgs, #[command(subcommand)] subcommand: Option, }, /// List available albums or libraries #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/List")] List { #[command(flatten)] password: PasswordArgs, /// Library/libraries to list albums from. Repeatable; default /// `primary` (the PrimarySync zone). Same value grammar as /// `[filters].libraries`: a CloudKit zone name (full UUID or the /// truncated 8-char `SharedSync-` prefix that `{library}` renders /// into paths), the sentinels `primary` / `shared` / `all` / /// `none`, or `!name` to exclude. Only consulted by /// `kei list albums`; `kei list libraries` always lists every /// library on the account. #[arg(long = "library", env = "KEI_LIBRARY", value_parser = non_empty_string)] libraries: Vec, #[command(subcommand)] what: ListCommand, }, /// Manage stored credentials (OS keyring or encrypted file) #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Password")] Password { #[command(flatten)] password: PasswordArgs, #[command(subcommand)] action: PasswordAction, }, /// Reset state database, sync tokens, or the local session #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Reset")] Reset { #[command(subcommand)] what: ResetCommand, }, /// Adopt an explicitly confirmed legacy database after fresh authentication MigrateState(MigrateStateArgs), /// Config management #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Config")] Config { #[command(subcommand)] action: ConfigAction, }, /// Show sync status and database summary #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Status")] Status(StatusArgs), /// Run redacted local diagnostics for support #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Doctor")] Doctor(DoctorArgs), /// Export privacy-safe saved diagnostics without login, network, scans or repairs #[command( after_help = "Review the JSON file locally before attaching it to a bug report. No automatic upload. Missing evidence is reported explicitly; no new sync is required.\n\nDocumentation: https://github.com/rhoopr/kei/blob/main/docs/support-export.md" )] SupportExport(SupportExportArgs), /// Export the local state catalog without contacting iCloud #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Manifest")] Manifest(ManifestArgs), /// Import existing local files into the state database #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Import-Existing")] ImportExisting(ImportArgs), /// Verify downloaded files exist and optionally check checksums #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Verify")] Verify(VerifyArgs), /// Reconcile state database with files on disk: mark assets as /// failed when their local file is missing or truncated, so the /// next sync re-downloads them. #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Reconcile")] Reconcile(ReconcileArgs), /// Register kei as a system service (launchd on macOS, systemd on /// Linux, Service Control Manager on Windows). Inside a Docker /// container the command logs that compose-managed deployments are /// already supervised and exits without writing anything. #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Install")] Install(InstallArgs), /// Remove the kei service registered by `kei install`. Pass `--purge` /// to also delete the state database, configuration, and stored /// credentials. #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Service")] Uninstall(UninstallArgs), /// Service-mode operations: run the long-lived worker, or query /// service registration status. #[command(after_help = "Documentation: https://github.com/rhoopr/kei/wiki/Service")] Service { #[command(subcommand)] action: ServiceAction, }, } #[derive(Parser, Debug)] #[command( name = "kei", about = "kei: photo sync engine", version, subcommand_required = true, arg_required_else_help = true, after_help = "Getting started:\n \ kei config setup Generate your config interactively\n \ kei sync Download your photos (runs after setup)\n\n \ Common:\n \ kei status See what's been downloaded\n \ kei login Sign in to iCloud\n \ kei list albums Browse albums and libraries\n\n \ Advanced:\n \ kei install Register as a background service\n \ kei verify Verify downloaded files\n \ kei import-existing Adopt an existing local photo tree\n\n \ Documentation: https://github.com/rhoopr/kei/wiki\n \ Support and issues: https://github.com/rhoopr/kei/issues" )] pub struct Cli { // ── Global options ────────────────────────────────────────────── /// Log level #[arg(long, value_enum, global = true, env = "KEI_LOG_LEVEL")] pub log_level: Option, /// Verbose output (alias for --log-level info, restores full target+timestamp format) #[arg(long, short = 'v', global = true)] pub verbose: bool, /// Path to TOML config file #[arg( long, global = true, default_value = "~/.config/kei/config.toml", env = "KEI_CONFIG" )] pub config: String, #[command(subcommand)] pub command: Command, } impl FriendlyArgs { /// User-stated friendly-mode preference, distilled from the /// `--friendly` / `--no-friendly` pair. `Some(true)` and `Some(false)` are /// explicit user requests; `None` means neither flag was set. #[must_use] pub fn request(&self) -> Option { if self.no_friendly { Some(false) } else if self.friendly { Some(true) } else { None } } } impl Cli { /// User-stated friendly-mode preference, distilled from the /// `--friendly` / `--no-friendly` pair. `Some(true)` and `Some(false)` are explicit user requests /// that override the TOML and the auto-detected default; `None` means /// neither flag was set, so the resolution chain falls through to TOML /// then to the default-on-for-TTY policy. /// /// Clap's `overrides_with` makes the two flags mutually exclusive at the /// argument level: when both appear, the last one wins, so the /// post-parse state has at most one of `friendly` / `no_friendly` set. #[must_use] pub fn friendly_request(&self) -> Option { match &self.command { Command::Sync { friendly, .. } => friendly.request(), _ => None, } } } /// Rendered CLI parse failure with a concrete process exit code. #[derive(Debug)] pub struct ParseCliError { rendered: String, exit_code: i32, use_stderr: bool, } impl ParseCliError { #[must_use] pub fn rendered(&self) -> &str { &self.rendered } #[must_use] pub const fn exit_code(&self) -> i32 { self.exit_code } #[must_use] pub const fn use_stderr(&self) -> bool { self.use_stderr } } impl std::fmt::Display for ParseCliError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.rendered) } } impl std::error::Error for ParseCliError {} fn command_context_from_argv(argv: &[OsString]) -> Option<&str> { let command = ::command(); argv.iter() .skip(1) .filter_map(|token| token.to_str()) .find(|token| command.find_subcommand(token).is_some()) } fn should_append_removed_sync_surface_hint(argv: &[OsString], err: &clap::Error) -> bool { if err.kind() != clap::error::ErrorKind::UnknownArgument { return false; } match command_context_from_argv(argv) { None | Some("sync") | Some("import-existing") => true, Some("service") => argv .iter() .skip(1) .skip_while(|token| *token != OsStr::new("service")) .skip(1) .any(|token| token == OsStr::new("run")), Some(_) => false, } } fn with_removed_sync_surface_hint(message: String) -> String { format!( "{message}\n\nnote: v0.20 removed durable sync CLI flags. Put persistent sync settings in config.toml instead.\n See {}", crate::upgrade_hints::V020_MIGRATION_URL ) } /// Classify the Docker launch before any volume ownership initialization. /// Clap still consumes option values, so a value named `support-export` cannot /// masquerade as the command. Partial export arguments must also avoid repair. #[must_use] pub(crate) fn is_early_read_only_invocation(itr: I) -> bool where I: IntoIterator, T: Into + Clone, { fn query_command(command: clap::Command) -> clap::Command { command .disable_help_flag(true) .disable_version_flag(true) .disable_help_subcommand(true) .arg( clap::Arg::new("entrypoint_help") .long("help") .short('h') .action(clap::ArgAction::SetTrue), ) .arg( clap::Arg::new("entrypoint_version") .long("version") .short('V') .action(clap::ArgAction::SetTrue), ) .mut_args(|argument| argument.env(None::<&str>)) .mut_subcommands(query_command) } // Derive help from the same tree rather than guessing from tokens or // rendering help. Discard captured env bindings so invalid configuration // or credential environment cannot alter this argv decision. let command = query_command(::command()); let help = clap::Command::new("help").subcommands(command.get_subcommands().cloned()); command .subcommand(help) .ignore_errors(true) .try_get_matches_from(itr) .is_ok_and(|matches| { matches.subcommand_name() == Some("support-export") || matches .subcommand_matches("doctor") .is_some_and(|doctor| doctor.get_flag("missing_reference_inventory")) || matches .subcommand_matches("help") .is_some_and(|help| help.subcommand_name() == Some("support-export")) }) } /// Parse CLI arguments and render migration hints for removed sync flags. pub fn parse_cli_with_sources(itr: I) -> Result where I: IntoIterator, T: Into + Clone, { let argv: Vec = itr.into_iter().map(Into::into).collect(); let cmd = ::command(); let matches = cmd .try_get_matches_from(&argv) .map_err(|err| ParseCliError { rendered: if should_append_removed_sync_surface_hint(&argv, &err) { with_removed_sync_surface_hint(err.to_string()) } else { err.to_string() }, exit_code: err.exit_code(), use_stderr: err.use_stderr(), })?; let cli = Cli::from_arg_matches(&matches).map_err(|err| ParseCliError { rendered: err.to_string(), exit_code: err.exit_code(), use_stderr: err.use_stderr(), })?; Ok(cli) } impl Command { /// Inject the `ICLOUD_PASSWORD` value captured before `Cli::parse()`. /// /// The env var is removed from the process environment for security /// (prevents leaking via `/proc/*/environ`), but clap's `env` attribute /// never sees it. This method restores it into whichever `PasswordArgs` /// the active command carries. pub fn inject_env_password(&mut self, env_password: Option) { let Some(pw) = env_password else { return }; if let Some(args) = self.password_args_mut() && args.password.is_none() { args.password = Some(pw); } } /// Return a mutable reference to the command's `PasswordArgs`, if any. fn password_args_mut(&mut self) -> Option<&mut PasswordArgs> { match self { Self::Sync { password, .. } | Self::Login { password, .. } | Self::List { password, .. } | Self::Password { password, .. } => Some(password), Self::ImportExisting(args) => Some(&mut args.password), Self::MigrateState(args) => Some(&mut args.password), Self::Service { action: ServiceAction::Run(args), } => Some(&mut args.password), Self::Reset { .. } | Self::Config { .. } | Self::Status(_) | Self::Doctor(_) | Self::SupportExport(_) | Self::Manifest(_) | Self::Verify(_) | Self::Reconcile(_) | Self::Install(_) | Self::Uninstall(_) | Self::Service { action: ServiceAction::Status, } => None, } } } #[cfg(test)] #[allow( clippy::multiple_unsafe_ops_per_block, clippy::undocumented_unsafe_blocks, reason = "env var ops in tests are sequenced under a mutex — splitting/documenting adds noise" )] mod tests { use super::*; use clap::Parser; fn parse(args: &[&str]) -> Cli { Cli::try_parse_from(args).unwrap() } fn assert_removed_sync_flag(args: &[&str]) { let err = Cli::try_parse_from(args).expect_err("removed sync flag must fail to parse"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } fn assert_removed_global_option(option: &str, value: &str) { let err = Cli::try_parse_from(["kei", option, value]) .expect_err("removed global option must fail to parse"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } fn assert_removed_sync_option(tail: &[&str]) { let mut args = vec!["kei"]; args.extend_from_slice(tail); assert_removed_sync_flag(&args); } #[test] fn migrate_state_requires_explicit_source_and_ownership_confirmation() { assert!(Cli::try_parse_from(["kei", "migrate-state", "--legacy-db", "source.db"]).is_err()); assert!(Cli::try_parse_from(["kei", "migrate-state", "--confirm-ownership"]).is_err()); let cli = Cli::try_parse_from([ "kei", "migrate-state", "--legacy-db", "source.db", "--confirm-ownership", "--code", "123456", ]) .unwrap(); match cli.command { Command::MigrateState(args) => { assert_eq!(args.legacy_db, PathBuf::from("source.db")); assert!(args.confirm_ownership); assert_eq!(args.code.as_deref(), Some("123456")); } _ => panic!("expected migration command"), } } #[test] fn command_context_uses_clap_subcommand_metadata() { let command = ::command(); for subcommand in command.get_subcommands() { let subcommand_name = subcommand.get_name(); let argv = [ OsString::from("kei"), OsString::from(subcommand_name), OsString::from("--download-dir"), OsString::from("/photos"), ]; assert_eq!( command_context_from_argv(&argv), Some(subcommand_name), "subcommand `{subcommand_name}` should be discovered from clap metadata" ); } } #[test] fn removed_sync_surface_hint_stays_on_sync_like_commands() { for argv in [ &["kei", "--download-dir", "/photos"][..], &["kei", "sync", "--download-dir", "/photos"][..], &["kei", "import-existing", "--download-dir", "/photos"][..], &["kei", "service", "run", "--download-dir", "/photos"][..], ] { let err = parse_cli_with_sources(argv).expect_err("removed flag must fail"); assert!( err.rendered() .contains("v0.20 removed durable sync CLI flags"), "sync-like command should include migration hint: {}", err.rendered() ); } } #[test] fn removed_sync_surface_hint_skips_other_metadata_commands() { for argv in [ &["kei", "doctor", "--download-dir", "/photos"][..], &["kei", "manifest", "--download-dir", "/photos"][..], &["kei", "install", "--download-dir", "/photos"][..], &["kei", "service", "status", "--download-dir", "/photos"][..], ] { let err = parse_cli_with_sources(argv).expect_err("removed flag must fail"); assert!( !err.rendered() .contains("v0.20 removed durable sync CLI flags"), "non-sync command should not include migration hint: {}", err.rendered() ); } } // ── RecentLimit parser ────────────────────────────────────────── #[test] fn parse_recent_limit_bare_count() { assert_eq!(parse_recent_limit("100").unwrap(), RecentLimit::Count(100)); assert_eq!(parse_recent_limit("1").unwrap(), RecentLimit::Count(1)); } #[test] fn parse_recent_limit_days_suffix() { assert_eq!(parse_recent_limit("30d").unwrap(), RecentLimit::Days(30)); assert_eq!(parse_recent_limit("1d").unwrap(), RecentLimit::Days(1)); } #[test] fn parse_recent_limit_rejects_zero() { assert!(parse_recent_limit("0").unwrap_err().contains("zero")); assert!(parse_recent_limit("0d").unwrap_err().contains("zero")); } #[test] fn parse_recent_limit_rejects_empty() { assert!(parse_recent_limit("").is_err()); } #[test] fn parse_recent_limit_rejects_unknown_suffix() { // Only `d` is accepted today. Other units (w, m, y) would need // explicit design decisions around month/year boundaries. assert!(parse_recent_limit("3w").is_err()); assert!(parse_recent_limit("1y").is_err()); assert!(parse_recent_limit("2m").is_err()); assert!(parse_recent_limit("30days").is_err()); } #[test] fn parse_recent_limit_rejects_garbage() { assert!(parse_recent_limit("abc").is_err()); assert!(parse_recent_limit("-5").is_err()); assert!(parse_recent_limit("10.5").is_err()); assert!(parse_recent_limit("10 d").is_err()); } #[test] fn recent_limit_toml_parses_integer() { #[derive(serde::Deserialize)] struct Wrap { recent: RecentLimit, } let got: Wrap = toml::from_str("recent = 100").unwrap(); assert_eq!(got.recent, RecentLimit::Count(100)); } #[test] fn recent_limit_toml_parses_days_string() { #[derive(serde::Deserialize)] struct Wrap { recent: RecentLimit, } let got: Wrap = toml::from_str(r#"recent = "30d""#).unwrap(); assert_eq!(got.recent, RecentLimit::Days(30)); } #[test] fn recent_limit_toml_rejects_zero_integer() { #[derive(serde::Deserialize, Debug)] struct Wrap { #[allow(dead_code)] recent: RecentLimit, } let err = toml::from_str::("recent = 0") .unwrap_err() .to_string(); assert!(err.contains("zero"), "got: {err}"); } #[test] fn recent_limit_toml_rejects_garbage_string() { #[derive(serde::Deserialize, Debug)] struct Wrap { #[allow(dead_code)] recent: RecentLimit, } assert!(toml::from_str::(r#"recent = "abc""#).is_err()); } #[test] fn recent_limit_display_roundtrip() { assert_eq!(RecentLimit::Count(100).to_string(), "100"); assert_eq!(RecentLimit::Days(30).to_string(), "30d"); } #[test] fn recent_scope_parses_global_and_per_filter() { let cli = parse(&["kei", "sync", "--recent", "100", "--recent-scope", "global"]); let Command::Sync { sync, .. } = cli.command else { panic!("expected sync command"); }; assert_eq!(sync.recent_scope, Some(RecentScope::Global)); let cli = parse(&[ "kei", "sync", "--recent", "100", "--recent-scope", "per-filter", ]); let Command::Sync { sync, .. } = cli.command else { panic!("expected sync command"); }; assert_eq!(sync.recent_scope, Some(RecentScope::PerFilter)); } #[test] fn recent_scope_rejects_unknown_value() { let err = Cli::try_parse_from(["kei", "sync", "--recent-scope", "per_album"]) .expect_err("invalid recent scope should fail"); assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue); } #[test] fn recent_limit_serializes_count_as_integer() { let json = serde_json::to_string(&RecentLimit::Count(5)).unwrap(); assert_eq!(json, "5"); } #[test] fn recent_limit_serializes_days_as_string() { let json = serde_json::to_string(&RecentLimit::Days(7)).unwrap(); assert_eq!(json, "\"7d\""); } fn base_args() -> Vec<&'static str> { vec!["kei", "sync"] } /// Scrub auth-related env vars for the duration of the returned guard so /// tests that exercise clap's flag parsing don't get contaminated when the /// developer has `ICLOUD_USERNAME` / `ICLOUD_PASSWORD` exported (via /// `.env` sourcing for live tests). A process-wide mutex serializes /// concurrent calls to this helper. /// /// Note that this only protects against other callers of `scrub_auth_env`. /// `setenv`/`getenv` on POSIX aren't thread-safe against each other, so an /// unrelated test reading an env var while the guard is mutating could /// theoretically race. The CLI unit tests only touch these two vars via /// clap's `env = "..."` attributes during `try_parse_from`, which happens /// synchronously on one thread per test — so in practice the guard is /// sufficient for this suite. If a future test reads env from another /// thread, it needs to coordinate through the same mutex. fn scrub_auth_env() -> AuthEnvGuard { use std::sync::Mutex; static LOCK: Mutex<()> = Mutex::new(()); let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); let prev_user = std::env::var("ICLOUD_USERNAME").ok(); let prev_pw = std::env::var("ICLOUD_PASSWORD").ok(); // SAFETY: the enclosing MutexGuard serializes every other caller of // scrub_auth_env, and the test suite does not read these env vars // from separate threads. unsafe { std::env::remove_var("ICLOUD_USERNAME"); std::env::remove_var("ICLOUD_PASSWORD"); } AuthEnvGuard { _lock: guard, prev_user, prev_pw, } } struct AuthEnvGuard { _lock: std::sync::MutexGuard<'static, ()>, prev_user: Option, prev_pw: Option, } impl Drop for AuthEnvGuard { fn drop(&mut self) { // SAFETY: still holding the static mutex, so restoration is // exclusive under the same "no cross-thread readers" condition // described on scrub_auth_env. unsafe { if let Some(v) = self.prev_user.take() { std::env::set_var("ICLOUD_USERNAME", v); } if let Some(v) = self.prev_pw.take() { std::env::set_var("ICLOUD_PASSWORD", v); } } } } // ── Global args ─────────────────────────────────────────────── #[test] fn test_username_global_removed() { assert_removed_global_option("--username", "test@example.com"); } #[test] fn test_domain_global_removed() { assert_removed_global_option("--domain", "cn"); } #[test] fn test_data_dir_global_removed() { assert_removed_global_option("--data-dir", "/config"); } #[test] fn test_config_flag_default() { let cli = parse(&base_args()); assert_eq!(cli.config, "~/.config/kei/config.toml"); } #[test] fn test_config_flag_custom() { let mut args = base_args(); args.extend(["--config", "/etc/kei.toml"]); let cli = parse(&args); assert_eq!(cli.config, "/etc/kei.toml"); } // ── Explicit subcommand required ────────────────────────────── #[test] fn test_no_subcommand_requires_command() { let _guard = scrub_auth_env(); let err = Cli::try_parse_from(["kei"]).expect_err("missing command must fail"); assert_eq!( err.kind(), clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand ); } #[test] fn test_no_subcommand_with_sync_flag_rejected() { assert_removed_sync_option(&["--download-dir", "/photos"]); } // ── New subcommands ─────────────────────────────────────────── #[test] fn test_login_subcommand() { let cli = Cli::try_parse_from(["kei", "login"]).unwrap(); assert!(matches!( cli.command, Command::Login { subcommand: None, .. } )); } #[test] fn test_login_get_code() { let cli = Cli::try_parse_from(["kei", "login", "get-code"]).unwrap(); assert!(matches!( cli.command, Command::Login { subcommand: Some(LoginCommand::GetCode), .. } )); } #[test] fn test_login_submit_code() { let cli = Cli::try_parse_from(["kei", "login", "submit-code", "123456"]).unwrap(); match cli.command { Command::Login { subcommand: Some(LoginCommand::SubmitCode { code }), .. } => assert_eq!(code, "123456"), _ => panic!("Expected Login SubmitCode"), } } #[test] fn test_list_albums() { let cli = Cli::try_parse_from(["kei", "list", "albums"]).unwrap(); assert!(matches!( cli.command, Command::List { what: ListCommand::Albums, .. } )); } #[test] fn test_list_libraries() { let cli = Cli::try_parse_from(["kei", "list", "libraries"]).unwrap(); assert!(matches!( cli.command, Command::List { what: ListCommand::Libraries, .. } )); } #[test] fn test_password_set() { let cli = Cli::try_parse_from(["kei", "password", "set"]).unwrap(); assert!(matches!( cli.command, Command::Password { action: PasswordAction::Set, .. } )); } #[test] fn test_password_clear() { let cli = Cli::try_parse_from(["kei", "password", "clear"]).unwrap(); assert!(matches!( cli.command, Command::Password { action: PasswordAction::Clear, .. } )); } #[test] fn test_password_backend() { let cli = Cli::try_parse_from(["kei", "password", "backend"]).unwrap(); assert!(matches!( cli.command, Command::Password { action: PasswordAction::Backend, .. } )); } #[test] fn test_reset_state() { let cli = Cli::try_parse_from(["kei", "reset", "state", "--yes"]).unwrap(); match cli.command { Command::Reset { what: ResetCommand::State { yes }, } => assert!(yes), _ => panic!("Expected Reset State"), } } #[test] fn install_dry_run_parses_with_user_flag() { let cli = Cli::try_parse_from(["kei", "install", "--user", "--dry-run"]).unwrap(); let args = match cli.command { Command::Install(a) => a.clone(), other => panic!("expected Install, got {other:?}"), }; assert!(args.user); assert!(args.dry_run); assert!(!args.system); } #[test] fn install_user_and_system_conflict_at_parse_time() { // clap rejects mutually exclusive flags during parse; this guards // the `conflicts_with` annotation on InstallArgs. let err = Cli::try_parse_from(["kei", "install", "--user", "--system"]).unwrap_err(); assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); } #[test] fn uninstall_purge_parses_to_struct_field() { let cli = Cli::try_parse_from(["kei", "uninstall", "--purge"]).unwrap(); assert!(matches!( cli.command, Command::Uninstall(UninstallArgs { purge: true }) )); } #[test] fn test_reset_sync_token() { let cli = Cli::try_parse_from(["kei", "reset", "sync-token"]).unwrap(); assert!(matches!( cli.command, Command::Reset { what: ResetCommand::SyncToken { yes: false }, } )); } #[test] fn test_reset_sync_token_with_yes() { let cli = Cli::try_parse_from(["kei", "reset", "sync-token", "--yes"]).unwrap(); assert!(matches!( cli.command, Command::Reset { what: ResetCommand::SyncToken { yes: true }, } )); } #[test] fn test_reset_sync_token_with_short_y() { let cli = Cli::try_parse_from(["kei", "reset", "sync-token", "-y"]).unwrap(); assert!(matches!( cli.command, Command::Reset { what: ResetCommand::SyncToken { yes: true }, } )); } #[test] fn test_reset_session() { let cli = Cli::try_parse_from(["kei", "reset", "session"]).unwrap(); assert!(matches!( cli.command, Command::Reset { what: ResetCommand::Session { yes: false }, } )); } #[test] fn test_reset_session_with_yes() { let cli = Cli::try_parse_from(["kei", "reset", "session", "--yes"]).unwrap(); assert!(matches!( cli.command, Command::Reset { what: ResetCommand::Session { yes: true }, } )); } #[test] fn test_reset_session_with_short_y() { let cli = Cli::try_parse_from(["kei", "reset", "session", "-y"]).unwrap(); assert!(matches!( cli.command, Command::Reset { what: ResetCommand::Session { yes: true }, } )); } #[test] fn test_config_show() { let cli = Cli::try_parse_from(["kei", "config", "show"]).unwrap(); assert!(matches!( cli.command, Command::Config { action: ConfigAction::Show, } )); } #[test] fn test_config_setup() { let cli = Cli::try_parse_from(["kei", "config", "setup"]).unwrap(); assert!(matches!( cli.command, Command::Config { action: ConfigAction::Setup { output: None }, } )); } #[test] fn test_config_setup_with_output() { let cli = Cli::try_parse_from(["kei", "config", "setup", "-o", "/tmp/config.toml"]).unwrap(); match cli.command { Command::Config { action: ConfigAction::Setup { output }, } => assert_eq!(output.as_deref(), Some("/tmp/config.toml")), _ => panic!("Expected Config Setup"), } } #[test] fn test_sync_retry_failed_flag() { assert_removed_sync_flag(&["kei", "sync", "--retry-failed", "--download-dir", "/photos"]); } // ── Legacy subcommand compat ────────────────────────────────── #[test] fn test_max_download_attempts_cli_parse() { assert_removed_sync_flag(&["kei", "sync", "--max-download-attempts", "5"]); } #[test] fn test_max_download_attempts_defaults_to_none() { assert_removed_sync_flag(&["kei", "sync", "--max-download-attempts", "5"]); } #[test] fn test_password_flag() { let mut args = base_args(); args.extend(["--password", "secret123"]); let cli = parse(&args); let Command::Sync { password, .. } = cli.command else { panic!("expected Sync command"); }; assert_eq!(password.password.as_deref(), Some("secret123")); } #[test] fn test_password_file_flag() { let _guard = scrub_auth_env(); let mut args = base_args(); args.extend(["--password-file", "/run/secrets/pw"]); let cli = parse(&args); let Command::Sync { password, .. } = cli.command else { panic!("expected Sync command"); }; assert_eq!(password.password_file.as_deref(), Some("/run/secrets/pw")); } #[test] fn test_password_command_flag() { let _guard = scrub_auth_env(); let mut args = base_args(); args.extend(["--password-command", "op read 'op://vault/icloud/pw'"]); let cli = parse(&args); let Command::Sync { password, .. } = cli.command else { panic!("expected Sync command"); }; assert_eq!( password.password_command.as_deref(), Some("op read 'op://vault/icloud/pw'") ); } #[test] fn test_password_conflicts_with_password_file() { let mut args = base_args(); args.extend(["--password", "pw", "--password-file", "/tmp/pw.txt"]); assert!(Cli::try_parse_from(&args).is_err()); } #[test] fn test_password_conflicts_with_password_command() { let mut args = base_args(); args.extend(["--password", "pw", "--password-command", "echo pw"]); assert!(Cli::try_parse_from(&args).is_err()); } #[test] fn test_password_file_conflicts_with_password_command() { let mut args = base_args(); args.extend([ "--password-file", "/tmp/pw", "--password-command", "echo pw", ]); assert!(Cli::try_parse_from(&args).is_err()); } #[test] fn sync_refresh_metadata_parses_to_struct_field() { let Command::Sync { sync, .. } = Cli::try_parse_from(["kei", "sync", "--refresh-metadata"]) .unwrap() .command else { panic!("expected Sync command"); }; assert!(sync.refresh_metadata); } #[test] fn sync_repair_capture_timestamps_requires_metadata_refresh() { assert!(Cli::try_parse_from(["kei", "sync", "--repair-capture-timestamps"]).is_err()); let Command::Sync { sync, .. } = Cli::try_parse_from([ "kei", "sync", "--refresh-metadata", "--repair-capture-timestamps", ]) .unwrap() .command else { panic!("expected Sync command"); }; assert!(sync.refresh_metadata); assert!(sync.repair_capture_timestamps); } #[test] fn sync_repair_truncated_parses_to_struct_field() { let Command::Sync { sync, .. } = Cli::try_parse_from(["kei", "sync", "--repair-truncated"]) .unwrap() .command else { panic!("expected Sync command"); }; assert!(sync.repair_truncated); } #[test] fn sync_repair_truncated_rejects_non_download_modes() { for conflicting in ["--dry-run", "--only-print-filenames", "--refresh-metadata"] { assert!( Cli::try_parse_from(["kei", "sync", "--repair-truncated", conflicting]).is_err(), "{conflicting} must conflict with --repair-truncated" ); } } #[test] fn test_save_password_flag() { let mut args = base_args(); args.push("--save-password"); let cli = parse(&args); let Command::Sync { sync, .. } = cli.command else { panic!("expected Sync command"); }; assert!(sync.save_password); } #[test] fn test_save_password_top_level_before_subcommand_is_rejected() { let err = Cli::try_parse_from(["kei", "--save-password", "sync"]).unwrap_err(); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } // ── Sync args ───────────────────────────────────────────────── #[test] fn test_library_accepts_custom_value() { assert_removed_sync_flag(&["kei", "--library", "SharedSync-ABCD1234"]); } #[test] fn test_library_repeatable_with_sentinels() { assert_removed_sync_flag(&["kei", "--library", "primary", "--library", "shared"]); } #[test] fn test_bandwidth_limit_bare_bytes() { assert_removed_sync_option(&["--bandwidth-limit", "1024"]); } #[test] fn test_bandwidth_limit_decimal_suffixes() { assert_removed_sync_option(&["--bandwidth-limit", "1.5M"]); } #[test] fn test_bandwidth_limit_binary_suffix() { assert_removed_sync_option(&["--bandwidth-limit", "2Mi"]); } #[test] fn test_bandwidth_limit_case_insensitive() { assert_eq!(parse_bandwidth_limit("500k"), Ok(500_000)); assert_eq!(parse_bandwidth_limit("500K"), Ok(500_000)); assert_eq!(parse_bandwidth_limit("1gib"), Ok(1024 * 1024 * 1024)); } #[test] fn test_bandwidth_limit_rejects_zero() { let mut args = base_args(); args.extend(["--bandwidth-limit", "0"]); assert!(Cli::try_parse_from(&args).is_err()); assert!(parse_bandwidth_limit("0K").is_err()); } #[test] fn test_bandwidth_limit_rejects_invalid() { assert!(parse_bandwidth_limit("").is_err()); assert!(parse_bandwidth_limit("abc").is_err()); assert!(parse_bandwidth_limit("10X").is_err()); assert!(parse_bandwidth_limit("-5M").is_err()); // 1.5M is NOW accepted (see test_bandwidth_limit_decimal_*). // Malformed decimals like `1..5M` or `1.5.5M` stay rejected. assert!(parse_bandwidth_limit("1..5M").is_err()); assert!(parse_bandwidth_limit("1.5.5M").is_err()); } // ── Decimal bandwidth values ─────────────────────────────────── #[test] fn test_bandwidth_limit_decimal_mb() { assert_eq!(parse_bandwidth_limit("1.5M"), Ok(1_500_000)); assert_eq!(parse_bandwidth_limit("2.5G"), Ok(2_500_000_000)); assert_eq!(parse_bandwidth_limit("0.5K"), Ok(500)); } #[test] fn test_bandwidth_limit_decimal_binary() { // 2.5 * 1_048_576 = 2_621_440 assert_eq!(parse_bandwidth_limit("2.5Mi"), Ok(2_621_440)); // 1.5 * 1024 = 1536 assert_eq!(parse_bandwidth_limit("1.5Ki"), Ok(1_536)); } #[test] fn test_bandwidth_limit_decimal_leading_dot() { assert_eq!(parse_bandwidth_limit(".5M"), Ok(500_000)); assert_eq!(parse_bandwidth_limit(".25K"), Ok(250)); } #[test] fn test_bandwidth_limit_decimal_trailing_dot() { // Trailing dot means integer-valued decimal - `1.M` is 1.0 MB/s. assert_eq!(parse_bandwidth_limit("1.M"), Ok(1_000_000)); } #[test] fn test_bandwidth_limit_decimal_rounds_to_zero_rejected() { // 0.0001 * 1000 = 0.1 bytes/sec, rounds to 0 - reject so users // don't think kei is throttling when it effectively isn't. let err = parse_bandwidth_limit("0.0001K").unwrap_err(); assert!(err.contains("rounds to zero"), "{err}"); // 0.4 bare bytes/sec rounds to 0 too. assert!(parse_bandwidth_limit("0.4").is_err()); } #[test] fn test_bandwidth_limit_decimal_rounds_to_nearest_byte() { // 0.1 * 1000 = 100 in theory, 99.99999... in f64. Round to 100. assert_eq!(parse_bandwidth_limit("0.1K"), Ok(100)); } #[test] fn test_bandwidth_limit_rejects_special_floats() { // f64::parse accepts "nan", "inf", "infinity" - but these make no // sense as a bandwidth value. assert!(parse_bandwidth_limit("nanK").is_err()); assert!(parse_bandwidth_limit("infM").is_err()); assert!(parse_bandwidth_limit("inf").is_err()); } #[test] fn test_max_retries_custom() { assert_removed_sync_option(&["--max-retries", "10"]); } #[test] fn test_max_retries_zero_disables() { assert_removed_sync_option(&["--max-retries", "0"]); } #[test] fn test_temp_suffix_custom() { assert_removed_sync_option(&["--temp-suffix", ".downloading"]); } #[test] fn test_skip_videos_flag() { assert_removed_sync_option(&["--skip-videos"]); } #[test] fn test_unfiled_flag_default_none() { let cli = parse(&base_args()); let Command::Sync { sync, .. } = cli.command else { panic!("expected Sync command"); }; assert_eq!(sync.config_overrides.unfiled, None); } #[test] fn test_unfiled_flag_bare_true() { assert_removed_sync_option(&["--unfiled"]); } #[test] fn test_unfiled_flag_explicit_false() { assert_removed_sync_option(&["--unfiled", "false"]); } #[test] fn test_unfiled_flag_explicit_true() { assert_removed_sync_option(&["--unfiled", "true"]); } #[test] fn test_skip_videos_explicit_false() { assert_removed_sync_option(&["--skip-videos", "false"]); } #[test] fn test_skip_photos_flag() { assert_removed_sync_option(&["--skip-photos"]); } #[test] fn test_force_size_flag() { assert_removed_sync_option(&["--force-size"]); } #[cfg(feature = "xmp")] #[test] fn test_set_exif_datetime_flag() { assert_removed_sync_option(&["--set-exif-datetime"]); } #[cfg(feature = "xmp")] #[test] fn test_embed_xmp_flag() { assert_removed_sync_option(&["--embed-xmp"]); } #[cfg(feature = "xmp")] #[test] fn test_embed_xmp_flag_explicit_false() { assert_removed_sync_option(&["--embed-xmp=false"]); } #[cfg(feature = "xmp")] #[test] fn test_xmp_sidecar_flag() { assert_removed_sync_option(&["--xmp-sidecar"]); } #[test] fn test_no_progress_bar_flag() { let mut args = base_args(); args.push("--no-progress-bar"); let cli = parse(&args); let Command::Sync { sync, .. } = cli.command else { panic!("expected Sync command"); }; assert!(sync.no_progress_bar); } #[test] fn test_no_progress_bar_rejects_value() { for value in ["true", "false"] { let flag = format!("--no-progress-bar={value}"); let mut args = base_args(); args.push("--no-progress-bar"); args.push(value); assert!(Cli::try_parse_from(&args).is_err()); assert!(Cli::try_parse_from(["kei", "sync", flag.as_str()]).is_err()); assert!(Cli::try_parse_from(["kei", flag.as_str(), "sync"]).is_err()); } } #[test] fn test_keep_unicode_in_filenames_flag() { assert_removed_sync_option(&["--keep-unicode-in-filenames"]); } #[test] fn test_notify_systemd_flag() { assert_removed_sync_option(&["--notify-systemd"]); } #[test] fn test_pid_file_flag() { assert_removed_sync_option(&["--pid-file", "/tmp/kei.pid"]); } #[test] fn test_reconcile_every_n_cycles_flag() { assert_removed_sync_option(&["--reconcile-every-n-cycles", "24"]); } #[test] fn test_reconcile_every_n_cycles_default_unset() { let cli = parse(&base_args()); let Command::Sync { sync, .. } = cli.command else { panic!("expected Sync command"); }; assert!(sync.config_overrides.reconcile_every_n_cycles.is_none()); } // 0 is "off" via TOML (or absence); the CLI flag rejects it so users // omit the flag instead of passing a magic value. Anything else <0 or // non-numeric also fails clap's range parser. #[test] fn test_reconcile_every_n_cycles_rejects_zero() { assert_removed_sync_option(&["--reconcile-every-n-cycles", "0"]); } #[test] fn test_notification_script_flag() { assert_removed_sync_option(&["--notification-script", "/tmp/notify.sh"]); } #[test] fn test_report_json_flag() { assert_removed_sync_option(&["--report-json", "/tmp/run.json"]); } // ── Enum variants ────────────────────────────────────────────── #[test] fn test_size_all_variants() { assert_removed_sync_option(&["--size", "medium"]); } #[test] fn test_live_photo_size_all_variants() { assert_removed_sync_option(&["--live-photo-size", "medium"]); } #[test] fn test_live_photo_mov_filename_policy_all_variants() { assert_removed_sync_option(&["--live-photo-mov-filename-policy", "original"]); } #[test] fn test_align_raw_all_variants() { assert_removed_sync_option(&["--align-raw", "prefer-original"]); } #[test] fn test_align_raw_rejects_invalid() { let mut args = base_args(); args.extend(["--align-raw", "bogus"]); assert!(Cli::try_parse_from(&args).is_err()); } #[test] fn test_file_match_policy_all_variants() { assert_removed_sync_option(&["--file-match-policy", "name-id7"]); } #[test] fn test_log_level_all_variants() { for (input, expected) in [ ("debug", LogLevel::Debug), ("info", LogLevel::Info), ("warn", LogLevel::Warn), ("error", LogLevel::Error), ] { let mut args = base_args(); args.extend(["--log-level", input]); let cli = parse(&args); assert_eq!(cli.log_level, Some(expected), "log_level variant: {input}"); } } // ── Optional value flags ─────────────────────────────────────── #[test] fn test_folder_structure_custom() { assert_removed_sync_option(&["--folder-structure", "%Y-%m"]); } #[test] fn test_download_dir_custom() { assert_removed_sync_option(&["--download-dir", "/photos"]); } #[test] fn test_watch_with_interval() { assert_removed_sync_option(&["--watch-with-interval", "3600"]); } #[test] fn test_watch_with_interval_rejects_below_minimum() { assert_removed_sync_option(&["--watch-with-interval", "59"]); } #[test] fn test_watch_with_interval_rejects_above_maximum() { assert_removed_sync_option(&["--watch-with-interval", "86401"]); } #[test] fn test_skip_created_before() { let mut args = base_args(); args.extend(["--skip-created-before", "2024-01-01"]); let cli = parse(&args); let Command::Sync { sync, .. } = cli.command else { panic!("expected Sync command"); }; assert_eq!(sync.skip_created_before.as_deref(), Some("2024-01-01")); } #[test] fn test_skip_created_after() { let mut args = base_args(); args.extend(["--skip-created-after", "2025-06-01"]); let cli = parse(&args); let Command::Sync { sync, .. } = cli.command else { panic!("expected Sync command"); }; assert_eq!(sync.skip_created_after.as_deref(), Some("2025-06-01")); } #[test] fn test_albums_multiple() { assert_removed_sync_option(&["--album", "Favorites", "--album", "Vacation"]); } #[test] fn test_albums_empty_by_default() { let cli = parse(&base_args()); let Command::Sync { sync, .. } = cli.command else { panic!("expected Sync command"); }; assert!(sync.config_overrides.albums.is_empty()); } #[test] fn test_album_all_accepted() { assert_removed_sync_flag(&["kei", "-a", "all"]); } // ── Input validation ─────────────────────────────────────────── #[test] fn test_empty_username_rejected() { assert_removed_global_option("--username", ""); } #[test] fn test_empty_password_rejected() { let mut args = base_args(); args.extend(["--password", ""]); assert!(Cli::try_parse_from(&args).is_err()); } #[test] fn test_empty_download_dir_rejected() { assert_removed_sync_option(&["--download-dir", ""]); } #[test] fn test_empty_album_rejected() { let mut args = base_args(); args.extend(["--album", ""]); assert!(Cli::try_parse_from(&args).is_err()); } #[test] fn test_submit_code_rejects_non_digits() { assert!(Cli::try_parse_from(["kei", "submit-code", "abcdef"]).is_err()); } #[test] fn test_submit_code_rejects_too_short() { assert!(Cli::try_parse_from(["kei", "submit-code", "12345"]).is_err()); } #[test] fn test_submit_code_rejects_too_long() { assert!(Cli::try_parse_from(["kei", "submit-code", "1234567"]).is_err()); } #[test] fn test_submit_code_rejects_empty() { assert!(Cli::try_parse_from(["kei", "submit-code", ""]).is_err()); } #[test] fn test_max_retries_rejects_above_100() { let mut args = base_args(); args.extend(["--max-retries", "101"]); assert!(Cli::try_parse_from(&args).is_err()); } #[test] fn test_max_retries_accepts_100() { assert_removed_sync_option(&["--max-retries", "100"]); } #[test] fn test_sync_subcommand() { let cli = Cli::try_parse_from(["kei", "sync"]).unwrap(); assert!(matches!(cli.command, Command::Sync { .. })); } #[test] fn test_status_subcommand() { let cli = Cli::try_parse_from(["kei", "status"]).unwrap(); assert!(matches!(cli.command, Command::Status(_))); } #[test] fn test_status_with_failed_flag() { let cli = Cli::try_parse_from(["kei", "status", "--failed"]).unwrap(); if let Command::Status(args) = cli.command { assert!(args.failed); assert!(!args.pending); assert!(!args.downloaded); } else { panic!("Expected Status command"); } } #[test] fn test_status_with_pending_flag() { let cli = Cli::try_parse_from(["kei", "status", "--pending"]).unwrap(); if let Command::Status(args) = cli.command { assert!(!args.failed); assert!(args.pending); assert!(!args.downloaded); } else { panic!("Expected Status command"); } } #[test] fn test_status_with_downloaded_flag() { let cli = Cli::try_parse_from(["kei", "status", "--downloaded"]).unwrap(); if let Command::Status(args) = cli.command { assert!(!args.failed); assert!(!args.pending); assert!(args.downloaded); } else { panic!("Expected Status command"); } } #[test] fn test_status_flags_combine() { let cli = Cli::try_parse_from(["kei", "status", "--failed", "--pending", "--downloaded"]) .unwrap(); if let Command::Status(args) = cli.command { assert!(args.failed); assert!(args.pending); assert!(args.downloaded); } else { panic!("Expected Status command"); } } // ── Global flags work with subcommands ──────────────────────── #[test] fn test_config_global_before_subcommand() { let cli = Cli::try_parse_from(["kei", "--config", "/custom/config.toml", "status"]).unwrap(); assert_eq!(cli.config, "/custom/config.toml"); assert!(matches!(cli.command, Command::Status(_))); } #[test] fn test_username_global_before_subcommand_removed() { let err = Cli::try_parse_from(["kei", "--username", "test@example.com", "sync"]) .expect_err("removed global option must fail to parse"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } // ── import-existing ─────────────────────────────────────────── #[test] fn test_import_existing_subcommand() { let cli = Cli::try_parse_from(["kei", "import-existing"]).unwrap(); if let Command::ImportExisting(args) = cli.command { assert!(args.recent.is_none()); assert!(!args.dry_run); assert!(!args.strict); } else { panic!("Expected ImportExisting command"); } } #[test] fn test_import_existing_library_flag_single() { let cli = Cli::try_parse_from(["kei", "import-existing", "--library", "SharedSync-ABCD1234"]) .unwrap(); if let Command::ImportExisting(args) = cli.command { assert_eq!(args.libraries, vec!["SharedSync-ABCD1234".to_string()]); } else { panic!("Expected ImportExisting command"); } } /// Parity check with `kei sync --library`: import-existing's flag is also /// repeatable and accepts mixed sentinels, zone names, and `!name` /// exclusions in one invocation. #[test] fn test_import_existing_library_flag_repeatable_with_mixed_grammar() { let cli = Cli::try_parse_from([ "kei", "import-existing", "--library", "primary", "--library", "SharedSync-ABCD1234", "--library", "!SharedSync-Photos", ]) .unwrap(); let Command::ImportExisting(args) = cli.command else { panic!("Expected ImportExisting command"); }; assert_eq!( args.libraries, vec![ "primary".to_string(), "SharedSync-ABCD1234".to_string(), "!SharedSync-Photos".to_string(), ] ); } fn parse_import(extra: &[&str]) -> ImportArgs { let mut args = vec!["kei", "import-existing"]; args.extend(extra.iter().copied()); let cli = Cli::try_parse_from(args).unwrap(); match cli.command { Command::ImportExisting(a) => a, _ => panic!("Expected ImportExisting command"), } } #[test] fn import_existing_strict_flag_parses_to_true() { let args = parse_import(&["--strict"]); assert!(args.strict); } #[test] fn import_existing_force_empty_flag_parses_to_true() { let _guard = scrub_auth_env(); unsafe { std::env::remove_var("KEI_FORCE_EMPTY"); } let args = parse_import(&["--force-empty"]); assert!(args.force_empty); } #[test] fn import_existing_force_empty_default_is_false() { let _guard = scrub_auth_env(); unsafe { std::env::remove_var("KEI_FORCE_EMPTY"); } let args = parse_import(&[]); assert!(!args.force_empty); } #[test] fn import_existing_force_empty_env_var_parses_to_true() { let _guard = scrub_auth_env(); unsafe { std::env::set_var("KEI_FORCE_EMPTY", "true"); } let cli = Cli::try_parse_from(["kei", "import-existing"]).unwrap(); unsafe { std::env::remove_var("KEI_FORCE_EMPTY"); } match cli.command { Command::ImportExisting(args) => assert!(args.force_empty), _ => panic!("Expected ImportExisting command"), } } #[test] fn import_existing_removed_durable_flags_fail() { for (flag, value) in [ ("--download-dir", Some("/tmp")), ("-d", Some("/tmp")), ("--folder-structure", Some("%Y/%m")), ("--folder-structure-albums", Some("{album}")), ("--folder-structure-smart-folders", Some("{smart-folder}")), ("--file-match-policy", Some("name-id7")), ("--size", Some("medium")), ("--live-photo-mode", Some("image-only")), ("--live-photo-size", Some("medium")), ("--live-photo-mov-filename-policy", Some("original")), ("--align-raw", Some("original")), ("--force-size", None), ("--keep-unicode-in-filenames", None), ] { let mut argv = vec!["kei", "import-existing", flag]; if let Some(value) = value { argv.push(value); } let err = Cli::try_parse_from(argv).expect_err("removed flag must fail"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } } #[test] fn test_list_albums_library_flag() { let cli = Cli::try_parse_from(["kei", "list", "--library", "all", "albums"]).unwrap(); assert!(matches!( cli.command, Command::List { ref libraries, what: ListCommand::Albums, .. } if libraries == &vec!["all".to_string()] )); } /// Parity check with `kei sync --library`: list's flag is also /// repeatable. Pre-fix it was `Option` and a second /// `--library` silently won, so multi-value users had no way to /// list across both primary and a specific shared library. #[test] fn test_list_albums_library_flag_repeatable() { let cli = Cli::try_parse_from([ "kei", "list", "--library", "primary", "--library", "SharedSync-ABCD1234", "albums", ]) .unwrap(); match cli.command { Command::List { libraries, what: ListCommand::Albums, .. } => { assert_eq!( libraries, vec!["primary".to_string(), "SharedSync-ABCD1234".to_string()] ); } other => panic!("expected Command::List, got {other:?}"), } } /// `--library` accepts the v0.13 `!name` exclusion sentinel. #[test] fn test_list_albums_library_flag_exclusion() { let cli = Cli::try_parse_from([ "kei", "list", "--library", "all", "--library", "!SharedSync-ABCD1234", "albums", ]) .unwrap(); match cli.command { Command::List { libraries, what: ListCommand::Albums, .. } => { assert_eq!( libraries, vec!["all".to_string(), "!SharedSync-ABCD1234".to_string()] ); } other => panic!("expected Command::List, got {other:?}"), } } /// `kei list libraries` also accepts the same flag grammar; the /// flag is parsed regardless of subcommand and is ignored on the /// libraries side (it's documented as albums-only). #[test] fn test_list_libraries_library_flag_repeatable() { let cli = Cli::try_parse_from([ "kei", "list", "--library", "primary", "--library", "shared", "libraries", ]) .unwrap(); match cli.command { Command::List { libraries, what: ListCommand::Libraries, .. } => { assert_eq!(libraries, vec!["primary".to_string(), "shared".to_string()]); } other => panic!("expected Command::List, got {other:?}"), } } #[test] fn test_verify_subcommand() { let cli = Cli::try_parse_from(["kei", "verify", "--checksums"]).unwrap(); if let Command::Verify(args) = cli.command { assert!(args.checksums); } else { panic!("Expected Verify command"); } } #[test] fn test_reconcile_subcommand() { let cli = Cli::try_parse_from(["kei", "reconcile"]).unwrap(); if let Command::Reconcile(args) = cli.command { assert!(!args.dry_run); } else { panic!("Expected Reconcile command"); } } #[test] fn test_reconcile_dry_run_flag() { let cli = Cli::try_parse_from(["kei", "reconcile", "--dry-run"]).unwrap(); if let Command::Reconcile(args) = cli.command { assert!(args.dry_run); } else { panic!("Expected Reconcile command"); } } // ── New filter flags ─────────────────────────────────────────── #[test] fn test_live_photo_mode_all_variants() { assert_removed_sync_option(&["--live-photo-mode", "skip"]); } #[test] fn test_filename_exclude_single() { assert_removed_sync_option(&["--filename-exclude", "*.AAE"]); } #[test] fn test_filename_exclude_multiple() { assert_removed_sync_option(&["--filename-exclude", "*.AAE,Screenshot*"]); } #[test] fn test_exclude_album_rejected() { let mut args = base_args(); args.extend(["--exclude-album", "Hidden"]); assert!(Cli::try_parse_from(args).is_err()); } // ── Clap-owned command boundary ──────────────────────────────── // // Sync flags live on `kei sync`, not on the top-level `kei` command. clap // should reject them before another subcommand can silently swallow them. #[test] fn top_level_rejects_skip_videos_before_status() { let err = Cli::try_parse_from(["kei", "--skip-videos=true", "status"]).unwrap_err(); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } #[test] fn top_level_rejects_skip_photos_before_list_albums() { let err = Cli::try_parse_from(["kei", "--skip-photos=true", "list", "albums"]).unwrap_err(); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } #[test] fn top_level_rejects_live_photo_mode_before_reset_state() { let err = Cli::try_parse_from([ "kei", "--live-photo-mode", "skip", "reset", "state", "--yes", ]) .unwrap_err(); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } #[test] fn top_level_rejects_value_flag_before_verify() { let err = Cli::try_parse_from(["kei", "--download-dir", "/photos", "verify"]) .expect_err("parse must reject"); let err = err.to_string(); assert!(err.contains("--download-dir"), "got: {err}"); } #[test] fn top_level_rejects_threads_before_reconcile() { let err = Cli::try_parse_from(["kei", "--threads", "4", "reconcile"]) .expect_err("parse must reject"); let err = err.to_string(); assert!(err.contains("--threads"), "got: {err}"); } #[test] fn top_level_rejects_dry_run_before_status() { let err = Cli::try_parse_from(["kei", "--dry-run", "status"]).expect_err("parse must reject"); let err = err.to_string(); assert!(err.contains("--dry-run"), "got: {err}"); } #[test] fn top_level_rejects_album_before_password_set() { let err = Cli::try_parse_from(["kei", "--album", "Vacation", "password", "set"]) .expect_err("parse must reject"); let err = err.to_string(); assert!(err.contains("--album"), "got: {err}"); } #[test] fn removed_auth_only_flag_is_rejected() { let err = Cli::try_parse_from(["kei", "--auth-only", "status"]) .expect_err("parse must reject removed flag") .to_string(); assert!(err.contains("--auth-only"), "got: {err}"); } #[test] fn top_level_rejects_first_offending_sync_flag() { let err = Cli::try_parse_from([ "kei", "--dry-run", "--only-print-filenames", "--recent", "100", "status", ]) .expect_err("parse must reject") .to_string(); assert!(err.contains("--dry-run"), "got: {err}"); } #[test] fn sync_subcommand_accepts_sync_flags() { Cli::try_parse_from([ "kei", "sync", "--friendly", "--recent", "100", "--skip-created-before", "2025-01-01", ]) .expect("explicit sync subcommand with kept sync flags must parse"); } #[test] fn top_level_sync_flag_before_sync_subcommand_is_rejected() { let err = Cli::try_parse_from(["kei", "--friendly", "--recent", "100", "sync"]) .expect_err("top-level sync flags must not parse before subcommand"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } #[test] fn retry_failed_with_sync_flag_parses_under_sync() { Cli::try_parse_from(["kei", "sync", "--retry-failed", "--recent", "100"]) .expect("sync --retry-failed accepts kept per-run sync flags"); } #[test] fn kept_global_flags_parse_before_non_sync_subcommand() { Cli::try_parse_from(["kei", "--log-level", "debug", "status"]) .expect("global flags must parse with any subcommand"); } #[test] fn status_with_no_top_level_flags_parses() { Cli::try_parse_from(["kei", "status"]).expect("plain `kei status` must parse"); } #[test] fn service_run_accepts_own_sync_flags() { Cli::try_parse_from(["kei", "service", "run", "--recent", "100", "--dry-run"]) .expect("service run must accept its own sync flags"); } #[test] fn service_run_rejects_friendly_flag() { let err = Cli::try_parse_from(["kei", "service", "run", "--friendly"]) .expect_err("service run does not expose friendly mode") .to_string(); assert!(err.contains("--friendly"), "got: {err}"); } #[test] fn service_status_rejects_sync_flags() { let err = Cli::try_parse_from(["kei", "--download-dir", "/photos", "service", "status"]) .expect_err("parse must reject sync flags with service status") .to_string(); assert!(err.contains("--download-dir"), "got: {err}"); } #[test] fn top_level_rejects_each_sync_only_flag_before_status() { let cases: &[(&str, &[&str])] = &[ ("skip_videos", &["--skip-videos=true"]), ("skip_photos", &["--skip-photos=true"]), ("unfiled", &["--unfiled=true"]), ("force_size", &["--force-size=true"]), ("no_progress_bar", &["--no-progress-bar"]), ("keep_unicode", &["--keep-unicode-in-filenames=true"]), ("notify_systemd", &["--notify-systemd=true"]), ("dry_run", &["--dry-run"]), ("friendly", &["--friendly"]), ("no_friendly", &["--no-friendly"]), ("only_print_filenames", &["--only-print-filenames"]), ("save_password", &["--save-password"]), ("retry_failed", &["--retry-failed"]), ("download_dir", &["--download-dir", "/photos"]), ("album", &["--album", "Vacation"]), ("smart_folder", &["--smart-folder", "Favorites"]), ("filename_exclude", &["--filename-exclude", "*.AAE"]), ("library", &["--library", "primary"]), ("size", &["--size", "original"]), ("live_photo_size", &["--live-photo-size", "original"]), ("recent", &["--recent", "100"]), ("threads", &["--threads", "4"]), ("bandwidth_limit", &["--bandwidth-limit", "10M"]), ("live_photo_mode", &["--live-photo-mode", "skip"]), ("folder_structure", &["--folder-structure", "%Y/%m"]), ( "folder_structure_albums", &["--folder-structure-albums", "{album}"], ), ( "folder_structure_smart_folders", &["--folder-structure-smart-folders", "{smart-folder}"], ), ("watch_with_interval", &["--watch-with-interval", "3600"]), ( "live_photo_mov_filename_policy", &["--live-photo-mov-filename-policy", "original"], ), ("align_raw", &["--align-raw", "original"]), ( "file_match_policy", &["--file-match-policy", "name-size-dedup-with-suffix"], ), ( "skip_created_before", &["--skip-created-before", "2025-01-01"], ), ( "skip_created_after", &["--skip-created-after", "2025-12-31"], ), ("max_retries", &["--max-retries", "3"]), ("temp_suffix", &["--temp-suffix", ".part"]), ("pid_file", &["--pid-file", "/tmp/kei.pid"]), ( "reconcile_every_n_cycles", &["--reconcile-every-n-cycles", "24"], ), ( "notification_script", &["--notification-script", "/tmp/notify.sh"], ), ("report_json", &["--report-json", "/tmp/report.json"]), ("http_port", &["--http-port", "9090"]), ("http_bind", &["--http-bind", "127.0.0.1"]), ("max_download_attempts", &["--max-download-attempts", "5"]), ]; for (name, args) in cases { let mut argv: Vec<&str> = vec!["kei"]; argv.extend_from_slice(args); argv.push("status"); let result = Cli::try_parse_from(&argv); assert!( result.is_err(), "clap must reject sync-only flag `{name}` ({args:?}) before status; got {result:?}" ); } } // ── friendly_request() resolution ─────────────────────────────────── // // The helper distils the `--friendly` / `--no-friendly` pair into the // tristate `lib.rs` actually consumes. Tests here prove clap's // `overrides_with` wiring matches the documented behaviour: last flag // wins when both appear, neither set yields `None`. #[test] fn friendly_request_none_when_neither_flag() { let cli = parse(&["kei", "status"]); assert_eq!(cli.friendly_request(), None); } #[test] fn friendly_request_some_true_with_friendly() { let cli = parse(&["kei", "sync", "--friendly"]); assert_eq!(cli.friendly_request(), Some(true)); } #[test] fn friendly_request_some_false_with_no_friendly() { let cli = parse(&["kei", "sync", "--no-friendly"]); assert_eq!(cli.friendly_request(), Some(false)); } #[test] fn friendly_request_last_wins_no_friendly_after_friendly() { let cli = parse(&["kei", "sync", "--friendly", "--no-friendly"]); assert_eq!( cli.friendly_request(), Some(false), "--no-friendly after --friendly must win via overrides_with" ); } #[test] fn friendly_request_last_wins_friendly_after_no_friendly() { let cli = parse(&["kei", "sync", "--no-friendly", "--friendly"]); assert_eq!( cli.friendly_request(), Some(true), "--friendly after --no-friendly must win via overrides_with" ); } } #[cfg(test)] mod read_only_entrypoint_tests { #[test] fn missing_reference_inventory_query_handles_partial_and_help_arguments() { for arguments in [ vec![ "kei", "doctor", "--live", "--json", "--missing-reference-inventory", ], vec!["kei", "doctor", "--missing-reference-inventory"], vec!["kei", "doctor", "--missing-reference-inventory", "--help"], vec!["kei", "doctor", "--help", "--missing-reference-inventory"], ] { assert!(super::is_early_read_only_invocation(arguments)); } for arguments in [ vec!["kei", "doctor", "--live", "--json"], vec!["kei", "--config", "doctor", "status"], vec!["kei", "sync"], ] { assert!(!super::is_early_read_only_invocation(arguments)); } } } /// Bounded offline local-byte acceptance; source verification is unchanged. #[derive(Debug, Clone, clap::Args)] pub struct TrustedLegacyArgs { #[command(subcommand)] pub action: TrustedLegacyAction, } #[derive(Debug, Clone, clap::Subcommand)] pub enum TrustedLegacyAction { /// Write a new reviewable JSON plan for one library (maximum 128 renditions) Plan { #[arg(long)] library: String, #[arg(long)] output: std::path::PathBuf, }, /// Accept exactly the unchanged files and catalogue bindings in a reviewed plan Apply { #[arg(long)] plan: std::path::PathBuf, }, } #[derive(Debug, Clone, clap::Subcommand)] pub enum ReconcileAction { /// Explicitly trust selected local files for preserved legacy migration (offline) #[command( after_help = "Documentation: https://github.com/rhoopr/kei/blob/main/docs/trusted-legacy-files.md" )] TrustLegacyFiles(TrustedLegacyArgs), }