src/cli.rs
Rama main · réplica de lectura del repositorio original.
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<String, String> {
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<u64, String> {
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<String, String> {
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<String>,
#[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<RecentLimit, String> {
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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
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: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
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<String>,
/// 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<String>,
/// 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<String>,
}
/// 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<RecentLimit>,
/// 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<RecentScope>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<RecentLimit>,
/// 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<ReconcileAction>,
/// 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 <PATH> set\n kei password --password-command <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<String>,
},
}
/// 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<ServiceRunArgs>),
/// 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<LoginCommand>,
},
/// 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<String>,
#[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<LogLevel>,
/// 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<bool> {
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<bool> {
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 = <Cli as clap::CommandFactory>::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<I, T>(itr: I) -> bool
where
I: IntoIterator<Item = T>,
T: Into<OsString> + 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(<Cli as clap::CommandFactory>::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<I, T>(itr: I) -> Result<Cli, ParseCliError>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let argv: Vec<OsString> = itr.into_iter().map(Into::into).collect();
let cmd = <Cli as clap::CommandFactory>::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<String>) {
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 = <Cli as clap::CommandFactory>::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::<Wrap>("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::<Wrap>(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<String>,
prev_pw: Option<String>,
}
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<String>` 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),
}