src/selection.rs
Rama main · réplica de lectura del repositorio original.
//! 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<String>;
/// 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<String>,
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<String>,
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<String>,
/// `!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<SelectorToken<'a>> {
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<String>,
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<AlbumSelector> {
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<String> = BTreeSet::new();
let mut excluded: BTreeSet<String> = 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<SmartFolderSelector> {
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<String> = BTreeSet::new();
let mut excluded: BTreeSet<String> = 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<LibrarySelector> {
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<String>,
excluded: &BTreeSet<String>,
) -> 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<bool>,
) -> anyhow::Result<Selection> {
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<String>` 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<String> {
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<String> {
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<String> {
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<String> {
v.iter().map(|x| (*x).to_string()).collect()
}
fn set(v: &[&str]) -> BTreeSet<String> {
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}"
);
}
}