//! Per-category selection model for the v0.13 selection-flags redesign. //! //! Each category (`albums`, `smart_folders`, `libraries`) has its own //! selector. Selectors are the resolved view of zero or more raw user inputs: //! sentinel words (`all`, `none`, `primary`, `shared`, `all-with-sensitive`), //! literal names, and `!literal-name` exclusions. Parsing happens at config //! resolution time — not at iCloud-call time — so invalid combinations fail //! before we open a network connection. //! //! The four-category bundle is [`Selection`], stored on //! [`crate::config::Config`]. The `commands::service` resolver consumes a //! `Selection` plus the live album/library map and emits concrete sync //! passes. //! //! See `.scratch/specs/selection-flags.md` for the design rationale. use std::collections::BTreeSet; /// Which categories of content the resolver can exclude from a category-wide /// "all" sweep. Same shape across album / smart-folder / library selectors. type ExcludeSet = BTreeSet; /// Album selection. /// /// Defaults to [`AlbumSelector::All`] with no exclusions, matching the /// "every user album" baseline of `kei sync` with no flags. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AlbumSelector { /// Sentinel `none`: explicitly skip every album pass. None, /// Sentinel `all` (or implicit when only `!name` exclusions are passed): /// every user album except those listed in `excluded`. All { excluded: ExcludeSet }, /// Explicit named albums (with optional `!name` excludes layered on top — /// rare but legal for forward compatibility with shell-glob expansion). Named { included: BTreeSet, excluded: ExcludeSet, }, } impl Default for AlbumSelector { fn default() -> Self { Self::All { excluded: ExcludeSet::new(), } } } /// Smart-folder selection. /// /// Defaults to [`SmartFolderSelector::None`]: smart folders aren't fetched /// unless the user opts in. This matches today's behaviour of suppressing /// smart folders during the `-a all` enumeration. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub enum SmartFolderSelector { /// Skip every smart-folder pass. #[default] None, /// Sentinel `all` / `all-with-sensitive`: every smart folder. The /// `include_sensitive` flag toggles whether Hidden and Recently Deleted /// are included. All { include_sensitive: bool, excluded: ExcludeSet, }, /// Explicit named smart folders. Named { included: BTreeSet, excluded: ExcludeSet, }, } /// Library selection. Different shape from the other two because the /// `primary` and `shared` sentinels carve out disjoint subsets. /// /// Default: `primary = true`, everything else empty (today's `--library /// PrimarySync` behaviour). #[derive(Debug, Clone, PartialEq, Eq)] pub struct LibrarySelector { /// Sentinel `primary`: include the PrimarySync zone. pub primary: bool, /// Sentinel `shared`: include every SharedSync-* zone. pub shared_all: bool, /// Explicit zone names. Either the full CloudKit zone (`PrimarySync`, /// `SharedSync-A1B2C3D4-...-...`) or the truncated 8-char form /// (`SharedSync-A1B2C3D4`) that `{library}` renders into paths. pub named: BTreeSet, /// `!name` exclusions, applied after the include set is resolved. pub excluded: ExcludeSet, } impl Default for LibrarySelector { fn default() -> Self { Self { primary: true, shared_all: false, named: BTreeSet::new(), excluded: ExcludeSet::new(), } } } impl LibrarySelector { /// True if the selector resolves to zero libraries. pub fn is_empty(&self) -> bool { !self.primary && !self.shared_all && self.named.is_empty() } } /// Bundle of every per-category selector plus the unfiled-pass toggle. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Selection { pub albums: AlbumSelector, pub albums_explicit: bool, pub smart_folders: SmartFolderSelector, pub smart_folders_explicit: bool, pub libraries: LibrarySelector, /// Run the unfiled (no-album) pass. Default `true` — orthogonal to /// `albums`, so `--album Vacation` still produces an unfiled pass unless /// `--unfiled false` is also passed. pub unfiled: bool, } impl Default for Selection { fn default() -> Self { Self { albums: AlbumSelector::default(), albums_explicit: false, smart_folders: SmartFolderSelector::default(), smart_folders_explicit: false, libraries: LibrarySelector::default(), unfiled: true, } } } // ── Parsing ───────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct SelectorToken<'a> { name: &'a str, exclude: bool, escaped: bool, } /// Parse a raw selector entry. A leading `=` escapes sentinel grammar so /// names like `all`, `none`, or `!Drafts` can be selected literally. fn selector_token<'a>(raw: &'a str, flag: &str) -> anyhow::Result> { if let Some(literal) = raw.strip_prefix('=') { anyhow::ensure!( !literal.is_empty(), "`--{flag}` literal value cannot be empty." ); return Ok(SelectorToken { name: literal, exclude: false, escaped: true, }); } let (name, exclude) = raw.strip_prefix('!').map_or((raw, false), |r| (r, true)); anyhow::ensure!(!name.is_empty(), "`--{flag}` needs a value."); anyhow::ensure!( !name.starts_with('='), "`!=value` is not valid for `--{flag}`. Use `=value` for a literal value or `!value` for an exclusion." ); Ok(SelectorToken { name, exclude, escaped: false, }) } fn escape_if_needed(name: &str, sentinels: &[&str]) -> String { if name.starts_with('!') || name.starts_with('=') || sentinels.iter().any(|s| name.eq_ignore_ascii_case(s)) { format!("={name}") } else { name.to_string() } } /// Insert into a BTreeSet, bailing if the value was already present. The /// duplicate-check pattern repeats across every parser; this lets callers /// describe the offending item once. `display_name` is a closure so the /// `format!("!{name}")` allocation only runs on the bail path, not on /// every successful insert. fn insert_unique( set: &mut BTreeSet, value: String, flag: &str, display_name: impl FnOnce() -> String, ) -> anyhow::Result<()> { if !set.insert(value) { anyhow::bail!("`--{flag} {}` was provided more than once.", display_name()); } Ok(()) } /// Parse `--album` raw values into an [`AlbumSelector`]. `default_to_all` is /// true when the user passed no `--album` flag at all (so the bare `!Foo` /// exclusion case still resolves to "all minus Foo"). pub(crate) fn parse_album_selector( raw: &[String], default_to_all: bool, ) -> anyhow::Result { if raw.is_empty() { return Ok(if default_to_all { AlbumSelector::default() } else { AlbumSelector::None }); } let mut has_all = false; let mut has_none = false; let mut included: BTreeSet = BTreeSet::new(); let mut excluded: BTreeSet = BTreeSet::new(); for entry in raw { let trimmed = entry.trim(); anyhow::ensure!(!trimmed.is_empty(), "`--album` needs a value."); let token = selector_token(trimmed, "album")?; let name = token.name; if name.eq_ignore_ascii_case("all") && !token.escaped { anyhow::ensure!( !token.exclude, "`--album !all` is not valid. Use `--album none` instead." ); has_all = true; } else if name.eq_ignore_ascii_case("none") && !token.escaped { anyhow::ensure!( !token.exclude, "`--album !none` is not valid. Omit that value instead." ); has_none = true; } else if token.exclude { insert_unique(&mut excluded, name.to_string(), "album", || { format!("!{name}") })?; } else { insert_unique(&mut included, name.to_string(), "album", || { name.to_string() })?; } } contradiction_check("album", &included, &excluded)?; if has_none { anyhow::ensure!( !has_all && included.is_empty() && excluded.is_empty(), "`--album none` cannot be combined with other `--album` values." ); return Ok(AlbumSelector::None); } if has_all { anyhow::ensure!( included.is_empty(), "`--album all` cannot be combined with album names. Use `--album !name` to exclude one." ); return Ok(AlbumSelector::All { excluded }); } if !included.is_empty() { return Ok(AlbumSelector::Named { included, excluded }); } // Only exclusions present → "all minus excluded". Ok(AlbumSelector::All { excluded }) } /// Parse `--smart-folder` raw values. Default is `None` — smart folders are /// off unless the user opts in. pub(crate) fn parse_smart_folder_selector(raw: &[String]) -> anyhow::Result { if raw.is_empty() { return Ok(SmartFolderSelector::None); } let mut has_all = false; let mut has_all_sensitive = false; let mut has_none = false; let mut included: BTreeSet = BTreeSet::new(); let mut excluded: BTreeSet = BTreeSet::new(); for entry in raw { let trimmed = entry.trim(); anyhow::ensure!(!trimmed.is_empty(), "`--smart-folder` needs a value."); let token = selector_token(trimmed, "smart-folder")?; let name = token.name; if name.eq_ignore_ascii_case("all") && !token.escaped { anyhow::ensure!(!token.exclude, "`--smart-folder !all` is not valid."); has_all = true; } else if name.eq_ignore_ascii_case("all-with-sensitive") && !token.escaped { anyhow::ensure!( !token.exclude, "`--smart-folder !all-with-sensitive` is not valid." ); has_all_sensitive = true; } else if name.eq_ignore_ascii_case("none") && !token.escaped { anyhow::ensure!(!token.exclude, "`--smart-folder !none` is not valid."); has_none = true; } else if token.exclude { insert_unique(&mut excluded, name.to_string(), "smart-folder", || { format!("!{name}") })?; } else { insert_unique(&mut included, name.to_string(), "smart-folder", || { name.to_string() })?; } } contradiction_check("smart-folder", &included, &excluded)?; if has_none { anyhow::ensure!( !has_all && !has_all_sensitive && included.is_empty() && excluded.is_empty(), "`--smart-folder none` cannot be combined with other `--smart-folder` values." ); return Ok(SmartFolderSelector::None); } if has_all || has_all_sensitive { anyhow::ensure!( !(has_all && has_all_sensitive), "`--smart-folder all` and `--smart-folder all-with-sensitive` cannot be used together." ); anyhow::ensure!( included.is_empty(), "`--smart-folder all` cannot be combined with smart folder names. Use `--smart-folder !name` to exclude one." ); return Ok(SmartFolderSelector::All { include_sensitive: has_all_sensitive, excluded, }); } if !included.is_empty() { return Ok(SmartFolderSelector::Named { included, excluded }); } // Only exclusions on the empty default is an interesting corner: warn at // a higher level, not here. Resolve to "every smart folder minus those". Ok(SmartFolderSelector::All { include_sensitive: false, excluded, }) } /// Parse `--library` raw values into a [`LibrarySelector`]. /// /// Default (empty input) = `primary = true` only. Accepted: bare sentinels /// (`primary`, `shared`, `all`, `none`) and zone names (`PrimarySync`, /// `SharedSync-A1B2C3D4` truncated, or the full UUID form), each with an /// optional `!` prefix for exclusions. `shared:Owner`-style friendly forms /// bail at parse time. pub(crate) fn parse_library_selector(raw: &[String]) -> anyhow::Result { if raw.is_empty() { return Ok(LibrarySelector::default()); } let mut sel = LibrarySelector { primary: false, shared_all: false, named: BTreeSet::new(), excluded: BTreeSet::new(), }; let mut has_all = false; let mut has_none = false; for entry in raw { let trimmed = entry.trim(); anyhow::ensure!(!trimmed.is_empty(), "`--library` needs a value."); let token = selector_token(trimmed, "library")?; let name = token.name; // CloudKit zone names use `-`, never `:`. The `:` forms are an old // friendly-alias surface ("shared:Owner Name") that kei does not // resolve. Bail at parse time with the supported alternatives so // users don't get a "not found" surprise after a network round-trip. if name.contains(':') { anyhow::bail!( "`--library` accepts `primary`, `shared`, `all`, or zone names like `PrimarySync` and `SharedSync-XYZ`. \ `{name}` is not supported. Run `kei list libraries` to see the available zone names." ); } if name.eq_ignore_ascii_case("all") && !token.escaped { anyhow::ensure!(!token.exclude, "`--library !all` is not valid."); has_all = true; } else if name.eq_ignore_ascii_case("none") && !token.escaped { anyhow::ensure!(!token.exclude, "`--library !none` is not valid."); has_none = true; } else if name.eq_ignore_ascii_case("primary") && !token.escaped { if token.exclude { insert_unique(&mut sel.excluded, "primary".to_string(), "library", || { "!primary".to_string() })?; } else { sel.primary = true; } } else if name.eq_ignore_ascii_case("shared") && !token.escaped { if token.exclude { insert_unique(&mut sel.excluded, "shared".to_string(), "library", || { "!shared".to_string() })?; } else { sel.shared_all = true; } } else if token.exclude { insert_unique(&mut sel.excluded, name.to_string(), "library", || { format!("!{name}") })?; } else { insert_unique(&mut sel.named, name.to_string(), "library", || { name.to_string() })?; } } if has_none { anyhow::bail!( "`--library none` is not allowed because kei needs at least one iCloud library to sync." ); } if has_all { sel.primary = true; sel.shared_all = true; } // If only exclusions or shared-only inputs were given without `primary`, // honour exactly what the user said. The "exclude implies all" rule for // libraries means: a bare `!Foo` would imply primary (the category // default), which we apply here. if !sel.primary && !sel.shared_all && sel.named.is_empty() && !sel.excluded.is_empty() { sel.primary = true; } contradiction_check("library", &sel.named, &sel.excluded)?; if sel.is_empty() { anyhow::bail!( "`--library` did not select any libraries. Choose `primary`, `shared`, or a zone name." ); } Ok(sel) } /// Verify the same name doesn't appear as both an include and an exclude. /// Spec: "Mixing positive and exclusion of the same name in any order: bail /// at parse time." fn contradiction_check( category: &str, included: &BTreeSet, excluded: &BTreeSet, ) -> anyhow::Result<()> { if let Some(name) = included.intersection(excluded).next() { anyhow::bail!("`--{category}` includes and excludes `{name}`. Pick one."); } Ok(()) } /// Build a [`Selection`] from raw CLI/TOML inputs. Test-only convenience /// for exercising every parser in one shot. #[cfg(test)] pub(crate) fn build_selection( raw_albums: &[String], raw_smart_folders: &[String], raw_libraries: &[String], unfiled_explicit: Option, ) -> anyhow::Result { Ok(Selection { albums: parse_album_selector(raw_albums, true)?, albums_explicit: !raw_albums.is_empty(), smart_folders: parse_smart_folder_selector(raw_smart_folders)?, smart_folders_explicit: !raw_smart_folders.is_empty(), libraries: parse_library_selector(raw_libraries)?, unfiled: unfiled_explicit.unwrap_or(true), }) } // ── Serialization helpers ─────────────────────────────────────────────────── impl AlbumSelector { /// Serialize back to the raw `Vec` form a user would write on the /// CLI / in TOML. `None` and `All` with an empty exclusion set serialize /// to a single sentinel; everything else lists positives + `!exclusions`. pub fn to_raw(&self) -> Vec { match self { Self::None => vec!["none".to_string()], Self::All { excluded } => std::iter::once("all".to_string()) .chain(excluded.iter().map(|n| format!("!{n}"))) .collect(), Self::Named { included, excluded } => included .iter() .map(|n| escape_if_needed(n, &["all", "none"])) .chain(excluded.iter().map(|n| format!("!{n}"))) .collect(), } } } impl SmartFolderSelector { pub fn to_raw(&self) -> Vec { match self { Self::None => Vec::new(), Self::All { include_sensitive, excluded, } => { let head = if *include_sensitive { "all-with-sensitive" } else { "all" }; std::iter::once(head.to_string()) .chain(excluded.iter().map(|n| format!("!{n}"))) .collect() } Self::Named { included, excluded } => included .iter() .map(|n| escape_if_needed(n, &["all", "all-with-sensitive", "none"])) .chain(excluded.iter().map(|n| format!("!{n}"))) .collect(), } } } impl LibrarySelector { pub fn to_raw(&self) -> Vec { let mut out = Vec::new(); if self.primary && self.shared_all && self.named.is_empty() { out.push("all".to_string()); } else { if self.primary { out.push("primary".to_string()); } if self.shared_all { out.push("shared".to_string()); } for n in &self.named { out.push(escape_if_needed(n, &["primary", "shared", "all", "none"])); } } for n in &self.excluded { out.push(format!("!{n}")); } out } } // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; fn s(v: &[&str]) -> Vec { v.iter().map(|x| (*x).to_string()).collect() } fn set(v: &[&str]) -> BTreeSet { v.iter().map(|x| (*x).to_string()).collect() } // ── AlbumSelector ───────────────────────────────────────────────── #[test] fn album_default_is_all() { assert_eq!( AlbumSelector::default(), AlbumSelector::All { excluded: BTreeSet::new() } ); } #[test] fn album_empty_with_default_to_all() { let s = parse_album_selector(&[], true).unwrap(); assert_eq!( s, AlbumSelector::All { excluded: BTreeSet::new() } ); } #[test] fn album_empty_without_default_to_all() { let s = parse_album_selector(&[], false).unwrap(); assert_eq!(s, AlbumSelector::None); } #[test] fn album_all_sentinel() { let r = parse_album_selector(&s(&["all"]), false).unwrap(); assert_eq!( r, AlbumSelector::All { excluded: BTreeSet::new() } ); } #[test] fn album_all_case_insensitive() { let r = parse_album_selector(&s(&["ALL"]), false).unwrap(); assert_eq!( r, AlbumSelector::All { excluded: BTreeSet::new() } ); } #[test] fn album_none_sentinel() { let r = parse_album_selector(&s(&["none"]), false).unwrap(); assert_eq!(r, AlbumSelector::None); } #[test] fn album_named() { let r = parse_album_selector(&s(&["Vacation", "Family"]), false).unwrap(); assert_eq!( r, AlbumSelector::Named { included: set(&["Vacation", "Family"]), excluded: BTreeSet::new(), } ); } #[test] fn album_all_with_exclusion() { let r = parse_album_selector(&s(&["all", "!Family"]), false).unwrap(); assert_eq!( r, AlbumSelector::All { excluded: set(&["Family"]), } ); } #[test] fn album_bare_exclusion_implies_all() { let r = parse_album_selector(&s(&["!Family"]), true).unwrap(); assert_eq!( r, AlbumSelector::All { excluded: set(&["Family"]), } ); } #[test] fn album_named_with_exclusion() { let r = parse_album_selector(&s(&["Vacation", "!Family"]), false).unwrap(); assert_eq!( r, AlbumSelector::Named { included: set(&["Vacation"]), excluded: set(&["Family"]), } ); } #[test] fn album_contradiction_bails() { let err = parse_album_selector(&s(&["Vacation", "!Vacation"]), false).unwrap_err(); assert!(err.to_string().contains("Vacation")); } #[test] fn album_all_plus_named_bails() { let err = parse_album_selector(&s(&["all", "Vacation"]), false).unwrap_err(); assert!(err.to_string().contains("`--album all`")); } #[test] fn album_none_plus_other_bails() { let err = parse_album_selector(&s(&["none", "Vacation"]), false).unwrap_err(); assert!(err.to_string().contains("`--album none`")); } #[test] fn album_duplicate_name_bails() { let err = parse_album_selector(&s(&["Vacation", "Vacation"]), false).unwrap_err(); assert!(err.to_string().contains("Vacation")); } #[test] fn album_empty_string_bails() { let err = parse_album_selector(&s(&[""]), false).unwrap_err(); assert!(err.to_string().contains("needs a value")); } #[test] fn album_bang_sentinel_bails() { let err = parse_album_selector(&s(&["!all"]), false).unwrap_err(); assert!(err.to_string().contains("`--album !all`")); } #[test] fn album_escape_selects_literal_sentinel_names() { for (raw, expected) in [("=all", "all"), ("=none", "none"), ("=!Drafts", "!Drafts")] { let r = parse_album_selector(&s(&[raw]), false).unwrap(); assert_eq!( r, AlbumSelector::Named { included: set(&[expected]), excluded: BTreeSet::new(), }, "raw: {raw}" ); } } /// CG-8 (2026-05-03 test review): cross-category sentinel collision. /// `primary` and `shared` are sentinels in `--library`, not `--album`. /// A user with an iCloud album literally named `Primary` (or `primary`, /// or `shared`) must have it parsed as a literal album, not silently /// hijacked into a library sentinel. Similarly, `all-with-sensitive` /// is a smart-folder sentinel and must round-trip as a literal album /// name when supplied to `parse_album_selector`. /// /// The mutation this catches: a future "smart" refactor that consults /// a single shared sentinel-word list across selectors would silently /// match `primary` (and friends) and drop the user's real album. #[test] fn parse_album_selector_treats_other_categories_sentinels_as_literal_names() { for word in [ "Primary", "primary", "Shared", "shared", "all-with-sensitive", ] { let r = parse_album_selector(&s(&[word]), false).unwrap(); assert_eq!( r, AlbumSelector::Named { included: set(&[word]), excluded: BTreeSet::new(), }, "album value `{word}` must round-trip as a literal album name, \ not as a cross-category sentinel" ); } } /// CG-8 sibling: pin the documented case-insensitivity boundary — /// only `all` and `none` are case-insensitive sentinels for albums. /// Lowercase `primary` / `shared` are NOT album sentinels. #[test] fn parse_album_selector_only_all_and_none_are_album_sentinels() { // `all` family -> AlbumSelector::All for word in ["all", "All", "ALL"] { let r = parse_album_selector(&s(&[word]), false).unwrap(); assert!( matches!(r, AlbumSelector::All { .. }), "album `{word}` must resolve to AlbumSelector::All" ); } // `none` family -> AlbumSelector::None for word in ["none", "None", "NONE"] { let r = parse_album_selector(&s(&[word]), false).unwrap(); assert_eq!( r, AlbumSelector::None, "album `{word}` must resolve to AlbumSelector::None" ); } } // ── SmartFolderSelector ──────────────────────────────────────────── #[test] fn smart_folder_default_is_none() { assert_eq!(SmartFolderSelector::default(), SmartFolderSelector::None); } #[test] fn smart_folder_empty_is_none() { let r = parse_smart_folder_selector(&[]).unwrap(); assert_eq!(r, SmartFolderSelector::None); } #[test] fn smart_folder_all_excludes_sensitive_by_default() { let r = parse_smart_folder_selector(&s(&["all"])).unwrap(); assert_eq!( r, SmartFolderSelector::All { include_sensitive: false, excluded: BTreeSet::new(), } ); } #[test] fn smart_folder_all_with_sensitive() { let r = parse_smart_folder_selector(&s(&["all-with-sensitive"])).unwrap(); assert_eq!( r, SmartFolderSelector::All { include_sensitive: true, excluded: BTreeSet::new(), } ); } #[test] fn smart_folder_all_and_all_with_sensitive_mutually_exclusive() { let err = parse_smart_folder_selector(&s(&["all", "all-with-sensitive"])).unwrap_err(); assert!(err.to_string().contains("cannot be used together")); } #[test] fn smart_folder_named() { let r = parse_smart_folder_selector(&s(&["Favorites", "Videos"])).unwrap(); assert_eq!( r, SmartFolderSelector::Named { included: set(&["Favorites", "Videos"]), excluded: BTreeSet::new(), } ); } #[test] fn smart_folder_exclusion_only_resolves_to_all() { let r = parse_smart_folder_selector(&s(&["!Hidden"])).unwrap(); assert_eq!( r, SmartFolderSelector::All { include_sensitive: false, excluded: set(&["Hidden"]), } ); } #[test] fn smart_folder_none_plus_other_bails() { let err = parse_smart_folder_selector(&s(&["none", "Favorites"])).unwrap_err(); assert!(err.to_string().contains("`--smart-folder none`")); } #[test] fn smart_folder_escape_selects_literal_sentinel_names() { for (raw, expected) in [ ("=all", "all"), ("=all-with-sensitive", "all-with-sensitive"), ("=none", "none"), ("=!Hidden", "!Hidden"), ] { let r = parse_smart_folder_selector(&s(&[raw])).unwrap(); assert_eq!( r, SmartFolderSelector::Named { included: set(&[expected]), excluded: BTreeSet::new(), }, "raw: {raw}" ); } } // ── LibrarySelector ──────────────────────────────────────────────── #[test] fn library_default_is_primary() { let r = LibrarySelector::default(); assert!(r.primary); assert!(!r.shared_all); assert!(r.named.is_empty()); } #[test] fn library_empty_input_is_primary() { let r = parse_library_selector(&[]).unwrap(); assert_eq!(r, LibrarySelector::default()); } #[test] fn library_primary_sentinel() { let r = parse_library_selector(&s(&["primary"])).unwrap(); assert!(r.primary); assert!(!r.shared_all); } #[test] fn library_shared_sentinel() { let r = parse_library_selector(&s(&["shared"])).unwrap(); assert!(!r.primary); assert!(r.shared_all); } #[test] fn library_all_sentinel() { let r = parse_library_selector(&s(&["all"])).unwrap(); assert!(r.primary); assert!(r.shared_all); } #[test] fn library_primary_plus_shared() { let r = parse_library_selector(&s(&["primary", "shared"])).unwrap(); assert!(r.primary); assert!(r.shared_all); } #[test] fn library_named_zones() { let r = parse_library_selector(&s(&["SharedSync-A1B2C3D4"])).unwrap(); assert!(!r.primary); assert_eq!(r.named, set(&["SharedSync-A1B2C3D4"])); } #[test] fn library_escape_selects_literal_sentinel_names() { for (raw, expected) in [ ("=primary", "primary"), ("=shared", "shared"), ("=all", "all"), ("=none", "none"), ] { let r = parse_library_selector(&s(&[raw])).unwrap(); assert_eq!( r.named, set(&[expected]), "raw {raw} should be a named zone" ); assert!(!r.primary); assert!(!r.shared_all); } } #[test] fn bang_equals_escape_form_is_rejected() { let err = parse_library_selector(&s(&["!=primary"])).unwrap_err(); assert!(err.to_string().contains("not valid")); } #[test] fn library_friendly_alias_bails_at_parse_time() { // Friendly forms ("shared:Owner Name", "primary:something") aren't // supported. Bailing at parse beats a "not found" surprise after a // CloudKit round-trip. let err = parse_library_selector(&s(&["shared:Owner Name"])).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("not supported"), "msg: {msg}"); assert!(msg.contains("shared:Owner Name"), "msg: {msg}"); } #[test] fn library_friendly_alias_bails_inside_exclusion() { // Same rule applies to `!shared:Owner` exclusions. let err = parse_library_selector(&s(&["!shared:Owner"])).unwrap_err(); assert!(err.to_string().contains("not supported")); } #[test] fn library_none_bails() { let err = parse_library_selector(&s(&["none"])).unwrap_err(); assert!(err.to_string().contains("`--library none`")); } #[test] fn library_only_exclusion_implies_primary() { // `--library !shared` resolves to primary minus shared (which has no // effect since primary doesn't include shared) — still a valid setup. let r = parse_library_selector(&s(&["!Foo"])).unwrap(); assert!(r.primary); assert_eq!(r.excluded, set(&["Foo"])); } #[test] fn library_excluded_named_collision_bails() { let err = parse_library_selector(&s(&["Foo", "!Foo"])).unwrap_err(); assert!(err.to_string().contains("Foo")); } // ── Selection ───────────────────────────────────────────────────── #[test] fn selection_defaults() { let s = Selection::default(); assert_eq!(s.albums, AlbumSelector::default()); assert!(!s.albums_explicit); assert_eq!(s.smart_folders, SmartFolderSelector::None); assert!(!s.smart_folders_explicit); assert!(s.libraries.primary); assert!(!s.libraries.shared_all); assert!(s.unfiled); } #[test] fn build_selection_no_input_is_default() { let s = build_selection(&[], &[], &[], None).unwrap(); assert_eq!(s, Selection::default()); } #[test] fn build_selection_unfiled_explicit_false() { let s = build_selection(&[], &[], &[], Some(false)).unwrap(); assert!(!s.unfiled); } #[test] fn build_selection_full_example() { let s = build_selection( &s(&["all", "!Family"]), &s(&["Favorites"]), &s(&["primary", "shared"]), Some(true), ) .unwrap(); assert_eq!( s.albums, AlbumSelector::All { excluded: set(&["Family"]), } ); assert_eq!( s.smart_folders, SmartFolderSelector::Named { included: set(&["Favorites"]), excluded: BTreeSet::new(), } ); assert!(s.albums_explicit); assert!(s.smart_folders_explicit); assert!(s.libraries.primary); assert!(s.libraries.shared_all); assert!(s.unfiled); } // ── Round-trip via to_raw() ──────────────────────────────────────── // // Serialization round-trips. // // `to_raw()` is consumed by `report.rs`, `Config::to_toml()`, and // `download/mod.rs::compute_config_hash()`. A swapped `included`/ // `excluded` order or a missing `format!("!{n}")` prefix would land // green if we only round-trip the all-sentinel case (the most common // fixture). Pin the named-with-excluded variants so a regression in // either prefix or ordering surfaces. #[test] fn selection_album_to_raw_round_trips_named_with_excludes() { // Arrange let original = AlbumSelector::Named { included: set(&["Vacation", "Wedding"]), excluded: set(&["Drafts", "Family"]), }; // Act let raw = original.to_raw(); let parsed = parse_album_selector(&raw, false).unwrap(); // Assert: round-trip is a fixed point. Also pin the on-the-wire // shape so a future "!" prefix drop fails here even before the // re-parse runs. assert_eq!(parsed, original); assert!( raw.iter().any(|r| r == "!Drafts"), "exclusion prefix '!' must survive serialization; got {raw:?}" ); assert!( raw.iter().any(|r| r == "!Family"), "exclusion prefix '!' must survive serialization; got {raw:?}" ); } #[test] fn selection_album_to_raw_round_trips_all_with_excludes() { let original = AlbumSelector::All { excluded: set(&["Family"]), }; let raw = original.to_raw(); let parsed = parse_album_selector(&raw, false).unwrap(); assert_eq!(parsed, original); assert_eq!(raw, vec!["all".to_string(), "!Family".to_string()]); } #[test] fn selection_album_to_raw_escapes_literal_sentinel_names() { let original = AlbumSelector::Named { included: set(&["all", "none", "!Drafts", "=Pinned"]), excluded: BTreeSet::new(), }; let raw = original.to_raw(); let parsed = parse_album_selector(&raw, false).unwrap(); assert_eq!(parsed, original); assert!(raw.iter().any(|r| r == "=all"), "raw: {raw:?}"); assert!(raw.iter().any(|r| r == "=none"), "raw: {raw:?}"); assert!(raw.iter().any(|r| r == "=!Drafts"), "raw: {raw:?}"); assert!(raw.iter().any(|r| r == "==Pinned"), "raw: {raw:?}"); } #[test] fn selection_smart_folder_to_raw_round_trips_named_with_excludes() { let original = SmartFolderSelector::Named { included: set(&["Favorites", "Videos"]), excluded: set(&["Hidden"]), }; let raw = original.to_raw(); let parsed = parse_smart_folder_selector(&raw).unwrap(); assert_eq!(parsed, original); assert!(raw.iter().any(|r| r == "!Hidden")); } #[test] fn selection_smart_folder_to_raw_round_trips_all_with_sensitive() { let original = SmartFolderSelector::All { include_sensitive: true, excluded: set(&["Recently Deleted"]), }; let raw = original.to_raw(); let parsed = parse_smart_folder_selector(&raw).unwrap(); assert_eq!(parsed, original); // The sensitive sentinel must survive verbatim. assert!( raw.iter().any(|r| r == "all-with-sensitive"), "all-with-sensitive sentinel must serialize verbatim; got {raw:?}" ); } #[test] fn selection_smart_folder_to_raw_escapes_literal_sentinel_names() { let original = SmartFolderSelector::Named { included: set(&["all", "all-with-sensitive", "none"]), excluded: BTreeSet::new(), }; let raw = original.to_raw(); let parsed = parse_smart_folder_selector(&raw).unwrap(); assert_eq!(parsed, original); assert!(raw.iter().any(|r| r == "=all"), "raw: {raw:?}"); assert!( raw.iter().any(|r| r == "=all-with-sensitive"), "raw: {raw:?}" ); assert!(raw.iter().any(|r| r == "=none"), "raw: {raw:?}"); } #[test] fn selection_library_to_raw_round_trips_named_with_excludes() { let original = LibrarySelector { primary: true, shared_all: false, named: set(&["SharedSync-A1B2C3D4"]), excluded: set(&["Foo"]), }; let raw = original.to_raw(); let parsed = parse_library_selector(&raw).unwrap(); assert_eq!(parsed, original); assert!(raw.iter().any(|r| r == "!Foo")); } #[test] fn selection_library_all_with_exclusion_round_trips() { // `LibrarySelector::to_raw()` collapses to `all` when both // primary and shared_all are set with no named zones. A dropped // exclusion under that branch would silently change which zones // sync after a TOML round-trip. let original = LibrarySelector { primary: true, shared_all: true, named: BTreeSet::new(), excluded: set(&["Foo"]), }; let raw = original.to_raw(); let parsed = parse_library_selector(&raw).unwrap(); assert_eq!(parsed, original); // Pin the wire shape so the `["all", "!Foo"]` collapsing branch // can't silently drop the `all` sentinel or reorder. assert_eq!(raw, vec!["all".to_string(), "!Foo".to_string()]); } #[test] fn selection_library_to_raw_escapes_literal_sentinel_names() { let original = LibrarySelector { primary: false, shared_all: false, named: set(&["primary", "shared", "all", "none"]), excluded: BTreeSet::new(), }; let raw = original.to_raw(); let parsed = parse_library_selector(&raw).unwrap(); assert_eq!(parsed, original); assert!(raw.iter().any(|r| r == "=primary"), "raw: {raw:?}"); assert!(raw.iter().any(|r| r == "=shared"), "raw: {raw:?}"); assert!(raw.iter().any(|r| r == "=all"), "raw: {raw:?}"); assert!(raw.iter().any(|r| r == "=none"), "raw: {raw:?}"); } #[test] fn selection_full_round_trip_via_build_selection() { // Take a non-default `Selection`, run every field through // `to_raw()`, feed back through `build_selection`, assert equal. // A future refactor that drops one `to_raw()` branch (e.g. // forgets `unfiled` because there's no `to_raw()` for it) lands // green without this. let original = Selection { albums: AlbumSelector::All { excluded: set(&["Family"]), }, albums_explicit: true, smart_folders: SmartFolderSelector::Named { included: set(&["Favorites"]), excluded: BTreeSet::new(), }, smart_folders_explicit: true, libraries: LibrarySelector { primary: true, shared_all: true, named: BTreeSet::new(), excluded: BTreeSet::new(), }, unfiled: false, }; let raw_albums = original.albums.to_raw(); let raw_smart = original.smart_folders.to_raw(); let raw_lib = original.libraries.to_raw(); let rebuilt = build_selection(&raw_albums, &raw_smart, &raw_lib, Some(original.unfiled)).unwrap(); assert_eq!(rebuilt, original); } // ── Bare exclusion with default_to_all = false ──────────────────── // // `parse_album_selector(["!Family"], false)` is not directly // tested. The implementation always returns `All { excluded }` for an // exclusion-only input regardless of `default_to_all`; pin that so a // future refactor that ties exclusions to `default_to_all` lands red. #[test] fn album_bare_exclusion_with_default_false_still_resolves_to_all() { let r = parse_album_selector(&s(&["!Family"]), false).unwrap(); assert_eq!( r, AlbumSelector::All { excluded: set(&["Family"]), } ); } // ── Unicode-whitespace boundary on parse_library_selector ───────── // // 3g boundary (adversarial): `entry.trim()` strips ASCII whitespace // but leaves Unicode whitespace like U+00A0 (NBSP) intact, so a // stray-NBSP-only entry would slip through as a literal zone name // and fail at the CloudKit boundary instead of at parse time. // "No silent failures" mandates the parse-time bail; if this test // fails, the production parser needs a `trim_matches(char::is_whitespace)` // (or equivalent) to match the spec. #[test] fn parse_library_selector_rejects_unicode_whitespace_only_entry() { // U+00A0 (NBSP) — a single-codepoint non-ASCII whitespace entry. let r = parse_library_selector(&s(&["\u{00A0}"])); assert!( r.is_err(), "single-NBSP entry must bail at parse time, got: {r:?}" ); let msg = r.unwrap_err().to_string(); assert!( msg.contains("needs a value"), "error must name the empty-input cause; got: {msg}" ); } }