src/setup.rs
Rama main · réplica de lectura del repositorio original.
#![allow(
clippy::print_stdout,
reason = "interactive setup wizard whose purpose is to drive a stdout dialogue"
)]
use std::fmt::Write as FmtWrite;
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use console::Style;
use dialoguer::{Confirm, Input, Password, Select};
use indicatif::ProgressBar;
use secrecy::ExposeSecret;
use crate::credential::{CredentialBackend, CredentialStore};
use crate::types::{
Domain, FileMatchPolicy, LivePhotoMode, LivePhotoMovFilenamePolicy, LogLevel, PhotoResolution,
RawPolicy,
};
/// Result of the setup wizard — either the user wants to sync now or just exit.
#[derive(Debug)]
pub(crate) enum SetupResult {
/// User chose to sync now. Contains the config path and any one-shot
/// password needed for explicit `.env` fallback.
SyncNow {
config_path: PathBuf,
one_shot_password: Option<secrecy::SecretString>,
},
/// User chose not to sync now (or cancelled).
Done,
}
/// Collected answers from the interactive setup wizard.
#[derive(Debug)]
struct SetupAnswers {
// Account
username: String,
password: secrecy::SecretString,
domain: Option<Domain>,
secret_source: SetupSecretSource,
// Destination. `folder_structure` is the unfiled-pass template;
// `folder_structure_albums` is the v0.13 per-album template. The wizard
// sets both together when the user picks a date hierarchy so album passes
// get the same layout as the unfiled pass (matches v0.12 behavior).
directory: String,
folder_structure: Option<String>,
folder_structure_albums: Option<String>,
// What to download
albums: Vec<String>,
/// v0.13+ array form. Empty = use kei's default (`primary`).
/// `["all"]` = every library (PrimarySync + every shared zone).
libraries: Vec<String>,
/// v0.13+ smart-folder selector (Favorites, Hidden, etc.). Empty = default
/// (`none`); non-empty = emit `[filters].smart_folders`.
smart_folders: Vec<String>,
/// Optional smart-folder pass template for `[download].folder_structure_smart_folders`.
/// `None` leaves the runtime default (`{smart-folder}`).
folder_structure_smart_folders: Option<String>,
/// `Some(false)` emits `[filters].unfiled = false` (used when the user
/// picks specific albums and doesn't also want every other photo).
/// `None` keeps the v0.13 default (`true`).
unfiled: Option<bool>,
/// v0.13 `[filters].filename_exclude` patterns; empty = don't emit.
filename_exclude: Vec<String>,
// Media types
skip_videos: bool,
/// `Some(_)` emits `[photos].live_photo_mode = "..."`; `None` keeps the
/// `Both` default. Replaces the wizard's old binary skip prompt.
live_photo_mode: Option<LivePhotoMode>,
live_photo_mov_filename_policy: Option<LivePhotoMovFilenamePolicy>,
// Quality
resolution: Option<PhotoResolution>,
force_resolution: bool,
raw_policy: Option<RawPolicy>,
// Date range
recent: Option<u32>,
skip_created_before: Option<String>,
skip_created_after: Option<String>,
// Running mode
watch_interval: Option<u64>,
notify_systemd: bool,
pid_file: Option<String>,
/// `[watch].reconcile_every_n_cycles`; only meaningful in watch mode.
reconcile_every_n_cycles: Option<u64>,
// Extras
notification_script: Option<String>,
threads_num: Option<u16>,
max_retries: Option<u32>,
/// Raw user input string (e.g. `"10MB"`); validated by the config layer's
/// `parse_bandwidth_limit` on next sync. Empty = no limit.
bandwidth_limit: Option<String>,
keep_unicode_in_filenames: bool,
set_exif_datetime: bool,
#[cfg(feature = "xmp")]
embed_xmp: bool,
#[cfg(feature = "xmp")]
xmp_sidecar: bool,
file_match_policy: Option<FileMatchPolicy>,
/// Top-level `data_dir` in the emitted TOML.
data_dir: Option<String>,
log_level: Option<LogLevel>,
/// `[ui].friendly`. `None` keeps the section out of the emitted TOML so
/// the runtime default-on-for-TTY policy applies. `Some(false)` lets a
/// user opt out at setup time without having to remember the flag later.
ui_friendly: Option<bool>,
}
impl Default for SetupAnswers {
fn default() -> Self {
Self {
username: String::new(),
password: secrecy::SecretString::from(String::new()),
domain: None,
secret_source: SetupSecretSource::CredentialStore,
directory: "~/Photos/iCloud".to_string(),
folder_structure: None,
folder_structure_albums: None,
albums: Vec::new(),
libraries: vec!["all".to_string()],
smart_folders: Vec::new(),
folder_structure_smart_folders: None,
unfiled: None,
filename_exclude: Vec::new(),
skip_videos: false,
live_photo_mode: None,
live_photo_mov_filename_policy: None,
resolution: None,
force_resolution: false,
raw_policy: None,
recent: None,
skip_created_before: None,
skip_created_after: None,
watch_interval: None,
notify_systemd: false,
pid_file: None,
reconcile_every_n_cycles: None,
notification_script: None,
threads_num: None,
max_retries: None,
bandwidth_limit: None,
keep_unicode_in_filenames: false,
set_exif_datetime: false,
#[cfg(feature = "xmp")]
embed_xmp: false,
#[cfg(feature = "xmp")]
xmp_sidecar: false,
file_match_policy: None,
data_dir: None,
log_level: None,
ui_friendly: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SetupSecretSource {
CredentialStore,
PasswordFile(String),
PasswordCommand(String),
EnvFile,
}
impl SetupSecretSource {
fn needs_password_prompt(&self) -> bool {
matches!(self, Self::CredentialStore | Self::EnvFile)
}
}
#[derive(Debug)]
struct SetupWriteResult {
credential_backend: Option<CredentialBackend>,
env_path: Option<PathBuf>,
}
// ── Delight helpers ──────────────────────────────────────────────
/// Print a dimmed section divider for visual structure between wizard steps.
/// Run `f` with a ticking spinner showing `msg`. Clears the spinner when done.
fn spinner_for<R>(msg: &'static str, f: impl FnOnce() -> R) -> R {
let spinner = ProgressBar::new_spinner();
spinner.set_message(msg);
spinner.enable_steady_tick(std::time::Duration::from_millis(80));
let result = f();
spinner.finish_and_clear();
result
}
fn section_header(text: &str) {
let dim = Style::new().dim();
println!();
println!("{}", dim.apply_to(format!(" ── {text} ──")));
}
/// Print a line prefixed with a green bold checkmark.
fn check(text: &str) {
let mark = Style::new().green().bold();
println!("{} {text}", mark.apply_to("✓"));
}
/// Dim every comment line in a generated TOML string so the active
/// keys stand out. Returns a new String with ANSI escapes injected.
fn dim_comments(toml: &str) -> String {
let dim = Style::new().dim();
toml.lines()
.map(|line| {
if line.trim_start().starts_with('#') {
dim.apply_to(line).to_string()
} else if line.trim_start().starts_with('[') {
Style::new().cyan().bold().apply_to(line).to_string()
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn print_toml_preview(toml_content: &str) {
println!();
println!("───────────────────────────────────────────────────────");
print!("{}", dim_comments(toml_content));
println!("───────────────────────────────────────────────────────");
println!();
}
pub(crate) fn run_setup(
config_path: &Path,
input_mode: crate::InputMode,
) -> anyhow::Result<SetupResult> {
if !input_mode.can_prompt() {
bail!("The setup wizard needs an interactive terminal.");
}
println!();
println!("Hey! Let's get kei set up.");
println!();
println!("A few quick questions, then you'll be syncing.");
println!("Press Enter to accept a default — it's always a safe bet.");
println!();
// Check for existing config
if config_path.exists() && !check_overwrite(config_path)? {
println!("Setup cancelled.");
return Ok(SetupResult::Done);
}
let mut answers = SetupAnswers::default();
// Step 1: Account
section_header("Account");
ask_account_identity(&mut answers)?;
check(&format!("Glad you're here, {}.", answers.username));
// Step 2: Secrets
section_header("Secrets");
ask_secret_source(&mut answers)?;
if answers.secret_source.needs_password_prompt() {
ask_account_password(&mut answers)?;
}
// Step 3: Where to save
section_header("Where to save");
ask_destination(&mut answers)?;
// Step 4: What to download
section_header("What to sync");
ask_what_to_download(&mut answers)?;
if !answers.albums.is_empty() {
check("Keeping it focused.");
}
// Step 5: Media types
section_header("Media types");
ask_media_types(&mut answers)?;
// Step 6: Photo quality & RAW
section_header("Quality");
ask_quality(&mut answers)?;
// Step 7: Date range
section_header("Date range");
ask_date_range(&mut answers)?;
// Step 8: Running mode
section_header("Running mode");
ask_running_mode(&mut answers)?;
if answers.watch_interval.is_some() {
check("Set-and-forget. Kei will keep an eye on things.");
}
// Step 9: Extras
section_header("Extras");
ask_extras(&mut answers)?;
apply_library_scoped_templates_for_all_libraries(&mut answers);
// Generate TOML with a brief spinner so the user sees something happening.
let toml_content: String = spinner_for("Building your config...", || generate_toml(&answers));
check("All done.");
println!();
// The Select offers "Show again" so users on small terminals can
// re-read the config after it scrolled past.
let line_count = toml_content.lines().count();
print_toml_preview(&toml_content);
loop {
let action_items = [
format!("Write to {}", config_path.display()),
format!("Show full configuration again ({line_count} lines)"),
"Cancel and exit without writing".to_string(),
];
let action = Select::new()
.with_prompt("Look good?")
.items(&action_items)
.default(0)
.interact()?;
match action {
0 => break,
1 => print_toml_preview(&toml_content),
_ => {
println!("Setup cancelled.");
return Ok(SetupResult::Done);
}
}
}
// Write files with a spinner for visual closure.
let write_result = spinner_for("Writing your config...", || {
write_setup_files(config_path, &toml_content, &answers)
})?;
check("Config saved.");
println!();
println!(" Config → {}", config_path.display());
print_secret_summary(&answers, &write_result);
println!();
let bold = Style::new().bold();
println!("{}", bold.apply_to("You're all set."));
if answers.watch_interval.is_some() {
println!();
println!(
" {} to run continuously in the background.",
bold.apply_to(format!("kei install --config {}", config_path.display()))
);
}
println!();
// Offer to sync now
let sync_now = Confirm::new()
.with_prompt("Want to kick off your first sync right now?")
.default(true)
.interact()?;
if sync_now {
let one_shot_password = match answers.secret_source {
SetupSecretSource::EnvFile => Some(answers.password),
_ => None,
};
Ok(SetupResult::SyncNow {
config_path: config_path.to_path_buf(),
one_shot_password,
})
} else {
println!();
println!("No rush. When you're ready:");
println!();
if let Some(env_path) = &write_result.env_path {
print_load_env_snippet(env_path);
}
println!(" {}", sync_command_for_config(config_path));
println!();
Ok(SetupResult::Done)
}
}
fn apply_library_scoped_templates_for_all_libraries(answers: &mut SetupAnswers) {
if answers.libraries.as_slice() != ["all"] {
return;
}
answers.folder_structure = Some(library_template_or_default(
answers.folder_structure.as_deref(),
"%Y/%m/%d",
));
}
fn ensure_library_token(template: &str) -> String {
if template.contains("{library}") {
return template.to_string();
}
if template.is_empty() {
return "{library}".to_string();
}
format!("{{library}}/{template}")
}
fn library_template_or_default(template: Option<&str>, default: &str) -> String {
ensure_library_token(template.unwrap_or(default))
}
fn single_quoted_shell(value: &str) -> String {
format!("'{}'", shell_single_quote_escape(value))
}
fn sync_command_for_config(config_path: &Path) -> String {
let path = config_path.to_string_lossy();
format!("kei --config {} sync", single_quoted_shell(&path))
}
/// Print the right "load .env into this shell" command for the user's shell.
/// Detects the shell from `$SHELL`. Defaults to the bash/zsh form because
/// it's the most common on Linux and macOS; calls out fish explicitly because
/// `set -a` doesn't exist there and the bash snippet would silently no-op.
fn print_load_env_snippet(env_path: &Path) {
let shell = std::env::var("SHELL").unwrap_or_default();
let shell_name = Path::new(&shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
let env_display = env_path.display();
match shell_name {
"fish" => {
// fish doesn't have `set -a`, and the .env file's single-quoted
// values would be passed through verbatim by a naive
// `(cat | string split)` loop. Cleanest reliable one-liner: have
// bash do the parsing, then exec fish so the inherited env
// carries through.
println!(" # fish: load .env via a bash subshell, then continue in fish");
println!(" bash -c 'set -a; source {env_display}; set +a; exec fish'");
}
_ => {
// bash / zsh / sh / dash / ksh: POSIX `set -a` + source.
println!(" set -a; source {env_display}; set +a");
}
}
}
fn write_setup_files(
config_path: &Path,
toml_content: &str,
answers: &SetupAnswers,
) -> anyhow::Result<SetupWriteResult> {
write_setup_files_with_store(
config_path,
toml_content,
answers,
|username, config_dir, pw| {
CredentialStore::new(
username,
config_dir,
answers.domain.unwrap_or(Domain::Com).as_str(),
)
.store(pw)
},
)
}
fn write_setup_files_with_store(
config_path: &Path,
toml_content: &str,
answers: &SetupAnswers,
store_password: impl FnOnce(&str, &Path, &str) -> anyhow::Result<CredentialBackend>,
) -> anyhow::Result<SetupWriteResult> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Could not create directory {}", parent.display()))?;
}
let (credential_backend, write_env) = match &answers.secret_source {
SetupSecretSource::CredentialStore => {
let config_dir = credential_store_dir(answers);
let backend = store_password(
&answers.username,
&config_dir,
answers.password.expose_secret(),
)?;
(Some(backend), false)
}
SetupSecretSource::PasswordFile(_) | SetupSecretSource::PasswordCommand(_) => (None, false),
SetupSecretSource::EnvFile => (None, true),
};
write_config_file(config_path, toml_content)?;
let env_path = if write_env {
Some(write_env_file(config_path, answers)?)
} else {
None
};
Ok(SetupWriteResult {
credential_backend,
env_path,
})
}
fn write_config_file(config_path: &Path, toml_content: &str) -> anyhow::Result<()> {
std::fs::write(config_path, toml_content)
.with_context(|| format!("Could not write {}", config_path.display()))?;
#[cfg(unix)]
set_restricted_permissions(config_path)?;
Ok(())
}
fn credential_store_dir(answers: &SetupAnswers) -> PathBuf {
match &answers.data_dir {
Some(dir) => crate::config::expand_tilde(dir),
None => crate::config::default_cookie_dir(),
}
}
fn write_env_file(config_path: &Path, answers: &SetupAnswers) -> anyhow::Result<PathBuf> {
let env_path = config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(".env");
let escaped_user = shell_single_quote_escape(&answers.username);
let escaped_pass = shell_single_quote_escape(answers.password.expose_secret());
let env_content =
format!("ICLOUD_USERNAME='{escaped_user}'\nICLOUD_PASSWORD='{escaped_pass}'\n",);
std::fs::write(&env_path, &env_content)
.with_context(|| format!("Could not write {}", env_path.display()))?;
#[cfg(unix)]
set_restricted_permissions(&env_path)?;
Ok(env_path)
}
fn shell_single_quote_escape(value: &str) -> String {
value.replace('\'', "'\\''")
}
#[cfg(unix)]
fn set_restricted_permissions(path: &Path) -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("Could not set secure permissions on {}", path.display()))
}
fn print_secret_summary(answers: &SetupAnswers, write_result: &SetupWriteResult) {
for line in secret_summary_lines(answers, write_result) {
println!("{line}");
}
}
fn secret_summary_lines(answers: &SetupAnswers, write_result: &SetupWriteResult) -> Vec<String> {
match &answers.secret_source {
SetupSecretSource::CredentialStore => {
let backend = write_result
.credential_backend
.map(CredentialBackend::as_str)
.unwrap_or("credential-store");
vec![
format!(" Secrets → {backend} backend"),
" Use `kei password backend` to check it or `kei password set` to change it."
.to_string(),
]
}
SetupSecretSource::PasswordFile(path) => {
vec![format!(" Secrets → password file: {path}")]
}
SetupSecretSource::PasswordCommand(_) => {
vec![" Secrets → password command from config".to_string()]
}
SetupSecretSource::EnvFile => {
if let Some(env_path) = &write_result.env_path {
vec![format!(" Secrets → {}", env_path.display())]
} else {
Vec::new()
}
}
}
}
fn check_overwrite(path: &Path) -> anyhow::Result<bool> {
Confirm::new()
.with_prompt(format!("{} already exists. Overwrite?", path.display()))
.default(false)
.interact()
.map_err(Into::into)
}
// ── Step 1: Account ────────────────────────────────────────────────
fn ask_account_identity(answers: &mut SetupAnswers) -> anyhow::Result<()> {
answers.username = Input::new()
.with_prompt("Apple ID email")
.validate_with(|input: &String| {
if input.contains('@') && input.contains('.') {
Ok(())
} else {
Err("Please enter a valid email address")
}
})
.interact_text()?;
println!();
let region_items = ["iCloud.com", "iCloud.com.cn (China)"];
let region = Select::new()
.with_prompt("iCloud region")
.items(region_items)
.default(0)
.interact()?;
answers.domain = match region {
1 => Some(Domain::Cn),
_ => None, // com is the default, no need to write it
};
Ok(())
}
fn ask_account_password(answers: &mut SetupAnswers) -> anyhow::Result<()> {
// `with_confirmation` re-prompts on mismatch in-place, so a typo costs
// one extra round, not a broken config that fails the next sync.
answers.password = secrecy::SecretString::from(
Password::new()
.with_prompt("iCloud password")
.with_confirmation(
"Re-enter password to confirm",
"Passwords didn't match, try again",
)
.interact()?,
);
Ok(())
}
// ── Step 2: Where to save ──────────────────────────────────────────
fn ask_destination(answers: &mut SetupAnswers) -> anyhow::Result<()> {
println!();
answers.directory = Input::new()
.with_prompt("Where should photos be saved?")
.default("~/Photos/iCloud".to_string())
.interact_text()?;
// Data directory for sessions, state DB, credentials, health. The default
// is right for almost everyone, so it's offered here as a single line with
// an obvious skip, not gated behind the "additional options?" extras prompt.
let data_dir: String = Input::new()
.with_prompt(
"App data directory (advanced — leave default unless you know you need to change this)",
)
.default("~/.config/kei".to_string())
.interact_text()?;
if data_dir != "~/.config/kei" {
answers.data_dir = Some(data_dir);
}
println!();
let folder_items = [
"By date: 2024/03/15 (%Y/%m/%d)",
"By month: 2024/03 (%Y/%m)",
"By year: 2024 (%Y)",
"All in one folder",
"Custom pattern...",
];
let folder = Select::new()
.with_prompt("How should photos be organized into folders?")
.items(folder_items)
.default(0)
.interact()?;
// The unfiled-pass template (`folder_structure`) and the per-album
// template (`folder_structure_albums`) are independent in v0.13.
// To match user intent ("organize photos by date") and v0.12 layout
// behavior, when the user picks a date hierarchy we apply the same
// template under each album folder by setting
// `folder_structure_albums = "{album}/<template>"`. The default
// `{album}` (flat per-album folder) stays for "All in one folder",
// since collapsing the date hierarchy to nothing inside per-album
// folders is what the user implicitly asked for.
let unfiled_template: Option<String> = match folder {
1 => Some("%Y/%m".to_string()),
2 => Some("%Y".to_string()),
3 => Some(String::new()),
4 => {
let custom: String = Input::new()
.with_prompt("Folder pattern (strftime format)")
.default("%Y/%m/%d".to_string())
.interact_text()?;
Some(custom)
}
// %Y/%m/%d is the default
_ => None,
};
let date_template_for_albums: Option<&str> = match folder {
0 => Some("%Y/%m/%d"),
1 => Some("%Y/%m"),
2 => Some("%Y"),
// 3 ("all in one folder") and 4 (custom) intentionally leave the
// album template at the v0.13 default `{album}`. For 4 the user
// can edit the generated TOML if they want a custom album layout
// too; offering yet another wizard prompt here clutters the flow.
_ => None,
};
answers.folder_structure_albums = date_template_for_albums.map(|t| format!("{{album}}/{t}"));
answers.folder_structure = unfiled_template;
Ok(())
}
// ── Step 3: Secrets ────────────────────────────────────────────────
fn ask_secret_source(answers: &mut SetupAnswers) -> anyhow::Result<()> {
println!();
let use_store = Confirm::new()
.with_prompt("Store the password in kei's credential store?")
.default(true)
.interact()?;
if use_store {
answers.secret_source = SetupSecretSource::CredentialStore;
println!(
" Kei will try the OS keyring first, then encrypted file storage if the keyring is unavailable."
);
return Ok(());
}
let secret_items = [
"Use an existing password file",
"Use a password command",
"Write a local .env file next to the config (last resort)",
];
let choice = Select::new()
.with_prompt("How should kei read the password?")
.items(secret_items)
.default(0)
.interact()?;
answers.secret_source = match choice {
0 => {
let path: String = Input::new()
.with_prompt("Password file path")
.validate_with(|input: &String| {
if input.trim().is_empty() {
Err("Password file path cannot be empty.")
} else {
Ok(())
}
})
.interact_text()?;
SetupSecretSource::PasswordFile(path.trim().to_string())
}
1 => {
let command: String = Input::new()
.with_prompt("Password command")
.validate_with(|input: &String| {
if input.trim().is_empty() {
Err("Password command cannot be empty.")
} else {
Ok(())
}
})
.interact_text()?;
SetupSecretSource::PasswordCommand(command.trim().to_string())
}
_ => SetupSecretSource::EnvFile,
};
Ok(())
}
// ── Step 4: What to download ───────────────────────────────────────
fn ask_what_to_download(answers: &mut SetupAnswers) -> anyhow::Result<()> {
println!();
let scope_items = ["Entire library", "Specific albums"];
let scope = Select::new()
.with_prompt("Download your entire library or specific albums?")
.items(scope_items)
.default(0)
.interact()?;
if scope == 1 {
// One album per prompt; blank input ends the loop. We can't comma-split
// the input because `--album` / `[filters].albums` no longer split
// (v0.13), so a single comma-separated entry would silently break any
// album whose name contains a comma.
println!(" Enter one album per line. Press Enter on a blank line to finish.");
println!(" Names are case-sensitive and must match iCloud exactly. If you're unsure,");
println!(" cancel now (Ctrl+C), run `kei list albums`, and re-run `kei config setup`.");
loop {
let prompt = if answers.albums.is_empty() {
"Album name".to_string()
} else {
format!(
"Album name ({} so far, blank to finish)",
answers.albums.len()
)
};
let name: String = Input::new()
.with_prompt(prompt)
.default(String::new())
.show_default(false)
.allow_empty(true)
.interact_text()?;
let trimmed = name.trim();
if trimmed.is_empty() {
break;
}
answers.albums.push(trimmed.to_string());
}
// Default sourced from runtime so the wizard stays truthful if
// `unfiled_default()` ever changes. Emit explicitly so the generated
// config records the user's intended album/unfiled scope.
if !answers.albums.is_empty() {
println!();
let also_unfiled = Confirm::new()
.with_prompt("Also download photos that aren't in any of these albums?")
.default(crate::config::unfiled_default())
.interact()?;
answers.unfiled = Some(also_unfiled);
}
} else {
// scope == 0 ("entire library") -- albums default to `all`. Set
// unfiled explicitly so the generated config records the full
// library scope.
answers.unfiled = Some(true);
}
println!();
let library_items = [
"Yes, sync all libraries (including shared)",
"No, just my main library",
];
let library = Select::new()
.with_prompt("Do you use shared or family libraries?")
.items(library_items)
.default(0)
.interact()?;
answers.libraries = match library {
0 => vec!["all".to_string()],
_ => Vec::new(), // empty = use kei's default (`primary`)
};
Ok(())
}
// ── Step 4: Media types ────────────────────────────────────────────
fn ask_media_types(answers: &mut SetupAnswers) -> anyhow::Result<()> {
println!();
let include_videos = Confirm::new()
.with_prompt("Include videos along with photos?")
.default(true)
.interact()?;
answers.skip_videos = !include_videos;
// Four-way choice (matches the runtime `LivePhotoMode` enum). The old
// wizard only exposed Both vs Skip, hiding the image-only / video-only
// modes that the CLI surface and TOML accept.
let live_items = [
"Both: image + video (default)",
"Image only: skip the .mov video file",
"Video only: skip the still image",
"Skip live photos entirely",
];
let live_choice = Select::new()
.with_prompt("How should live photos be downloaded?")
.items(live_items)
.default(0)
.interact()?;
answers.live_photo_mode = match live_choice {
1 => Some(LivePhotoMode::ImageOnly),
2 => Some(LivePhotoMode::VideoOnly),
3 => Some(LivePhotoMode::Skip),
// `Both` is the default; leave as None so the emitted TOML keeps the
// commented hint instead of an explicit assignment.
_ => None,
};
// The .mov filename policy only matters when both image and video land
// on disk together. Image-only / video-only / skip leave the .mov on its
// own (or absent), so the suffix-vs-original choice is moot.
let download_both_parts = matches!(live_choice, 0);
if download_both_parts {
let mov_items = [
"Add -live suffix (IMG_1234-live.mov)",
"Same name as the photo (IMG_1234.mov)",
];
let mov_policy = Select::new()
.with_prompt("How should the video part of live photos be named?")
.items(mov_items)
.default(0)
.interact()?;
answers.live_photo_mov_filename_policy = match mov_policy {
1 => Some(LivePhotoMovFilenamePolicy::Original),
_ => None, // suffix is the default
};
}
Ok(())
}
// ── Step 5: Photo quality & RAW ────────────────────────────────────
fn ask_quality(answers: &mut SetupAnswers) -> anyhow::Result<()> {
println!();
let size_items = ["Original (full resolution)", "Medium", "Thumbnail"];
let size = Select::new()
.with_prompt("What size should photos be downloaded at?")
.items(size_items)
.default(0)
.interact()?;
answers.resolution = match size {
1 => Some(PhotoResolution::Medium),
2 => Some(PhotoResolution::Thumb),
_ => None, // original is the default
};
// If not original, ask about fallback
if answers.resolution.is_some() {
let fallback = Confirm::new()
.with_prompt("If that size isn't available, fall back to original?")
.default(true)
.interact()?;
answers.force_resolution = !fallback;
}
println!();
let shoots_raw = Confirm::new()
.with_prompt("Do you shoot RAW?")
.default(false)
.interact()?;
if shoots_raw {
let raw_items = [
"Download both as-is",
"Prefer the RAW original",
"Prefer the processed JPEG",
];
let raw_policy = Select::new()
.with_prompt("When both RAW and JPEG versions exist:")
.items(raw_items)
.default(0)
.interact()?;
answers.raw_policy = match raw_policy {
1 => Some(RawPolicy::PreferRaw),
2 => Some(RawPolicy::PreferJpeg),
_ => None, // as-is is the default
};
}
Ok(())
}
// ── Step 6: Date range ─────────────────────────────────────────────
fn ask_date_range(answers: &mut SetupAnswers) -> anyhow::Result<()> {
println!();
let limit = Confirm::new()
.with_prompt("Want to limit syncing to a specific date range or recent photos?")
.default(false)
.interact()?;
if !limit {
return Ok(());
}
answers.skip_created_before = date_prompt("Only sync photos created after")?;
answers.skip_created_after = date_prompt("Only sync photos created before")?;
let recent: String = Input::new()
.with_prompt(
"Only consider the N most-recent assets per selected library/album/smart-folder pass (blank = all)",
)
.default(String::new())
.show_default(false)
.allow_empty(true)
.validate_with(|s: &String| parse_positive_or_blank::<u32>(s).map(|_| ()))
.interact_text()?;
if let Ok(Some(n)) = parse_positive_or_blank::<u32>(&recent) {
answers.recent = Some(n);
}
Ok(())
}
/// One of the two date-range prompts (`skip_created_before` /
/// `skip_created_after`). Returns the trimmed value or `None` for blank.
/// The `(ISO date ...)` suffix is uniform across both prompts.
fn date_prompt(label: &str) -> anyhow::Result<Option<String>> {
let prompt = format!("{label} (ISO date, datetime, or Nd interval; blank = no limit)");
let raw: String = Input::new()
.with_prompt(prompt)
.default(String::new())
.show_default(false)
.allow_empty(true)
.validate_with(|s: &String| validate_date_or_blank(s))
.interact_text()?;
let trimmed = raw.trim();
Ok(if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
})
}
/// Accept blank or anything `config::parse_created_date_filter` parses cleanly,
/// so a typo here surfaces with the same error the runtime would print.
fn validate_date_or_blank(s: &str) -> Result<(), String> {
if s.trim().is_empty() {
return Ok(());
}
crate::config::parse_created_date_filter(s.trim())
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Parse a wizard input that should be either blank or a strictly-positive
/// integer. Returns the parsed value (or `None` for blank), so callers don't
/// re-parse what dialoguer's `validate_with` already validated. `"0"` is
/// rejected because every caller treats blank (not zero) as "off".
fn parse_positive_or_blank<T: std::str::FromStr>(s: &str) -> Result<Option<T>, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed == "0" {
return Err("Enter a number greater than zero, or leave blank to disable.".to_string());
}
trimmed
.parse::<T>()
.map(Some)
.map_err(|_e| "Enter a positive integer, or leave blank.".to_string())
}
// ── Step 7: Running mode ───────────────────────────────────────────
fn ask_running_mode(answers: &mut SetupAnswers) -> anyhow::Result<()> {
println!();
let mode_items = [
"Manually when needed",
"Continuously in the background (watch mode)",
];
let mode = Select::new()
.with_prompt("How will you run kei?")
.items(mode_items)
.default(0)
.interact()?;
if mode == 1 {
// Range mirrors the runtime parser at `src/cli.rs::SyncArgs::watch_with_interval`.
let interval: u64 = Input::new()
.with_prompt("Re-sync every how many seconds (60..=86400)")
.default(3600u64)
.validate_with(|n: &u64| -> Result<(), String> {
if (60..=86400).contains(n) {
Ok(())
} else {
Err("Enter a value between 60 and 86400 seconds.".to_string())
}
})
.interact_text()?;
answers.watch_interval = Some(interval);
let systemd = Confirm::new()
.with_prompt("Running as a systemd service?")
.default(false)
.interact()?;
if systemd {
answers.notify_systemd = true;
let pid: String = Input::new()
.with_prompt("PID file path (blank = skip)")
.default(String::new())
.show_default(false)
.allow_empty(true)
.interact_text()?;
if !pid.trim().is_empty() {
answers.pid_file = Some(pid.trim().to_string());
}
}
// Read-only walk: logs missing files, never re-downloads or marks
// failed. Blank/0 = off; opt-in is intentional.
let reconcile: String = Input::new()
.with_prompt(
"Reconcile (read-only walk for missing local files) every N watch cycles (blank = off)",
)
.default(String::new())
.show_default(false)
.allow_empty(true)
.validate_with(|s: &String| parse_positive_or_blank::<u64>(s).map(|_| ()))
.interact_text()?;
if let Ok(Some(n)) = parse_positive_or_blank::<u64>(&reconcile) {
answers.reconcile_every_n_cycles = Some(n);
}
}
Ok(())
}
// ── Step 8: Extras ─────────────────────────────────────────────────
fn ask_extras(answers: &mut SetupAnswers) -> anyhow::Result<()> {
println!();
let configure = Confirm::new()
.with_prompt("Configure advanced options?")
.default(false)
.interact()?;
if !configure {
return Ok(());
}
println!();
// Smart folders (Favorites, Hidden, Recently Deleted, etc.). v0.13 added
// these as a first-class selector; default is `none`. One name per prompt
// line for the same reason as the album loop.
let include_smart = Confirm::new()
.with_prompt("Include smart folders (Favorites, Hidden, Recently Deleted, etc.)?")
.default(false)
.interact()?;
if include_smart {
let smart_scope_items = [
"All visible smart folders (Favorites, etc., excludes Hidden + Recently Deleted)",
"All including Hidden + Recently Deleted",
"Specific smart folders by name",
];
let smart_scope = Select::new()
.with_prompt("Which smart folders?")
.items(smart_scope_items)
.default(0)
.interact()?;
match smart_scope {
0 => answers.smart_folders = vec!["all".to_string()],
1 => answers.smart_folders = vec!["all-with-sensitive".to_string()],
_ => {
// Source-of-truth for the available names; stays in sync if
// any are added or renamed.
let known: Vec<&'static str> =
crate::icloud::photos::smart_folders::smart_folder_names().collect();
println!(
" Enter one smart-folder name per line. Press Enter on a blank line to finish."
);
println!(" Names are case-sensitive. Available smart folders:");
println!(" {}.", known.join(", "));
loop {
let name: String = Input::new()
.with_prompt("Smart folder name (blank to finish)")
.default(String::new())
.show_default(false)
.allow_empty(true)
.interact_text()?;
let trimmed = name.trim();
if trimmed.is_empty() {
break;
}
answers.smart_folders.push(trimmed.to_string());
}
}
}
}
// Notifications
println!();
let notify = Confirm::new()
.with_prompt("Run a notification script on events (2FA needed, sync complete, errors)?")
.default(false)
.interact()?;
if notify {
let script: String = Input::new().with_prompt("Script path").interact_text()?;
if !script.is_empty() {
answers.notification_script = Some(script);
}
}
// Performance. Ranges mirror runtime config validation so the wizard
// rejects values the sync path would also reject.
println!();
let threads: u16 = Input::new()
.with_prompt("Concurrent download threads (1..=64)")
.default(10u16)
.validate_with(|n: &u16| -> Result<(), String> {
if (1..=64).contains(n) {
Ok(())
} else {
Err("Enter a value between 1 and 64.".to_string())
}
})
.interact_text()?;
if threads != 10 {
answers.threads_num = Some(threads);
}
let retries: u32 = Input::new()
.with_prompt("Max retries per failed download (0..=100, 0 = disable)")
.default(3u32)
.validate_with(|n: &u32| -> Result<(), String> {
if (0..=100).contains(n) {
Ok(())
} else {
Err("Enter a value between 0 and 100.".to_string())
}
})
.interact_text()?;
if retries != 3 {
answers.max_retries = Some(retries);
}
let bandwidth: String = Input::new()
.with_prompt("Bandwidth limit (e.g. 10MB, 500K; blank = no limit)")
.default(String::new())
.show_default(false)
.interact_text()?;
if !bandwidth.trim().is_empty() {
// Validate at the same place the runtime config does, so a typo here
// surfaces immediately instead of on the next sync.
match crate::cli::parse_bandwidth_limit(bandwidth.trim()) {
Ok(_) => answers.bandwidth_limit = Some(bandwidth.trim().to_string()),
Err(e) => println!(" Invalid bandwidth limit ({e}), skipping."),
}
}
// Exclusions: filename glob patterns applied across every pass.
println!();
let exclude = Confirm::new()
.with_prompt("Exclude files matching glob patterns (e.g. IMG_screenshot*.png)?")
.default(false)
.interact()?;
if exclude {
println!(" Enter one pattern per line. Press Enter on a blank line to finish.");
loop {
let pat: String = Input::new()
.with_prompt("Pattern (blank to finish)")
.default(String::new())
.show_default(false)
.allow_empty(true)
.interact_text()?;
let trimmed = pat.trim();
if trimmed.is_empty() {
break;
}
answers.filename_exclude.push(trimmed.to_string());
}
}
// Filenames
println!();
answers.keep_unicode_in_filenames = Confirm::new()
.with_prompt("Preserve Unicode characters in filenames?")
.default(false)
.interact()?;
answers.set_exif_datetime = Confirm::new()
.with_prompt("Write EXIF date tag if missing from photo?")
.default(false)
.interact()?;
#[cfg(feature = "xmp")]
{
answers.embed_xmp = Confirm::new()
.with_prompt("Embed XMP metadata (rating, GPS, description) into photos?")
.default(false)
.interact()?;
answers.xmp_sidecar = Confirm::new()
.with_prompt("Write a sidecar `.xmp` file alongside each photo?")
.default(false)
.interact()?;
}
// Dedup
println!();
let dedup_items = [
"By name and size, add suffix for duplicates",
"By name and iCloud ID (deterministic)",
];
let dedup = Select::new()
.with_prompt("File deduplication strategy")
.items(dedup_items)
.default(0)
.interact()?;
if dedup == 1 {
answers.file_match_policy = Some(FileMatchPolicy::NameId7);
}
// Log level
println!();
let log_items = ["info", "debug", "warn", "error"];
let log = Select::new()
.with_prompt("Log level")
.items(log_items)
.default(0)
.interact()?;
answers.log_level = match log {
1 => Some(LogLevel::Debug),
2 => Some(LogLevel::Warn),
3 => Some(LogLevel::Error),
_ => None, // info is the default
};
Ok(())
}
// ── TOML generation ────────────────────────────────────────────────
fn generate_toml(answers: &SetupAnswers) -> String {
// `fmt::Write for String` cannot fail (the `core` impl is unreachable),
// so wrap the `?`-propagating body in an IIFE and unwrap once at the
// boundary instead of polluting the public signature with a dead error
// channel.
#[allow(
clippy::expect_used,
reason = "fmt::Write for String is infallible; the IIFE only carries `?` so writeln! calls compile cleanly"
)]
(|| -> Result<String, std::fmt::Error> {
let mut out = String::with_capacity(2048);
writeln!(out, "# kei configuration")?;
writeln!(out, "# Generated by: kei setup")?;
writeln!(out)?;
// Top-level keys (data_dir, log_level)
match &answers.data_dir {
Some(dir) => writeln!(out, "data_dir = \"{}\"", escape_toml_string(dir))?,
None => writeln!(out, "# data_dir = \"~/.config/kei\"")?,
};
match answers.log_level {
Some(level) => writeln!(out, "log_level = \"{}\"", log_level_str(level))?,
None => writeln!(out, "# log_level = \"warn\"")?,
};
// [auth]
writeln!(out)?;
writeln!(out, "[auth]")?;
writeln!(
out,
"username = \"{}\"",
escape_toml_string(&answers.username)
)?;
match &answers.secret_source {
SetupSecretSource::CredentialStore => {
writeln!(
out,
"# Password is stored in kei's credential store, not in this file."
)?;
writeln!(
out,
"# Use `kei password backend` to check it or `kei password set` to change it."
)?;
}
SetupSecretSource::PasswordFile(path) => {
writeln!(out, "password_file = \"{}\"", escape_toml_string(path))?;
}
SetupSecretSource::PasswordCommand(command) => {
writeln!(
out,
"password_command = \"{}\"",
escape_toml_string(command)
)?;
}
SetupSecretSource::EnvFile => {
writeln!(
out,
"# Password is stored in .env file, not here (last-resort fallback)."
)?;
}
}
match answers.domain {
Some(domain) => writeln!(out, "domain = \"{}\"", domain.as_str())?,
None => writeln!(out, "# domain = \"com\"")?,
};
// [download]
writeln!(out)?;
writeln!(out, "[download]")?;
writeln!(
out,
"directory = \"{}\"",
escape_toml_string(&answers.directory)
)?;
// `folder_structure` is the unfiled-pass template (every photo not in
// any user album / smart folder). The two per-category templates
// below default to flat per-category folders; the wizard sets
// `folder_structure_albums` to mirror the chosen date hierarchy when
// appropriate (see ask_destination).
match &answers.folder_structure {
Some(fs) => writeln!(out, "folder_structure = \"{}\"", escape_toml_string(fs))?,
None => writeln!(out, "# folder_structure = \"%Y/%m/%d\"")?,
};
match &answers.folder_structure_albums {
Some(fs) => writeln!(
out,
"folder_structure_albums = \"{}\"",
escape_toml_string(fs)
)?,
None => writeln!(out, "# folder_structure_albums = \"{{album}}\"")?,
};
match &answers.folder_structure_smart_folders {
Some(fs) => writeln!(
out,
"folder_structure_smart_folders = \"{}\"",
escape_toml_string(fs)
)?,
None => writeln!(
out,
"# folder_structure_smart_folders = \"{{smart-folder}}\""
)?,
};
match answers.threads_num {
Some(n) => writeln!(out, "threads = {n}")?,
None => writeln!(out, "# threads = 10")?,
};
match &answers.bandwidth_limit {
Some(b) => writeln!(out, "bandwidth_limit = \"{}\"", escape_toml_string(b))?,
None => writeln!(
out,
"# bandwidth_limit = \"10MB\" # blank/comment = no limit"
)?,
};
writeln!(out, "# temp_suffix = \".kei-tmp\"")?;
// [download.retry]
writeln!(out)?;
writeln!(out, "[download.retry]")?;
match answers.max_retries {
Some(n) => writeln!(out, "per_transfer = {n}")?,
None => writeln!(out, "# per_transfer = 3")?,
};
// [metadata]
writeln!(out)?;
writeln!(out, "[metadata]")?;
if answers.set_exif_datetime {
writeln!(out, "set_exif_datetime = true")?;
} else {
writeln!(out, "# set_exif_datetime = false")?;
}
writeln!(out, "# set_exif_rating = false")?;
writeln!(out, "# set_exif_gps = false")?;
writeln!(out, "# set_exif_description = false")?;
#[cfg(feature = "xmp")]
{
if answers.embed_xmp {
writeln!(out, "embed_xmp = true")?;
} else {
writeln!(out, "# embed_xmp = false")?;
}
if answers.xmp_sidecar {
writeln!(out, "xmp_sidecar = true")?;
} else {
writeln!(out, "# xmp_sidecar = false")?;
}
}
// [filters]
writeln!(out)?;
writeln!(out, "[filters]")?;
if answers.libraries.is_empty() {
writeln!(out, "# libraries = [\"primary\"]")?;
} else {
let library_strs: Vec<String> = answers
.libraries
.iter()
.map(|l| format!("\"{}\"", escape_toml_string(l)))
.collect();
writeln!(out, "libraries = [{}]", library_strs.join(", "))?;
}
if answers.albums.is_empty() {
writeln!(out, "# albums = [\"all\"]")?;
} else {
let album_strs: Vec<String> = answers
.albums
.iter()
.map(|a| format!("\"{}\"", escape_toml_string(a)))
.collect();
writeln!(out, "albums = [{}]", album_strs.join(", "))?;
}
if answers.smart_folders.is_empty() {
writeln!(out, "# smart_folders = [\"none\"]")?;
} else {
let sf_strs: Vec<String> = answers
.smart_folders
.iter()
.map(|s| format!("\"{}\"", escape_toml_string(s)))
.collect();
writeln!(out, "smart_folders = [{}]", sf_strs.join(", "))?;
}
match answers.unfiled {
Some(false) => writeln!(out, "unfiled = false")?,
Some(true) => writeln!(out, "unfiled = true")?,
None => writeln!(out, "# unfiled = true")?,
};
if answers.filename_exclude.is_empty() {
writeln!(out, "# filename_exclude = []")?;
} else {
let pat_strs: Vec<String> = answers
.filename_exclude
.iter()
.map(|p| format!("\"{}\"", escape_toml_string(p)))
.collect();
writeln!(out, "filename_exclude = [{}]", pat_strs.join(", "))?;
}
if answers.skip_videos {
writeln!(out, "media = [\"photos\", \"live-photos\"]")?;
} else {
writeln!(out, "# media = [\"photos\", \"videos\", \"live-photos\"]")?;
}
// `live_photo_mode` is emitted in the [photos] section below.
match answers.recent {
Some(n) => writeln!(out, "recent = {n}")?,
None => writeln!(
out,
"# recent = 0 # 0 = all; cap applies per selected library/album/smart-folder pass"
)?,
};
match &answers.skip_created_before {
Some(d) => writeln!(out, "skip_created_before = \"{}\"", escape_toml_string(d))?,
None => writeln!(out, "# skip_created_before = \"\"")?,
};
match &answers.skip_created_after {
Some(d) => writeln!(out, "skip_created_after = \"{}\"", escape_toml_string(d))?,
None => writeln!(out, "# skip_created_after = \"\"")?,
};
// [photos]
writeln!(out)?;
writeln!(out, "[photos]")?;
match answers.resolution {
Some(size) => writeln!(out, "resolution = \"{}\"", version_size_str(size))?,
None => writeln!(out, "# resolution = \"original\"")?,
};
writeln!(out, "# edited = false")?;
writeln!(out, "# alternative = false")?;
writeln!(out, "# live_resolution = \"original\"")?;
match answers.live_photo_mode {
Some(mode) => writeln!(out, "live_photo_mode = \"{}\"", live_photo_mode_str(mode))?,
None => writeln!(out, "# live_photo_mode = \"both\"")?,
};
match answers.live_photo_mov_filename_policy {
Some(p) => writeln!(
out,
"live_photo_mov_filename_policy = \"{}\"",
mov_policy_str(p)
)?,
None => writeln!(out, "# live_photo_mov_filename_policy = \"suffix\"")?,
};
match answers.raw_policy {
Some(p) => writeln!(out, "raw_policy = \"{}\"", raw_policy_str(p))?,
None => writeln!(out, "# raw_policy = \"as-is\"")?,
};
match answers.file_match_policy {
Some(p) => writeln!(out, "file_match_policy = \"{}\"", file_match_str(p))?,
None => writeln!(out, "# file_match_policy = \"name-size-dedup-with-suffix\"")?,
};
if answers.force_resolution {
writeln!(out, "force_resolution = true")?;
} else {
writeln!(out, "# force_resolution = false")?;
}
if answers.keep_unicode_in_filenames {
writeln!(out, "keep_unicode_in_filenames = true")?;
} else {
writeln!(out, "# keep_unicode_in_filenames = false")?;
}
// [watch]
writeln!(out)?;
writeln!(out, "[watch]")?;
match answers.watch_interval {
Some(n) => writeln!(out, "interval = {n}")?,
None => writeln!(out, "# interval = 3600")?,
};
if answers.notify_systemd {
writeln!(out, "notify_systemd = true")?;
} else {
writeln!(out, "# notify_systemd = false")?;
}
match &answers.pid_file {
Some(p) => writeln!(out, "pid_file = \"{}\"", escape_toml_string(p))?,
None => writeln!(out, "# pid_file = \"\"")?,
};
match answers.reconcile_every_n_cycles {
Some(n) => writeln!(out, "reconcile_every_n_cycles = {n}")?,
None => writeln!(out, "# reconcile_every_n_cycles = 0 # 0/unset = off")?,
};
// [notifications]
writeln!(out)?;
writeln!(out, "[notifications]")?;
match &answers.notification_script {
Some(s) => writeln!(out, "script = \"{}\"", escape_toml_string(s))?,
None => writeln!(out, "# script = \"/path/to/script.sh\"")?,
}
// [server] - HTTP/Prometheus metrics endpoint. Replaces the removed
// [metrics] section. Hint-only; no wizard prompt.
writeln!(out)?;
writeln!(out, "[server]")?;
writeln!(out, "# port = 9090")?;
writeln!(out, "# bind = \"127.0.0.1\"")?;
// [report] - per-run JSON report destination. Hint-only.
writeln!(out)?;
writeln!(out, "[report]")?;
writeln!(out, "# json = \"/path/to/last-run.json\"")?;
// [ui] - friendly terminal progress and summaries. Default-on-for-TTY
// at runtime, so we only emit an active line when the user explicitly
// opted out; otherwise the section is hint-only.
writeln!(out)?;
writeln!(out, "[ui]")?;
match answers.ui_friendly {
Some(false) => writeln!(out, "friendly = false")?,
Some(true) => writeln!(out, "friendly = true")?,
None => writeln!(out, "# friendly = true # default on TTY")?,
}
Ok(out)
})()
.expect("formatting into a String is infallible")
}
fn escape_toml_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn log_level_str(level: LogLevel) -> &'static str {
match level {
LogLevel::Debug => "debug",
LogLevel::Info => "info",
LogLevel::Warn => "warn",
LogLevel::Error => "error",
}
}
fn version_size_str(size: PhotoResolution) -> &'static str {
match size {
PhotoResolution::None => "none",
PhotoResolution::Original => "original",
PhotoResolution::Medium => "medium",
PhotoResolution::Thumb => "thumb",
}
}
fn mov_policy_str(policy: LivePhotoMovFilenamePolicy) -> &'static str {
match policy {
LivePhotoMovFilenamePolicy::Suffix => "suffix",
LivePhotoMovFilenamePolicy::Original => "original",
}
}
fn live_photo_mode_str(mode: LivePhotoMode) -> &'static str {
// Must match the kebab-case rename in `LivePhotoMode`'s `Deserialize`
// (`#[serde(rename_all = "kebab-case")]`).
match mode {
LivePhotoMode::Both => "both",
LivePhotoMode::ImageOnly => "image-only",
LivePhotoMode::VideoOnly => "video-only",
LivePhotoMode::Skip => "skip",
}
}
fn raw_policy_str(policy: RawPolicy) -> &'static str {
match policy {
RawPolicy::AsIs => "as-is",
RawPolicy::PreferRaw => "prefer-raw",
RawPolicy::PreferJpeg => "prefer-jpeg",
}
}
fn file_match_str(policy: FileMatchPolicy) -> &'static str {
match policy {
FileMatchPolicy::NameSizeDedupWithSuffix => "name-size-dedup-with-suffix",
FileMatchPolicy::NameId7 => "name-id7",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TomlConfig;
use crate::types::LivePhotoMode;
#[test]
fn dim_comments_preserves_toml_text_while_styling_comments_and_sections() {
let rendered = dim_comments("# comment\n[auth]\nusername = \"u\"");
assert!(rendered.contains("# comment"));
assert!(rendered.contains("[auth]"));
assert!(rendered.contains("username = \"u\""));
assert_eq!(rendered.lines().count(), 3);
}
#[test]
fn setup_secret_source_password_prompt_policy_is_explicit() {
assert!(SetupSecretSource::CredentialStore.needs_password_prompt());
assert!(SetupSecretSource::EnvFile.needs_password_prompt());
assert!(
!SetupSecretSource::PasswordFile("/run/secrets/icloud".to_string())
.needs_password_prompt()
);
assert!(
!SetupSecretSource::PasswordCommand("op read item kei".to_string())
.needs_password_prompt()
);
}
#[test]
fn apply_library_scoped_templates_sets_library_safe_defaults_for_all_libraries() {
let mut answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
directory: "~/Photos/iCloud".to_string(),
libraries: vec!["all".to_string()],
folder_structure_albums: Some("{album}/%Y/%m/%d".to_string()),
..Default::default()
};
apply_library_scoped_templates_for_all_libraries(&mut answers);
assert_eq!(
answers.folder_structure.as_deref(),
Some("{library}/%Y/%m/%d")
);
assert_eq!(
answers.folder_structure_albums.as_deref(),
Some("{album}/%Y/%m/%d")
);
assert_eq!(answers.folder_structure_smart_folders.as_deref(), None);
}
#[test]
fn apply_library_scoped_templates_does_not_mutate_primary_only_setup() {
let mut answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
directory: "~/Photos/iCloud".to_string(),
libraries: Vec::new(),
..Default::default()
};
apply_library_scoped_templates_for_all_libraries(&mut answers);
assert_eq!(answers.folder_structure, None);
assert_eq!(answers.folder_structure_albums, None);
assert_eq!(answers.folder_structure_smart_folders, None);
}
#[test]
fn apply_library_scoped_templates_avoids_double_prefixing() {
let mut answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
directory: "~/Photos/iCloud".to_string(),
libraries: vec!["all".to_string()],
folder_structure: Some("{library}/%Y/%m".to_string()),
folder_structure_albums: Some("{album}".to_string()),
folder_structure_smart_folders: Some("{smart-folder}".to_string()),
..Default::default()
};
apply_library_scoped_templates_for_all_libraries(&mut answers);
assert_eq!(answers.folder_structure.as_deref(), Some("{library}/%Y/%m"));
assert_eq!(answers.folder_structure_albums.as_deref(), Some("{album}"));
assert_eq!(
answers.folder_structure_smart_folders.as_deref(),
Some("{smart-folder}")
);
}
#[test]
fn sync_command_for_config_shell_quotes_paths() {
assert_eq!(
sync_command_for_config(Path::new("/tmp/kei config.toml")),
"kei --config '/tmp/kei config.toml' sync"
);
assert_eq!(
sync_command_for_config(Path::new("/tmp/rob's-config.toml")),
"kei --config '/tmp/rob'\\''s-config.toml' sync"
);
}
#[test]
fn test_generate_toml_defaults_only() {
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml = generate_toml(&answers);
// Must contain the username uncommented
assert!(toml.contains("username = \"user@example.com\""));
// Must contain directory uncommented
assert!(toml.contains("directory = \"~/Photos/iCloud\""));
// Libraries should be set to ["all"] (v0.13 array form, not the
// removed singular `library` key).
assert!(toml.contains("libraries = [\"all\"]"));
assert!(!toml.contains("library = \"all\""));
// Password should NOT be in the TOML
assert!(!toml.contains("secret"));
// Defaults should be commented out
assert!(toml.contains("# resolution = \"original\""));
assert!(toml.contains("# threads = 10"));
assert!(toml.contains("# log_level = \"warn\""));
assert!(toml.contains("# data_dir = \"~/.config/kei\""));
assert!(toml.contains("Password is stored in kei's credential store"));
assert!(toml.contains("kei password backend"));
assert!(!toml.contains("password_file ="));
assert!(!toml.contains("password_command ="));
assert!(!toml.contains("ICLOUD_PASSWORD"));
// Removed v0.20 keys must not appear in the generated config.
assert!(!toml.contains("cookie_directory"));
assert!(!toml.contains("skip_live_photos"));
}
#[test]
fn test_generate_toml_password_file_secret_source() {
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("hunter2"),
secret_source: SetupSecretSource::PasswordFile("/run/secrets/icloud".to_string()),
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(toml_str.contains("password_file = \"/run/secrets/icloud\""));
assert!(!toml_str.contains("hunter2"));
let parsed: TomlConfig = toml::from_str(&toml_str).unwrap();
let auth = parsed.auth.unwrap();
assert_eq!(auth.password_file.as_deref(), Some("/run/secrets/icloud"));
assert!(auth.password.is_none());
}
#[test]
fn test_generate_toml_password_command_secret_source() {
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("hunter2"),
secret_source: SetupSecretSource::PasswordCommand("op read item".to_string()),
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(toml_str.contains("password_command = \"op read item\""));
assert!(!toml_str.contains("hunter2"));
let parsed: TomlConfig = toml::from_str(&toml_str).unwrap();
let auth = parsed.auth.unwrap();
assert_eq!(auth.password_command.as_deref(), Some("op read item"));
assert!(auth.password.is_none());
}
#[test]
fn test_password_prompt_only_runs_for_setup_owned_secrets() {
assert!(SetupSecretSource::CredentialStore.needs_password_prompt());
assert!(SetupSecretSource::EnvFile.needs_password_prompt());
assert!(
!SetupSecretSource::PasswordFile("/run/secrets/icloud".to_string())
.needs_password_prompt()
);
assert!(
!SetupSecretSource::PasswordCommand("op read item".to_string()).needs_password_prompt()
);
}
// ── [ui] section emission ───────────────────────────────────────
//
// The wizard's friendly question is the only opt-out path baked into
// the TOML. Default answers (skipped or "yes") leave `[ui].friendly`
// commented so the runtime keeps freedom to flip the default; an
// explicit "no" must persist as `friendly = false`.
#[test]
fn test_generate_toml_default_leaves_ui_friendly_commented() {
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(
toml_str.contains("[ui]"),
"[ui] section header must always render so users see the option"
);
assert!(
toml_str.contains("# friendly = true"),
"default-yes answer must leave the friendly key commented; got:\n{toml_str}"
);
// Round-trip: parser sees no preference.
let parsed: TomlConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.ui.and_then(|u| u.friendly), None);
}
#[test]
fn test_generate_toml_opt_out_emits_friendly_false() {
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
directory: "~/Photos/iCloud".to_string(),
ui_friendly: Some(false),
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(
toml_str.contains("\nfriendly = false"),
"explicit opt-out must emit an active `friendly = false` line; got:\n{toml_str}"
);
let parsed: TomlConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(
parsed.ui.and_then(|u| u.friendly),
Some(false),
"round-trip parse must preserve the opt-out"
);
}
#[test]
fn test_generate_toml_roundtrip() {
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
// Must parse as valid TOML config
let parsed: TomlConfig = toml::from_str(&toml_str)
.unwrap_or_else(|e| panic!("Generated TOML failed to parse: {e}\n\n{toml_str}"));
// Verify values round-trip
let auth = parsed.auth.expect("auth section missing");
assert_eq!(auth.username.as_deref(), Some("user@example.com"));
assert!(auth.password.is_none());
let download = parsed.download.expect("download section missing");
assert_eq!(download.directory.as_deref(), Some("~/Photos/iCloud"));
let filters = parsed.filters.expect("filters section missing");
assert_eq!(filters.libraries.as_deref(), Some(&["all".to_string()][..]));
}
#[test]
fn test_write_setup_files_stores_password_and_skips_env_file() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("data");
let config_path = dir.path().join("config.toml");
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
data_dir: Some(data_dir.display().to_string()),
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
let result = write_setup_files_with_store(
&config_path,
&toml_str,
&answers,
|username, credential_dir, password| {
assert_eq!(username, "user@example.com");
assert_eq!(credential_dir, data_dir.as_path());
assert_eq!(password, "secret");
Ok(CredentialBackend::Keyring)
},
)
.unwrap();
assert_eq!(result.credential_backend, Some(CredentialBackend::Keyring));
assert!(result.env_path.is_none());
assert!(!dir.path().join(".env").exists());
let summary = secret_summary_lines(&answers, &result);
assert_eq!(summary[0], " Secrets → keyring backend");
assert!(
summary[1].contains("kei password backend"),
"summary should point at credential inspection: {summary:?}"
);
assert!(
summary[1].contains("kei password set"),
"summary should point at credential replacement: {summary:?}"
);
let written = std::fs::read_to_string(&config_path).unwrap();
assert!(!written.contains("secret"));
let parsed: TomlConfig = toml::from_str(&written).unwrap();
assert!(parsed.auth.unwrap().password.is_none());
}
#[test]
fn test_write_setup_files_reports_encrypted_file_backend() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
let result = write_setup_files_with_store(
&config_path,
&toml_str,
&answers,
|_username, _credential_dir, password| {
assert_eq!(password, "secret");
Ok(CredentialBackend::EncryptedFile)
},
)
.unwrap();
assert_eq!(
result.credential_backend,
Some(CredentialBackend::EncryptedFile)
);
assert!(result.env_path.is_none());
let summary = secret_summary_lines(&answers, &result);
assert_eq!(summary[0], " Secrets → encrypted-file backend");
assert!(
summary[1].contains("kei password backend"),
"summary should point at credential inspection: {summary:?}"
);
assert!(
summary[1].contains("kei password set"),
"summary should point at credential replacement: {summary:?}"
);
}
#[test]
fn test_write_setup_files_does_not_write_config_when_credential_store_fails() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
let err = write_setup_files_with_store(
&config_path,
&toml_str,
&answers,
|_username, _credential_dir, _password| anyhow::bail!("store failed"),
)
.unwrap_err();
assert!(
err.to_string().contains("store failed"),
"expected store failure, got {err}"
);
assert!(
!config_path.exists(),
"config must not be committed unless the selected credential store accepts the password"
);
}
#[test]
fn test_write_setup_files_env_fallback_writes_env_not_toml_password() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
secret_source: SetupSecretSource::EnvFile,
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
let result = write_setup_files_with_store(
&config_path,
&toml_str,
&answers,
|_username, _credential_dir, _password| {
panic!("env fallback must not touch credential storage");
},
)
.unwrap();
let env_path = result.env_path.expect("env fallback should write .env");
assert_eq!(env_path, dir.path().join(".env"));
let written = std::fs::read_to_string(&config_path).unwrap();
assert!(!written.contains("secret"));
let env_content = std::fs::read_to_string(&env_path).unwrap();
assert!(env_content.contains("ICLOUD_USERNAME='user@example.com'"));
assert!(env_content.contains("ICLOUD_PASSWORD='secret'"));
}
#[test]
fn test_generate_toml_full() {
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
domain: Some(Domain::Cn),
secret_source: SetupSecretSource::CredentialStore,
directory: "~/photos".to_string(),
folder_structure: Some("%Y/%m".to_string()),
folder_structure_albums: Some("{album}/%Y/%m".to_string()),
albums: vec!["Favorites".to_string(), "Vacation".to_string()],
libraries: vec!["all".to_string()],
smart_folders: vec!["all".to_string()],
folder_structure_smart_folders: None,
unfiled: Some(false),
filename_exclude: vec!["IMG_screenshot*.png".to_string()],
skip_videos: true,
live_photo_mode: Some(LivePhotoMode::Both),
live_photo_mov_filename_policy: Some(LivePhotoMovFilenamePolicy::Original),
resolution: Some(PhotoResolution::Medium),
force_resolution: true,
raw_policy: Some(RawPolicy::PreferRaw),
recent: Some(100),
skip_created_before: Some("2024-01-01".to_string()),
skip_created_after: Some("2025-01-01".to_string()),
watch_interval: Some(1800),
notify_systemd: true,
pid_file: Some("/var/run/kei.pid".to_string()),
reconcile_every_n_cycles: Some(24),
notification_script: Some("/usr/local/bin/notify.sh".to_string()),
threads_num: Some(4),
max_retries: Some(5),
bandwidth_limit: Some("10MB".to_string()),
keep_unicode_in_filenames: true,
set_exif_datetime: true,
#[cfg(feature = "xmp")]
embed_xmp: true,
#[cfg(feature = "xmp")]
xmp_sidecar: false,
file_match_policy: Some(FileMatchPolicy::NameId7),
data_dir: Some("~/.kei".to_string()),
log_level: Some(LogLevel::Debug),
ui_friendly: Some(false),
};
let toml_str = generate_toml(&answers);
// All user-set values should be uncommented
assert!(toml_str.contains("domain = \"cn\""));
assert!(toml_str.contains("folder_structure = \"%Y/%m\""));
assert!(toml_str.contains("folder_structure_albums = \"{album}/%Y/%m\""));
assert!(toml_str.contains("albums = [\"Favorites\", \"Vacation\"]"));
assert!(toml_str.contains("libraries = [\"all\"]"));
assert!(toml_str.contains("smart_folders = [\"all\"]"));
assert!(toml_str.contains("unfiled = false"));
assert!(toml_str.contains("filename_exclude = [\"IMG_screenshot*.png\"]"));
assert!(toml_str.contains("media = [\"photos\", \"live-photos\"]"));
assert!(toml_str.contains("resolution = \"medium\""));
// live_photo_mode = "both" is the default; emitting it explicitly is
// also fine, but the test above sets `Some(Both)` so we expect it.
assert!(toml_str.contains("live_photo_mode = \"both\""));
assert!(toml_str.contains("force_resolution = true"));
assert!(toml_str.contains("raw_policy = \"prefer-raw\""));
assert!(toml_str.contains("recent = 100"));
assert!(toml_str.contains("interval = 1800"));
assert!(toml_str.contains("notify_systemd = true"));
assert!(toml_str.contains("reconcile_every_n_cycles = 24"));
assert!(toml_str.contains("threads = 4"));
assert!(toml_str.contains("bandwidth_limit = \"10MB\""));
assert!(toml_str.contains("file_match_policy = \"name-id7\""));
assert!(toml_str.contains("log_level = \"debug\""));
assert!(toml_str.contains("set_exif_datetime = true"));
#[cfg(feature = "xmp")]
{
assert!(toml_str.contains("embed_xmp = true"));
// `xmp_sidecar = false` here means it's commented out, not active.
assert!(toml_str.contains("# xmp_sidecar = false"));
}
assert!(toml_str.contains("keep_unicode_in_filenames = true"));
assert!(toml_str.contains("data_dir = \"~/.kei\""));
assert!(toml_str.contains("script = \"/usr/local/bin/notify.sh\""));
// Must still parse
let _parsed: TomlConfig = toml::from_str(&toml_str)
.unwrap_or_else(|e| panic!("Generated TOML failed to parse: {e}\n\n{toml_str}"));
}
#[test]
fn test_generate_toml_full_roundtrip_values() {
let answers = SetupAnswers {
username: "test@icloud.com".to_string(),
password: secrecy::SecretString::from("pw"),
domain: Some(Domain::Cn),
secret_source: SetupSecretSource::CredentialStore,
directory: "/data/photos".to_string(),
folder_structure: Some("%Y-%m".to_string()),
folder_structure_albums: Some("{album}/%Y-%m".to_string()),
albums: vec!["A".to_string()],
libraries: Vec::new(),
smart_folders: vec!["Favorites".to_string()],
folder_structure_smart_folders: None,
unfiled: Some(false),
filename_exclude: vec!["*.tmp".to_string()],
skip_videos: true,
live_photo_mode: Some(LivePhotoMode::Skip),
live_photo_mov_filename_policy: Some(LivePhotoMovFilenamePolicy::Original),
resolution: Some(PhotoResolution::Thumb),
force_resolution: true,
raw_policy: Some(RawPolicy::PreferJpeg),
recent: Some(50),
skip_created_before: Some("30d".to_string()),
skip_created_after: Some("2025-06-01".to_string()),
watch_interval: Some(600),
notify_systemd: true,
pid_file: Some("/tmp/pid".to_string()),
reconcile_every_n_cycles: Some(48),
notification_script: Some("/bin/notify".to_string()),
threads_num: Some(2),
max_retries: Some(0),
bandwidth_limit: Some("1Mi".to_string()),
keep_unicode_in_filenames: true,
set_exif_datetime: true,
#[cfg(feature = "xmp")]
embed_xmp: true,
#[cfg(feature = "xmp")]
xmp_sidecar: true,
file_match_policy: Some(FileMatchPolicy::NameId7),
data_dir: Some("/var/lib/kei".to_string()),
log_level: Some(LogLevel::Error),
ui_friendly: Some(false),
};
let toml_str = generate_toml(&answers);
let parsed: TomlConfig = toml::from_str(&toml_str)
.unwrap_or_else(|e| panic!("Failed to parse: {e}\n\n{toml_str}"));
assert_eq!(parsed.data_dir.as_deref(), Some("/var/lib/kei"));
let auth = parsed.auth.unwrap();
assert_eq!(auth.username.as_deref(), Some("test@icloud.com"));
assert_eq!(auth.domain, Some(Domain::Cn));
let dl = parsed.download.unwrap();
assert_eq!(dl.directory.as_deref(), Some("/data/photos"));
assert_eq!(dl.folder_structure.as_deref(), Some("%Y-%m"));
assert_eq!(dl.folder_structure_albums.as_deref(), Some("{album}/%Y-%m"));
assert_eq!(dl.threads, Some(2));
assert_eq!(dl.bandwidth_limit.as_deref(), Some("1Mi"));
let metadata = parsed.metadata.unwrap();
assert_eq!(metadata.set_exif_datetime, Some(true));
#[cfg(feature = "xmp")]
{
assert_eq!(metadata.embed_xmp, Some(true));
assert_eq!(metadata.xmp_sidecar, Some(true));
}
let retry = dl.retry.unwrap();
assert_eq!(retry.per_transfer, Some(0));
let filters = parsed.filters.unwrap();
assert_eq!(filters.albums.as_deref(), Some(&["A".to_string()][..]));
assert_eq!(
filters.smart_folders.as_deref(),
Some(&["Favorites".to_string()][..])
);
assert_eq!(filters.unfiled, Some(false));
assert_eq!(
filters.filename_exclude.as_deref(),
Some(&["*.tmp".to_string()][..])
);
assert_eq!(
filters.media.as_deref(),
Some(
&[
crate::config::MediaKind::Photos,
crate::config::MediaKind::LivePhotos,
][..]
)
);
assert!(
filters.libraries.is_none(),
"empty libraries vec must produce a comment, not an array"
);
assert_eq!(filters.recent, Some(crate::cli::RecentLimit::Count(50)));
assert_eq!(filters.skip_created_before.as_deref(), Some("30d"));
assert_eq!(filters.skip_created_after.as_deref(), Some("2025-06-01"));
let photos = parsed.photos.unwrap();
assert_eq!(photos.resolution, Some(PhotoResolution::Thumb));
assert_eq!(photos.force_resolution, Some(true));
assert_eq!(photos.raw_policy, Some(RawPolicy::PreferJpeg));
assert_eq!(
photos.live_photo_mov_filename_policy,
Some(LivePhotoMovFilenamePolicy::Original)
);
assert_eq!(photos.live_photo_mode, Some(LivePhotoMode::Skip));
assert_eq!(photos.file_match_policy, Some(FileMatchPolicy::NameId7));
assert_eq!(photos.keep_unicode_in_filenames, Some(true));
let watch = parsed.watch.unwrap();
assert_eq!(watch.interval, Some(600));
assert_eq!(watch.notify_systemd, Some(true));
assert_eq!(watch.pid_file.as_deref(), Some("/tmp/pid"));
assert_eq!(watch.reconcile_every_n_cycles, Some(48));
let notif = parsed.notifications.unwrap();
assert_eq!(notif.script.as_deref(), Some("/bin/notify"));
assert_eq!(parsed.log_level, Some(LogLevel::Error));
}
#[test]
fn test_generate_toml_albums_array() {
let answers = SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "/d".to_string(),
albums: vec!["My Album".to_string(), "Vacation \"2024\"".to_string()],
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(toml_str.contains("albums = [\"My Album\", \"Vacation \\\"2024\\\"\"]"));
// Must still parse
let parsed: TomlConfig = toml::from_str(&toml_str)
.unwrap_or_else(|e| panic!("Failed to parse: {e}\n\n{toml_str}"));
let albums = parsed.filters.unwrap().albums.unwrap();
assert_eq!(albums, vec!["My Album", "Vacation \"2024\""]);
}
/// Single source of truth: the wizard must never emit any removed TOML key.
/// Add future removed keys to `REMOVED_KEYS` so wizard authors get a CI
/// failure pointing them at the right field.
#[test]
fn test_wizard_never_emits_removed_keys() {
// Cover both "default" answers (most users) and "every option set"
// answers (everything the wizard can possibly emit).
let cases: Vec<SetupAnswers> = vec![
SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "~/Photos".to_string(),
..Default::default()
},
SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "/photos".to_string(),
albums: vec!["A".to_string()],
libraries: Vec::new(),
smart_folders: vec!["all".to_string()],
folder_structure_smart_folders: None,
unfiled: Some(false),
filename_exclude: vec!["*.tmp".to_string()],
skip_videos: true,
live_photo_mode: Some(LivePhotoMode::Skip),
live_photo_mov_filename_policy: Some(LivePhotoMovFilenamePolicy::Original),
resolution: Some(PhotoResolution::Thumb),
force_resolution: true,
raw_policy: Some(RawPolicy::PreferJpeg),
bandwidth_limit: Some("10MB".to_string()),
reconcile_every_n_cycles: Some(24),
set_exif_datetime: true,
#[cfg(feature = "xmp")]
embed_xmp: true,
#[cfg(feature = "xmp")]
xmp_sidecar: true,
file_match_policy: Some(FileMatchPolicy::NameId7),
data_dir: Some("/var/lib/kei".to_string()),
log_level: Some(LogLevel::Error),
..Default::default()
},
];
// Removed keys must not appear in wizard output. Match
// `key = ` (with the equals sign) so we don't false-positive on
// comment hints or substring matches inside another key.
const REMOVED_KEYS: &[(&str, &str)] = &[
(
"cookie_directory =",
"[auth].cookie_directory -> top-level data_dir",
),
(
"library =",
"[filters].library (singular) -> [filters].libraries (array)",
),
(
"album =",
"[filters].album (singular) -> [filters].albums (array)",
),
(
"exclude_albums =",
"[filters].exclude_albums -> [filters].albums with !name entries",
),
(
"skip_live_photos =",
"[filters].skip_live_photos -> [photos].live_photo_mode",
),
("skip_videos =", "[filters].skip_videos -> [filters].media"),
("skip_photos =", "[filters].skip_photos -> [filters].media"),
(
"threads_num =",
"[download].threads_num -> [download].threads",
),
];
for answers in &cases {
let toml_str = generate_toml(answers);
// Strip comment lines so the substring search only inspects
// active assignments, not the `# foo = ...` hint comments.
let active: String = toml_str
.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
for (needle, msg) in REMOVED_KEYS {
assert!(
!active.contains(needle),
"wizard emitted removed key `{needle}` ({msg}); full output:\n{toml_str}"
);
}
}
}
/// "Specific albums" + the user wants only those albums (declined the
/// "also download photos not in any of these albums?" prompt) -> wizard
/// must emit `unfiled = false`. The pre-fix wizard implicitly relied on
/// v0.13's `unfiled = true` default and pulled every other photo into
/// the unfiled pass.
#[test]
fn test_specific_albums_with_unfiled_disabled_emits_unfiled_false() {
let answers = SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "/d".to_string(),
albums: vec!["Vacation".to_string()],
unfiled: Some(false),
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(toml_str.contains("unfiled = false"));
let parsed: TomlConfig =
toml::from_str(&toml_str).expect("generated TOML must parse cleanly");
assert_eq!(parsed.filters.unwrap().unfiled, Some(false));
}
/// "Specific albums" + the user wants those AND every other photo
/// (accepted the unfiled prompt) -> wizard emits `unfiled = true`
/// explicitly so the generated config records that selection.
#[test]
fn test_specific_albums_with_unfiled_enabled_emits_unfiled_true() {
let answers = SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "/d".to_string(),
albums: vec!["Vacation".to_string()],
unfiled: Some(true),
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(toml_str.contains("unfiled = true"));
let parsed: TomlConfig =
toml::from_str(&toml_str).expect("generated TOML must parse cleanly");
assert_eq!(parsed.filters.unwrap().unfiled, Some(true));
}
/// When the user picks a date hierarchy, the wizard must emit a
/// `folder_structure_albums = "{album}/<template>"` so album passes share
/// the same date layout as the unfiled pass. The v0.13 default for the
/// per-album template is the flat `{album}` (no date), which silently
/// changes the on-disk layout for v0.12 users who only set
/// `--folder-structure %Y/%m/%d`.
#[test]
fn test_date_template_lifts_into_folder_structure_albums() {
let answers = SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "/d".to_string(),
// Match what ask_destination sets for the "By month" choice.
folder_structure: Some("%Y/%m".to_string()),
folder_structure_albums: Some("{album}/%Y/%m".to_string()),
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(toml_str.contains("folder_structure = \"%Y/%m\""));
assert!(toml_str.contains("folder_structure_albums = \"{album}/%Y/%m\""));
let parsed: TomlConfig = toml::from_str(&toml_str).expect("must parse");
let dl = parsed.download.unwrap();
assert_eq!(dl.folder_structure.as_deref(), Some("%Y/%m"));
assert_eq!(dl.folder_structure_albums.as_deref(), Some("{album}/%Y/%m"));
}
/// When the user picks "All in one folder", the wizard emits
/// `folder_structure = ""` (empty unfiled template) and intentionally
/// leaves `folder_structure_albums` unset so albums keep their flat
/// per-album folder default. Collapsing per-album folders into a single
/// flat directory is rarely what the user wanted from "all in one".
#[test]
fn test_all_in_one_folder_does_not_set_folder_structure_albums() {
let answers = SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "/d".to_string(),
folder_structure: Some(String::new()),
folder_structure_albums: None,
..Default::default()
};
let toml_str = generate_toml(&answers);
// Empty template emits as `folder_structure = ""`, not `none`.
assert!(toml_str.contains("folder_structure = \"\""));
let active: String = toml_str
.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
assert!(!active.contains("folder_structure_albums ="));
assert!(toml_str.contains("# folder_structure_albums = \"{album}\""));
}
/// The wizard must be able to emit every `LivePhotoMode` value the
/// runtime accepts. The pre-fix wizard only exposed Both vs Skip, hiding
/// `image-only` and `video-only` from interactive setup.
#[test]
fn test_live_photo_mode_emits_every_runtime_variant() {
for (mode, expected) in [
(LivePhotoMode::Both, "live_photo_mode = \"both\""),
(LivePhotoMode::ImageOnly, "live_photo_mode = \"image-only\""),
(LivePhotoMode::VideoOnly, "live_photo_mode = \"video-only\""),
(LivePhotoMode::Skip, "live_photo_mode = \"skip\""),
] {
let answers = SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "/d".to_string(),
live_photo_mode: Some(mode),
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(
toml_str.contains(expected),
"expected `{expected}` for mode {mode:?}; got:\n{toml_str}"
);
let parsed: TomlConfig = toml::from_str(&toml_str).expect("must parse");
assert_eq!(parsed.photos.unwrap().live_photo_mode, Some(mode));
}
}
/// Smart-folder selector emission, including the v0.13 grammar
/// (`all`, `all-with-sensitive`, named entries, `!exclusion`).
#[test]
fn test_smart_folders_emission() {
let answers = SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "/d".to_string(),
smart_folders: vec![
"all".to_string(),
"!Hidden".to_string(),
"Recently Saved".to_string(),
],
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(toml_str.contains("smart_folders = [\"all\", \"!Hidden\", \"Recently Saved\"]"));
let parsed: TomlConfig = toml::from_str(&toml_str).expect("must parse");
let sf = parsed.filters.unwrap().smart_folders.unwrap();
assert_eq!(sf, vec!["all", "!Hidden", "Recently Saved"]);
}
/// `[metrics]` was removed in v0.20. The wizard must emit `[server]`
/// instead and never name `[metrics]` even as a hint.
#[test]
fn test_wizard_emits_server_section_not_metrics() {
let answers = SetupAnswers {
username: "u@e.com".to_string(),
password: secrecy::SecretString::from("p"),
directory: "/d".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
assert!(toml_str.contains("[server]"));
assert!(toml_str.contains("# port = 9090"));
assert!(
!toml_str.contains("[metrics]"),
"wizard must not name the removed [metrics] section; got:\n{toml_str}"
);
}
#[test]
fn test_generate_toml_enum_values() {
// Verify each enum serializes to the correct TOML string that
// the config parser expects.
assert_eq!(version_size_str(PhotoResolution::Original), "original");
assert_eq!(version_size_str(PhotoResolution::Medium), "medium");
assert_eq!(version_size_str(PhotoResolution::Thumb), "thumb");
assert_eq!(raw_policy_str(RawPolicy::AsIs), "as-is");
assert_eq!(raw_policy_str(RawPolicy::PreferRaw), "prefer-raw");
assert_eq!(raw_policy_str(RawPolicy::PreferJpeg), "prefer-jpeg");
assert_eq!(
file_match_str(FileMatchPolicy::NameSizeDedupWithSuffix),
"name-size-dedup-with-suffix"
);
assert_eq!(file_match_str(FileMatchPolicy::NameId7), "name-id7");
assert_eq!(mov_policy_str(LivePhotoMovFilenamePolicy::Suffix), "suffix");
assert_eq!(
mov_policy_str(LivePhotoMovFilenamePolicy::Original),
"original"
);
assert_eq!(live_photo_mode_str(LivePhotoMode::Both), "both");
assert_eq!(live_photo_mode_str(LivePhotoMode::ImageOnly), "image-only");
assert_eq!(live_photo_mode_str(LivePhotoMode::VideoOnly), "video-only");
assert_eq!(live_photo_mode_str(LivePhotoMode::Skip), "skip");
assert_eq!(log_level_str(LogLevel::Debug), "debug");
assert_eq!(log_level_str(LogLevel::Info), "info");
assert_eq!(log_level_str(LogLevel::Warn), "warn");
assert_eq!(log_level_str(LogLevel::Error), "error");
}
#[test]
fn test_escape_toml_string() {
assert_eq!(escape_toml_string("hello"), "hello");
assert_eq!(escape_toml_string("he\"llo"), "he\\\"llo");
assert_eq!(escape_toml_string("c:\\path"), "c:\\\\path");
}
/// T-5: The .env file created by the setup wizard must have mode 0o600
/// so credentials are not world-readable.
#[cfg(unix)]
#[test]
fn test_env_file_created_with_restricted_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let answers = SetupAnswers {
username: "test@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
secret_source: SetupSecretSource::EnvFile,
directory: "~/Photos/iCloud".to_string(),
..Default::default()
};
let toml_str = generate_toml(&answers);
let result = write_setup_files_with_store(
&config_path,
&toml_str,
&answers,
|_username, _credential_dir, _password| {
panic!("env fallback must not touch credential storage");
},
)
.unwrap();
let env_path = result.env_path.unwrap();
// Verify permissions
let metadata = std::fs::metadata(&env_path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"expected mode 0o600 (owner rw only), got {mode:#o}"
);
// Verify content
let content = std::fs::read_to_string(&env_path).unwrap();
assert!(content.contains("ICLOUD_USERNAME='test@example.com'"));
assert!(content.contains("ICLOUD_PASSWORD='secret'"));
}
#[test]
fn secret_summary_lines_covers_every_secret_source() {
let env_dir = tempfile::tempdir().unwrap();
let env_path = env_dir.path().join(".env");
for (secret_source, write_result, expected) in [
(
SetupSecretSource::CredentialStore,
SetupWriteResult {
credential_backend: None,
env_path: None,
},
vec![
" Secrets → credential-store backend".to_string(),
" Use `kei password backend` to check it or `kei password set` to change it."
.to_string(),
],
),
(
SetupSecretSource::PasswordFile("/run/secrets/icloud".to_string()),
SetupWriteResult {
credential_backend: None,
env_path: None,
},
vec![" Secrets → password file: /run/secrets/icloud".to_string()],
),
(
SetupSecretSource::PasswordCommand("op read item kei".to_string()),
SetupWriteResult {
credential_backend: None,
env_path: None,
},
vec![" Secrets → password command from config".to_string()],
),
(
SetupSecretSource::EnvFile,
SetupWriteResult {
credential_backend: None,
env_path: Some(env_path.clone()),
},
vec![format!(" Secrets → {}", env_path.display())],
),
] {
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
secret_source,
directory: "/photos".to_string(),
..Default::default()
};
assert_eq!(
secret_summary_lines(&answers, &write_result),
expected,
"unexpected secret summary for {:?}",
answers.secret_source
);
}
}
#[test]
fn shell_single_quote_escape_handles_embedded_quotes() {
assert_eq!(shell_single_quote_escape("plain"), "plain");
assert_eq!(shell_single_quote_escape("don't"), "don'\\''t");
assert_eq!(shell_single_quote_escape("a'b'c"), "a'\\''b'\\''c");
}
#[test]
fn credential_store_dir_uses_data_dir_or_cookie_default() {
let custom = SetupAnswers {
data_dir: Some("/var/lib/kei".to_string()),
..Default::default()
};
assert_eq!(credential_store_dir(&custom), PathBuf::from("/var/lib/kei"));
let default = SetupAnswers::default();
assert_eq!(
credential_store_dir(&default),
crate::config::default_cookie_dir()
);
}
#[test]
fn write_setup_files_with_external_secret_source_writes_only_config() {
for secret_source in [
SetupSecretSource::PasswordFile("/run/secrets/icloud".to_string()),
SetupSecretSource::PasswordCommand("op read item kei".to_string()),
] {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let answers = SetupAnswers {
username: "user@example.com".to_string(),
password: secrecy::SecretString::from("secret"),
secret_source,
..Default::default()
};
let result = write_setup_files_with_store(
&config_path,
"[auth]\nusername = \"user@example.com\"\n",
&answers,
|_username, _dir, _pw| {
panic!("external secret sources must not write to credential store")
},
)
.expect("setup files");
assert_eq!(result.credential_backend, None);
assert_eq!(result.env_path, None);
assert!(config_path.exists());
assert!(!dir.path().join(".env").exists());
}
}
// ── Numeric / date wizard-input validators ──────────────────────
#[test]
fn validate_date_or_blank_accepts_empty() {
assert!(validate_date_or_blank("").is_ok());
assert!(validate_date_or_blank(" ").is_ok());
}
#[test]
fn validate_date_or_blank_accepts_iso_date() {
assert!(validate_date_or_blank("2025-01-02").is_ok());
assert!(validate_date_or_blank("2025-01-02T14:30:00").is_ok());
}
#[test]
fn validate_date_or_blank_accepts_relative_interval() {
assert!(validate_date_or_blank("30d").is_ok());
assert!(validate_date_or_blank("1d").is_ok());
}
#[test]
fn validate_date_or_blank_rejects_garbage() {
for bad in ["2024-13-99", "tomorrow", "30dx", "abc", "999"] {
assert!(
validate_date_or_blank(bad).is_err(),
"bad input {bad:?} should have been rejected"
);
}
}
#[test]
fn parse_positive_or_blank_blank_is_none() {
assert_eq!(parse_positive_or_blank::<u32>("").unwrap(), None);
assert_eq!(parse_positive_or_blank::<u64>(" ").unwrap(), None);
}
#[test]
fn parse_positive_or_blank_accepts_positive() {
assert_eq!(parse_positive_or_blank::<u32>("1").unwrap(), Some(1));
assert_eq!(parse_positive_or_blank::<u32>("100").unwrap(), Some(100));
assert_eq!(
parse_positive_or_blank::<u32>("4294967295").unwrap(),
Some(u32::MAX)
);
assert_eq!(parse_positive_or_blank::<u64>("3600").unwrap(), Some(3600));
}
#[test]
fn parse_positive_or_blank_rejects_zero() {
let err = parse_positive_or_blank::<u32>("0").unwrap_err();
assert!(err.contains("greater than zero"), "got: {err}");
}
#[test]
fn parse_positive_or_blank_rejects_garbage() {
for bad in ["abc", "-1", "1.5", "100000000000"] {
assert!(
parse_positive_or_blank::<u32>(bad).is_err(),
"bad input {bad:?} should have been rejected"
);
}
}
}