Files
lightcraft/crates/catalog/src/rules.rs
T
Chad ElliottandClaude Opus 5.5 615d8a365e perf(catalog): #1136 index stacks and album membership, sort keys once per photo
stack_of, validate_stack and regular-album membership looked a photo up by
scanning every stack or the album's list, so an "In a Stack" or "Album"
smart album, or a query filtered to an album, cost photos x list length
(2.7 s and 0.6-0.8 s at 100k photos). Derived indexes (never saved) answer
them now: built on first use, kept current by the six ops that change
stacks or album photos in apply_inner, so undo, failed-batch rollback and
journal replay follow. A file with a photo in two stacks falls back to the
scan.

query() works out each sort key once (File Name was lower-cased twice per
comparison) and tests `only` as a set; smart-album passes prepare the
filter once; rules name flag, label and kind without formatting per photo.

A property test checks the indexes against a scan through random op
sequences, rollbacks, snapshots, replay, clones and undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:48:16 -05:00

1697 lines
81 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Smart-album rules: a list of conditions matched all / any / none, with nested groups.
//!
//! Each [`Rule`] is `{field, op, value}`; [`FIELDS`] lists the fields with their kind, which
//! decides the operators ([`ops_for`]). Text compares case-insensitively; dates are ISO strings
//! compared by prefix; "in the last N days/weeks/months/years" is relative to [`now`].
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Catalog, Photo, Source};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Match {
#[default]
All,
Any,
None,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct RuleSet {
#[serde(default, rename = "match")]
pub mode: Match,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Rule {
/// A nested group with its own match mode.
Group { group: RuleSet },
Field {
field: String,
op: String,
#[serde(default)]
value: Value,
},
}
/// How a field's value is compared.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Text,
/// A list of names (keywords, people): text ops over each name (any name matches; `isEmpty` =
/// none).
Keywords,
Number,
Date,
/// An album or a smart album (not a folder), by id: in it or not.
Album,
/// One of a fixed set: (id, label). Rules store the id; people see the label.
Choice(&'static [(&'static str, &'static str)]),
Bool,
}
/// The fields rules can test: (id, label, kind), in field-menu order ([`TOP_LEVEL_FIELDS`], then
/// each of [`FIELD_GROUPS`]).
pub const FIELDS: &[(&str, &str, Kind)] = &[
("rating", "Rating", Kind::Number),
("flag", "Pick Flag", Kind::Choice(&[("pick", "Picked"), ("reject", "Rejected"), ("none", "Unflagged")])),
(
"label",
"Color Label",
Kind::Choice(&[("red", "Red"), ("yellow", "Yellow"), ("green", "Green"), ("blue", "Blue"), ("purple", "Purple"), ("none", "No Label")]),
),
("text", "Any Searchable Text", Kind::Text),
// Source
("album", "Album", Kind::Album),
("virtualCopy", "Virtual Copy", Kind::Bool),
("copyName", "Copy Name", Kind::Text),
("stacked", "In a Stack", Kind::Bool),
// File
("fileName", "Filename", Kind::Text),
("extension", "File Extension", Kind::Text),
("filePath", "File Path", Kind::Text),
("kind", "File Type", Kind::Choice(&[("image", "Image"), ("raw", "Raw"), ("video", "Video")])),
("format", "File Format", Kind::Text),
("duration", "Video Duration", Kind::Number),
// Date
("captureDate", "Capture Date", Kind::Date),
("importDate", "Import Date", Kind::Date),
("editDate", "Edit Date", Kind::Date),
// Keywords & People
("keywords", "Keywords", Kind::Keywords),
("keywordCount", "Keyword Count", Kind::Number),
("person", "People", Kind::Keywords),
("personCount", "People Count", Kind::Number),
// Description
("title", "Title", Kind::Text),
("caption", "Caption", Kind::Text),
("altText", "Alt Text", Kind::Text),
("creator", "Creator", Kind::Text),
("copyright", "Copyright", Kind::Text),
(
"copyrightStatus",
"Copyright Status",
Kind::Choice(&[("copyrighted", "Copyrighted"), ("publicDomain", "Public Domain"), ("unknown", "Unknown")]),
),
// Camera Info
("camera", "Camera", Kind::Text),
("lens", "Lens", Kind::Text),
("focalLength", "Focal Length", Kind::Number),
("aperture", "Aperture", Kind::Number),
("shutterSpeed", "Shutter Speed", Kind::Number),
("iso", "ISO Speed", Kind::Number),
// Location
("location", "Location", Kind::Text),
("city", "City", Kind::Text),
("state", "State / Province", Kind::Text),
("country", "Country", Kind::Text),
("hasGps", "Has GPS", Kind::Bool),
// Size
("longEdge", "Long Edge", Kind::Number),
("shortEdge", "Short Edge", Kind::Number),
("aspect", "Aspect Ratio", Kind::Choice(&[("landscape", "Landscape (wide)"), ("portrait", "Portrait (tall)"), ("square", "Square")])),
("megapixels", "Megapixels", Kind::Number),
// Develop
("edited", "Has Edits", Kind::Bool),
("cropped", "Cropped", Kind::Bool),
("treatment", "Treatment", Kind::Choice(&[("color", "In Color"), ("monochrome", "Black & White")])),
// Assisted Culling
("sharpness", "Focus (assisted culling)", Kind::Number),
("bestOfGroup", "Best of Similar Shots", Kind::Bool),
];
/// The fields the field menu shows at its top level, before the groups.
pub const TOP_LEVEL_FIELDS: &[&str] = &["rating", "flag", "label", "text"];
/// The field menu's submenus: (label, fields), in order. Every field of [`FIELDS`] is either here
/// once or in [`TOP_LEVEL_FIELDS`].
pub const FIELD_GROUPS: &[(&str, &[&str])] = &[
("Source", &["album", "virtualCopy", "copyName", "stacked"]),
("File", &["fileName", "extension", "filePath", "kind", "format", "duration"]),
("Date", &["captureDate", "importDate", "editDate"]),
("Keywords & People", &["keywords", "keywordCount", "person", "personCount"]),
("Description", &["title", "caption", "altText", "creator", "copyright", "copyrightStatus"]),
("Camera Info", &["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"]),
("Location", &["location", "city", "state", "country", "hasGps"]),
("Size", &["longEdge", "shortEdge", "aspect", "megapixels"]),
("Develop", &["edited", "cropped", "treatment"]),
("Assisted Culling", &["sharpness", "bestOfGroup"]),
];
/// The field-menu group `field` sits in; `None` for a top-level or unknown field.
pub fn field_group(field: &str) -> Option<&'static str> {
FIELD_GROUPS.iter().find(|g| g.1.contains(&field)).map(|g| g.0)
}
/// A rule that can't mean anything ([`RuleSet::check`]): where it is, what is wrong in English
/// (for agents and logs), and the field and kind of issue, so the editor can say it in any language.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Problem {
/// Positions from the top rule list down: `[1, 0]` is the first rule of the second rule's group.
pub path: Vec<usize>,
/// The rule's field; `None` for a group.
pub field: Option<String>,
pub issue: Issue,
pub message: String,
}
impl std::fmt::Display for Problem {
/// `rule 2.1: no rating 0–5 is 9` (counting from 1, as people do).
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let at: Vec<String> = self.path.iter().map(|i| (i.saturating_add(1)).to_string()).collect();
// a problem with the filter around the rules (its album field) has no rule to point at
if at.is_empty() { write!(f, "{}", self.message) } else { write!(f, "rule {}: {}", at.join("."), self.message) }
}
}
/// What kind of thing is wrong with a rule ([`Problem`]); [`Issue::text`] says it after the
/// field's name ("Title: needs something to look for").
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Issue {
UnknownField,
NoSuchOperator,
NotYesNo,
NeedsNumber,
NeedsShutterSpeed,
NeedsDate,
DatesReversed,
NeedsTwoValues,
NeedsCount,
UnknownUnit,
NotAChoice,
NeedsTextOrEmpty,
NeedsTextOrNotEmpty,
/// Keywords and People: their operators say "are empty".
NeedsNamesOrEmpty,
NeedsNamesOrNotEmpty,
NeedsText,
NoRatingMatches,
ChooseAlbum,
NoSuchAlbum,
AlbumLoop,
FolderAlbum,
EmptyGroup,
}
impl Issue {
pub const ALL: [Issue; 22] = [
Issue::UnknownField,
Issue::NoSuchOperator,
Issue::NotYesNo,
Issue::NeedsNumber,
Issue::NeedsShutterSpeed,
Issue::NeedsDate,
Issue::DatesReversed,
Issue::NeedsTwoValues,
Issue::NeedsCount,
Issue::UnknownUnit,
Issue::NotAChoice,
Issue::NeedsTextOrEmpty,
Issue::NeedsTextOrNotEmpty,
Issue::NeedsNamesOrEmpty,
Issue::NeedsNamesOrNotEmpty,
Issue::NeedsText,
Issue::NoRatingMatches,
Issue::ChooseAlbum,
Issue::NoSuchAlbum,
Issue::AlbumLoop,
Issue::FolderAlbum,
Issue::EmptyGroup,
];
/// The issue in a few words, to follow the field's name; a message for translation catalogs.
pub fn text(self) -> &'static str {
match self {
Issue::UnknownField => "isn't a field rules know",
Issue::NoSuchOperator => "can't be tested that way",
Issue::NotYesNo => "is yes or no",
Issue::NeedsNumber => "needs a number",
Issue::NeedsShutterSpeed => "needs a time like 1/250 or 2",
Issue::NeedsDate => "needs a date like 2026, 2026-08 or 2026-08-14",
Issue::DatesReversed => "has the first date after the second",
Issue::NeedsTwoValues => "needs two values",
Issue::NeedsCount => "needs a number more than 0",
Issue::UnknownUnit => "needs hours, days, weeks, months or years",
Issue::NotAChoice => "isn't one of the choices",
Issue::NeedsTextOrEmpty => "needs something to look for (or use “is empty”)",
Issue::NeedsTextOrNotEmpty => "needs something to look for (or use “isn't empty”)",
Issue::NeedsNamesOrEmpty => "needs something to look for (or use “are empty”)",
Issue::NeedsNamesOrNotEmpty => "needs something to look for (or use “aren't empty”)",
Issue::NeedsText => "needs text",
Issue::NoRatingMatches => "can't match any rating from 0 to 5",
Issue::ChooseAlbum => "needs an album",
Issue::NoSuchAlbum => "names an album that no longer exists",
Issue::AlbumLoop => "would make this album include itself through other smart albums",
Issue::FolderAlbum => "can't test a folder; choose an album in it",
Issue::EmptyGroup => "This group is empty: add a rule or remove it.",
}
}
}
/// An issue with its English message, or nothing wrong.
type Found = Option<(Issue, String)>;
/// What is wrong with one rule, if anything ([`RuleSet::check`]).
fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog, owner: Option<crate::AlbumId>) -> Found {
let Some(kind) = field_kind(field) else { return Some((Issue::UnknownField, format!("unknown field `{field}`"))) };
if !ops_for(kind).iter().any(|o| o.0 == op) {
return Some((Issue::NoSuchOperator, format!("`{field}` has no operator `{op}`")));
}
if matches!(op, "isEmpty" | "isNotEmpty") {
return None;
}
match kind {
Kind::Bool => bool_value(value).is_none().then(|| (Issue::NotYesNo, format!("`{field}` is yes or no, not {value}"))),
Kind::Album => album_problem(value, cat, owner),
Kind::Number => {
let each = match value {
Value::Array(a) if op == "between" && a.len() == 2 => a.iter().find_map(|v| number_problem(field, v)),
_ if op == "between" => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two values, not {value}"))),
v => number_problem(field, v),
};
// a rating rule no rating could meet ("is 9", "≥ 7"); "< 6" is fine, if broad
each.or_else(|| {
let label = ops_for(kind).iter().find(|o| o.0 == op).map_or(op, |o| o.1);
(field == "rating" && !(0..=5).any(|r| num_op(op, Some(f64::from(r)), value)))
.then(|| (Issue::NoRatingMatches, format!("no rating 0–5 {label} {value}")))
})
}
Kind::Date => date_problem(field, op, value),
Kind::Choice(choices) => choice_problem(field, value, choices, cat),
Kind::Text | Kind::Keywords => match value {
Value::String(s) if !s.trim().is_empty() => None,
Value::String(_) | Value::Null => {
// the hint names the operator as the menu does: Keywords and People say "are empty"
let not = matches!(op, "isNot" | "notContains");
let (issue, instead) = match (kind, not) {
(Kind::Keywords, false) => (Issue::NeedsNamesOrEmpty, "are empty"),
(Kind::Keywords, true) => (Issue::NeedsNamesOrNotEmpty, "aren't empty"),
(_, false) => (Issue::NeedsTextOrEmpty, "is empty"),
(_, true) => (Issue::NeedsTextOrNotEmpty, "isn't empty"),
};
Some((issue, format!("`{field}` needs something to look for (or use “{instead}”)")))
}
Value::Number(_) => None,
other => Some((Issue::NeedsText, format!("`{field}` needs text, not {other}"))),
},
}
}
fn number_problem(field: &str, v: &Value) -> Found {
if field == "shutterSpeed" {
return shutter_value(v).is_null().then(|| (Issue::NeedsShutterSpeed, format!("`shutterSpeed` needs a time like 1/250 or 2, not {v}")));
}
number(v).filter(|n| n.is_finite()).is_none().then(|| (Issue::NeedsNumber, format!("`{field}` needs a number, not {v}")))
}
/// The album an Album rule names: its id as a whole number, written as a number (`3`, `3.0`) or a
/// numeric string (`"3"`). The check, the matcher and the editor all read it this way.
pub fn album_rule_id(value: &Value) -> Option<crate::AlbumId> {
number(value).filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0).map(|n| crate::AlbumId(n as u64))
}
/// An Album rule's value: an album that exists and holds photos (a plain or a smart album, not a
/// folder of albums), and, for the rules of `owner`, not one that leads back to it (a smart album
/// testing itself, directly or through others).
fn album_problem(v: &Value, cat: &Catalog, owner: Option<crate::AlbumId>) -> Found {
if v.is_null() {
return Some((Issue::ChooseAlbum, "choose an album".into()));
}
match album_rule_id(v).and_then(|id| cat.album(id)) {
None => Some((Issue::NoSuchAlbum, format!("no album {v}"))),
Some(a) if owner.is_some_and(|o| a.id == o || cat.album_reaches(a.id, o)) => {
Some((Issue::AlbumLoop, format!("album {v} would make this album include itself")))
}
Some(a) if a.folder => Some((Issue::FolderAlbum, format!("album {v} is a folder; choose an album in it"))),
Some(_) => None,
}
}
/// A rule date in its one reading: `2026`, `2026-08`, `2026-08-14`, then optionally `T10`,
/// `T10:00` or `T10:00:00` (a space works as the `T`), and after whole seconds a fraction and a
/// time zone as imported capture times carry them (`.250`, `+02:00`, `Z`); a real date and time
/// (no month 13, no 30 February, no 25 o'clock). Anything else is [`Issue::NeedsDate`]. The check
/// and the matcher both read dates this way.
pub fn rule_date(s: &str) -> Result<String, Issue> {
const PATTERN: &str = "dddd-dd-ddTdd:dd:dd";
let s: String = s.trim().char_indices().map(|(i, c)| if i == 10 && c == ' ' { 'T' } else { c }).collect();
let shaped = |t: &str| {
matches!(t.len(), 4 | 7 | 10 | 13 | 16 | 19)
&& t.chars().zip(PATTERN.chars()).all(|(c, p)| if p == 'd' { c.is_ascii_digit() } else { c == p })
};
// after whole seconds, a fraction and a zone as imports write them (.250, +02:00 or Z)
let tail_ok = |rest: &str| {
let after_fraction = match rest.strip_prefix('.') {
Some(f) => {
let digits = f.bytes().take_while(u8::is_ascii_digit).count();
if !(1..=9).contains(&digits) {
return false;
}
f.get(digits..).unwrap_or("")
}
None => rest,
};
let zone = after_fraction.as_bytes();
match zone {
[] | [b'Z' | b'z'] => true,
[b'+' | b'-', h1, h2, b':', m1, m2] => [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit()),
_ => false,
}
};
if !(shaped(&s) || s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
return Err(Issue::NeedsDate);
}
let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::<u32>().ok());
let time_ok = part(11, 13).is_none_or(|h| h < 24) && part(14, 16).is_none_or(|m| m < 60) && part(17, 19).is_none_or(|x| x < 60);
let day = match s.len().min(19) {
4 => format!("{s}-01-01"),
7 => format!("{s}-01"),
_ => s.get(..10).unwrap_or("").to_string(),
};
if time_ok && crate::dates::normalize_iso(&day).is_some() { Ok(s) } else { Err(Issue::NeedsDate) }
}
fn date_problem(field: &str, op: &str, value: &Value) -> Found {
let date = |v: &Value| match v.as_str().map(rule_date) {
Some(Ok(_)) => None,
_ => Some((Issue::NeedsDate, format!("`{field}` needs a date like 2026, 2026-08 or 2026-08-14, not {v}"))),
};
match op {
"inLast" | "notInLast" => {
let (n, unit) = match value {
Value::Object(o) => (o.get("n").and_then(number), o.get("unit").cloned().unwrap_or(Value::Null)),
v => (number(v), Value::Null),
};
if !n.is_some_and(|n| n.is_finite() && n > 0.0) {
return Some((Issue::NeedsCount, format!("`{field}` needs a number of hours, days, weeks, months or years more than 0")));
}
unit_secs(&unit).is_none().then(|| (Issue::UnknownUnit, format!("`{field}`: unknown unit {unit} (hours, days, weeks, months or years)")))
}
"between" => match value {
Value::Array(a) if a.len() == 2 => a.iter().find_map(date).or_else(|| {
let bound = |i: usize| a.get(i).and_then(Value::as_str).and_then(|d| rule_date(d).ok()).unwrap_or_default();
let (from, to) = (bound(0), bound(1));
(from > to && !from.starts_with(&to)).then(|| (Issue::DatesReversed, format!("`{field}`: the first date is after the second")))
}),
_ => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two dates, not {value}"))),
},
_ => date(value),
}
}
fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Catalog) -> Found {
let known = value.as_str().is_some_and(|s| {
choices.iter().any(|c| c.0.eq_ignore_ascii_case(s.trim()))
|| match field {
"flag" => matches!(s.trim().to_lowercase().as_str(), "picked" | "rejected"),
// a colour's name or the custom name given to it
"label" => cat.label_from_name(s).is_some(),
"copyrightStatus" => crate::CopyrightStatus::parse(s).is_some(),
_ => false,
}
});
let ids: Vec<&str> = choices.iter().map(|c| c.0).collect();
(!known).then(|| (Issue::NotAChoice, format!("`{field}` is one of {}, not {value}", ids.join(", "))))
}
thread_local! {
/// The smart albums whose rules are being evaluated on this thread, outermost first.
static EVALUATING: std::cell::RefCell<Vec<crate::AlbumId>> = const { std::cell::RefCell::new(Vec::new()) };
}
thread_local! {
/// While one outermost question is answered (is this photo in album X?): what each smart album
/// it leads to answered for each photo, so an album tested many times along the way (X tests Y
/// twice, Y tests Z twice…) is worked out once, not 2^depth times. Cleared when it ends.
static ANSWERS: std::cell::RefCell<std::collections::HashMap<(crate::AlbumId, crate::PhotoId), bool>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
/// Whether `photo` is in smart album `id`, by `matches` (its rules), remembered for the rest of the
/// outermost question and guarded by [`evaluating`] (a loop or a chain too deep holds nothing).
pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, matches: impl FnOnce() -> bool) -> bool {
if let Some(known) = ANSWERS.with_borrow(|a| a.get(&(id, photo)).copied()) {
return known;
}
// the outermost question forgets the answers when it ends, however it ends
struct Forget(bool);
impl Drop for Forget {
fn drop(&mut self) {
if self.0 {
ANSWERS.with_borrow_mut(std::collections::HashMap::clear);
}
}
}
let _forget = Forget(EVALUATING.with_borrow(Vec::is_empty));
let answer = evaluating(id, matches);
if let Some(a) = answer {
ANSWERS.with_borrow_mut(|known| known.insert((id, photo), a));
}
answer.unwrap_or(false)
}
/// How deep smart albums may test smart albums before the innermost counts as holding nothing.
const MAX_ALBUM_DEPTH: usize = 32;
/// Run `f` as the evaluation of smart album `id`'s rules: `None` (so it holds nothing) when `id`
/// is already being evaluated (a loop the check would refuse, saved anyway) or the chain is
/// deeper than [`MAX_ALBUM_DEPTH`]. Loops cost one pass round them, never a stack overflow.
pub(crate) fn evaluating<T>(id: crate::AlbumId, f: impl FnOnce() -> T) -> Option<T> {
let entered = EVALUATING.with_borrow_mut(|stack| {
let ok = !stack.contains(&id) && stack.len() < MAX_ALBUM_DEPTH;
if ok {
stack.push(id);
}
ok
});
if !entered {
return None;
}
// leaves the stack as it was, however `f` ends
struct Leave;
impl Drop for Leave {
fn drop(&mut self) {
EVALUATING.with_borrow_mut(|stack| {
stack.pop();
});
}
}
let _leave = Leave;
Some(f())
}
/// How a summary names an Album rule's album: “Name”, or `#9` when there is no such album.
pub fn album_name(value: &Value, cat: Option<&Catalog>) -> String {
let id = album_rule_id(value);
match id.and_then(|id| cat.and_then(|c| c.album(id))) {
Some(a) => format!("“{}”", a.name),
None => id.map_or_else(|| value.to_string(), |id| format!("#{}", id.0)),
}
}
/// What a yes/no rule's value means: `true`, `"yes"`, `1` (or `1.0`) or no value is yes; `false`,
/// `"no"`, `0` is no (strings in any case); `None` for anything else, which matches nothing.
pub fn bool_value(value: &Value) -> Option<bool> {
match value {
Value::Bool(b) => Some(*b),
Value::Null => Some(true),
Value::Number(n) => match n.as_f64() {
Some(1.0) => Some(true),
Some(0.0) => Some(false),
_ => None,
},
Value::String(s) => match s.trim().to_lowercase().as_str() {
"true" | "yes" | "1" => Some(true),
"false" | "no" | "0" => Some(false),
_ => None,
},
_ => None,
}
}
/// The display label of a yes/no value.
pub fn bool_label(b: bool) -> &'static str {
if b { "Yes" } else { "No" }
}
/// The display label of choice `id` of `field` ("Public Domain" for `publicDomain`); `None` when
/// `field` isn't a choice field or has no such choice.
pub fn choice_label(field: &str, id: &str) -> Option<&'static str> {
let Some(Kind::Choice(choices)) = field_kind(field) else { return None };
choices.iter().find(|c| c.0 == id).map(|c| c.1)
}
/// The display label of `field`; `None` for an unknown field.
pub fn field_label(field: &str) -> Option<&'static str> {
FIELDS.iter().find(|f| f.0 == field).map(|f| f.1)
}
/// The operators for a field kind: (id, label).
pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] {
match kind {
Kind::Text => &[
("contains", "contains"),
("notContains", "doesn't contain"),
("is", "is"),
("isNot", "isn't"),
("startsWith", "starts with"),
("endsWith", "ends with"),
("isEmpty", "is empty"),
("isNotEmpty", "isn't empty"),
],
Kind::Keywords => &[
("contains", "contains"),
("notContains", "doesn't contain"),
("is", "is"),
("startsWith", "starts with"),
("isEmpty", "are empty"),
("isNotEmpty", "aren't empty"),
],
Kind::Number => {
&[("is", "is"), ("isNot", "isn't"), ("gte", "is ≥"), ("lte", "is ≤"), ("gt", "is >"), ("lt", "is <"), ("between", "is between")]
}
Kind::Date => &[
("is", "is"),
("after", "is after"),
("before", "is before"),
("between", "is between"),
("inLast", "is in the last"),
("notInLast", "isn't in the last"),
("isEmpty", "is unknown"),
],
Kind::Choice(_) | Kind::Album => &[("is", "is"), ("isNot", "isn't")],
Kind::Bool => &[("is", "is")],
}
}
pub fn field_kind(field: &str) -> Option<Kind> {
FIELDS.iter().find(|f| f.0 == field).map(|f| f.2)
}
thread_local! {
static NOW: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
}
/// Set the time "in the last…" rules count back from (the engine's clock; tests).
pub fn set_now(iso: Option<String>) {
NOW.with(|n| *n.borrow_mut() = iso);
}
/// The current time as ISO 8601: [`set_now`]'s, else the system clock (UTC).
pub fn now() -> String {
if let Some(n) = NOW.with(|n| n.borrow().clone()) {
return n;
}
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
crate::dates::civil(secs)
}
#[cfg(target_arch = "wasm32")]
{
"2026-01-01T00:00:00".to_string()
}
}
/// A flag as rules name it (its variant, lower case), without building a string per photo.
fn flag_name(f: crate::Flag) -> &'static str {
match f {
crate::Flag::None => "none",
crate::Flag::Pick => "pick",
crate::Flag::Reject => "reject",
}
}
/// A colour label's colour as rules name it (lower case).
fn label_colour(l: crate::ColorLabel) -> &'static str {
match l {
crate::ColorLabel::Red => "red",
crate::ColorLabel::Yellow => "yellow",
crate::ColorLabel::Green => "green",
crate::ColorLabel::Blue => "blue",
crate::ColorLabel::Purple => "purple",
}
}
/// A media kind as rules name it (lower case).
fn kind_name(k: crate::MediaKind) -> &'static str {
match k {
crate::MediaKind::Image => "image",
crate::MediaKind::Raw => "raw",
crate::MediaKind::Video => "video",
}
}
fn lower(v: &Value) -> String {
match v {
Value::String(s) => s.trim().to_lowercase(),
Value::Null => String::new(),
other => other.to_string().to_lowercase(),
}
}
fn text_op(op: &str, have: &str, want: &str) -> bool {
let have = have.trim().to_lowercase();
match op {
"contains" => want.split_whitespace().all(|w| have.contains(w)),
"notContains" => !want.split_whitespace().any(|w| have.contains(w)),
"is" => have == want,
"isNot" => have != want,
"startsWith" => have.starts_with(want),
"endsWith" => have.ends_with(want),
"isEmpty" => have.is_empty(),
"isNotEmpty" => !have.is_empty(),
_ => false,
}
}
/// A [`Kind::Keywords`] op over a list of names: any name matches (none for `notContains`); `is`
/// also matches one level of a hierarchical keyword (`italy` in `travel|italy|rome`).
fn names_op<S: AsRef<str>>(op: &str, names: &[S], want: &str) -> bool {
let names = names.iter().map(AsRef::as_ref);
match op {
"isEmpty" => names.clone().all(|n| n.trim().is_empty()),
"isNotEmpty" => names.clone().any(|n| !n.trim().is_empty()),
"notContains" => !names.clone().any(|n| text_op("contains", n, want)),
_ => names.clone().any(|n| text_op(op, n, want) || (op == "is" && n.to_lowercase().split('|').any(|part| part.trim() == want))),
}
}
fn number(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().trim_start_matches("f/").trim_end_matches("mm").trim().parse().ok(),
_ => None,
}
}
fn num_op(op: &str, have: Option<f64>, value: &Value) -> bool {
if op == "between" {
let (a, b) = match value {
Value::Array(v) if v.len() == 2 => (number(&v[0]), number(&v[1])),
_ => (None, None),
};
return matches!((have, a, b), (Some(h), Some(a), Some(b)) if h >= a.min(b) && h <= a.max(b));
}
let (Some(h), Some(w)) = (have, number(value)) else { return op == "isNot" && have.is_none() };
match op {
"is" => (h - w).abs() < 1e-6,
"isNot" => (h - w).abs() >= 1e-6,
"gte" => h >= w - 1e-9,
"lte" => h <= w + 1e-9,
"gt" => h > w,
"lt" => h < w,
_ => false,
}
}
/// `landscape`, `portrait` or `square` (edges within 1 %) for a `w × h` frame; `None` when it has no area.
fn aspect((w, h): (f64, f64)) -> Option<&'static str> {
if !(w > 0.0 && h > 0.0) {
return None;
}
Some(if (w - h).abs() <= 0.01 * w.max(h) {
"square"
} else if w > h {
"landscape"
} else {
"portrait"
})
}
/// A shutter-speed rule's value in seconds: camera notation (`1/250`) or a number; an operand
/// that isn't a time becomes `null`, which matches nothing.
fn shutter_value(value: &Value) -> Value {
let secs = |v: &Value| match v {
Value::String(s) => crate::parse_shutter_seconds(s).map_or(Value::Null, Value::from),
other => number(other).filter(|n| n.is_finite() && *n > 0.0).map_or(Value::Null, Value::from),
};
match value {
Value::Array(a) => Value::Array(a.iter().map(secs).collect()),
v => secs(v),
}
}
/// Seconds in one "in the last…" unit: hours, days, weeks, months or years, in any case, singular
/// or plural; none means days. `None` for anything else, which the check refuses and the matcher
/// matches nothing with.
fn unit_secs(unit: &Value) -> Option<f64> {
let day = 86_400.0;
let name = match unit {
Value::Null => return Some(day),
Value::String(s) => s.trim().to_lowercase(),
_ => return None,
};
match name.strip_suffix('s').unwrap_or(&name) {
"hour" => Some(3600.0),
"day" => Some(day),
"week" => Some(7.0 * day),
"month" => Some(30.4375 * day),
"year" => Some(365.25 * day),
_ => None,
}
}
/// The longest "in the last…" span: 10,000 years, beyond any capture date ("ever").
const MAX_LAST_SECS: f64 = 10_000.0 * 365.25 * 86_400.0;
/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days, in
/// seconds. `None` for a zero, negative or unreadable `n`; a longer span than [`MAX_LAST_SECS`]
/// is that (so the arithmetic on it can't overflow).
fn last_secs(value: &Value) -> Option<i64> {
let (n, unit) = match value {
Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").unwrap_or(&Value::Null)),
v => (number(v)?, &Value::Null),
};
let per = unit_secs(unit)?;
let secs = n * per;
if secs.is_nan() || secs <= 0.0 {
return None;
}
Some(secs.min(MAX_LAST_SECS) as i64)
}
fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
if op == "isEmpty" {
return have.is_none_or(str::is_empty);
}
let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" };
// the rule's date as the check reads it (a space as the "T"); one it can't read as written
let s = |v: &Value| v.as_str().map(|d| rule_date(d).unwrap_or_else(|_| d.trim().to_string())).unwrap_or_default();
match op {
// a prefix: 2026, 2026-04, 2026-04-12
"is" => {
let w = s(value);
!w.is_empty() && h.starts_with(&w)
}
"after" => {
let w = s(value);
!w.is_empty() && h > w.as_str() && !h.starts_with(&w)
}
"before" => {
let w = s(value);
!w.is_empty() && h < w.as_str()
}
"between" => match value {
Value::Array(v) if v.len() == 2 => {
let (a, b) = (s(&v[0]), s(&v[1]));
h >= a.as_str() && (h <= b.as_str() || h.starts_with(&b))
}
_ => false,
},
"inLast" | "notInLast" => {
let Some(secs) = last_secs(value) else { return false };
// a span reaching before year 0 is "ever": every dated photo is in it
let Some(from) = crate::dates::shift_iso(&now(), -secs) else { return op == "inLast" };
(h >= from.as_str()) == (op == "inLast")
}
_ => false,
}
}
impl Rule {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
let (field, op, value) = match self {
Rule::Group { group } => return group.matches(p, cat),
Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value),
};
let m = &p.meta;
let want = lower(value);
let text = |have: &str| text_op(op, have, &want);
let yes = |have: bool| bool_value(value).is_some_and(|b| have == b);
match field {
"rating" => num_op(op, Some(p.rating as f64), value),
"flag" => {
let f = flag_name(p.flag);
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
}
"label" => {
let l = p.label.map_or("none", label_colour);
// custom label names count too
let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want));
(l == want || named) == (op == "is")
}
"kind" => (kind_name(p.kind) == want) == (op == "is"),
"edited" => yes(p.is_edited()),
"hasGps" => yes(m.gps.is_some()),
"virtualCopy" => yes(p.copy_of.is_some()),
"copyName" => text(p.copy_name.as_deref().unwrap_or("")),
"stacked" => yes(cat.stack_of(p.id).is_some()),
"cropped" => yes(p.is_cropped()),
"treatment" => {
let t = if p.develop.treatment == lightcraft_develop::Treatment::Bw { "monochrome" } else { "color" };
(t == want) == (op == "is")
}
"keywords" => names_op(op, &m.keywords, &want),
"keywordCount" => num_op(op, Some(p.keyword_count() as f64), value),
"person" => names_op(op, &p.people(), &want),
"personCount" => num_op(op, Some(p.people().len() as f64), value),
"text" => {
let all = [
p.file_name.as_str(),
&m.title,
&m.caption,
&m.camera,
&m.lens,
&m.location,
&m.city,
&m.state,
&m.country,
&m.alt_text,
&p.format,
&m.keywords.join(" "),
&p.people().join(" "),
p.copy_name.as_deref().unwrap_or(""),
]
.join(" ");
text(&all)
}
"fileName" => text(&p.file_name),
"extension" => text_op(op, p.extension(), want.trim_start_matches('.')),
"duration" => num_op(op, p.duration.filter(|d| d.is_finite()), value),
"filePath" => {
// `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path
let path = match &p.source {
Source::File { path } => path.replace('\\', "/"),
Source::Demo { .. } => String::new(),
};
let want = want.replace('\\', "/");
match op {
// the whole string, spaces included (not word by word like the other text fields)
"contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
"notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
_ => text_op(op, &path, &want),
}
}
"format" => text(&p.format),
"title" => text(&m.title),
"caption" => text(&m.caption),
"altText" => text(&m.alt_text),
"city" => text(&m.city),
"state" => text(&m.state),
"country" => text(&m.country),
"camera" => text(&m.camera),
"lens" => text(&m.lens),
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
"creator" => text(&m.creator),
"copyright" => text(&m.copyright),
"copyrightStatus" => {
let want = crate::CopyrightStatus::parse(&want);
(want == Some(m.copyright_status)) == (op == "is")
}
"captureDate" => date_op(op, p.captured.as_deref(), value),
"importDate" => date_op(op, Some(&p.imported), value),
"editDate" => date_op(op, p.edited.as_deref(), value),
"shutterSpeed" => num_op(op, crate::parse_shutter_seconds(&m.shutter), &shutter_value(value)),
"iso" => num_op(op, m.iso.map(|v| v as f64), value),
"aperture" => num_op(op, m.aperture.map(|v| v as f64), value),
"focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value),
"longEdge" | "shortEdge" => {
let (w, h) = p.shown_size();
num_op(op, Some(if field == "longEdge" { w.max(h) } else { w.min(h) }.round()), value)
}
"aspect" => (aspect(p.shown_size()) == Some(want.as_str())) == (op == "is"),
"megapixels" => {
let (w, h) = p.shown_size();
num_op(op, Some(w * h / 1e6), value)
}
"sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value),
"bestOfGroup" => yes(p.analysis.is_some_and(|a| a.best || a.group.is_none())),
"album" => {
let id = album_rule_id(value);
// a smart album's photos are its matches; one being evaluated (a loop) holds none
id.is_some_and(|a| cat.album_contains(a, p)) == (op == "is")
}
_ => false,
}
}
}
impl RuleSet {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if self.rules.is_empty() {
return self.mode != Match::Any;
}
match self.mode {
Match::All => self.rules.iter().all(|r| r.matches(p, cat)),
Match::Any => self.rules.iter().any(|r| r.matches(p, cat)),
Match::None => !self.rules.iter().any(|r| r.matches(p, cat)),
}
}
/// Whether matches depend on the clock ("in the last…" rules, nested groups included): the
/// same photos can enter or leave the set without any catalog change.
pub fn depends_on_now(&self) -> bool {
self.rules.iter().any(|r| match r {
Rule::Group { group } => group.depends_on_now(),
Rule::Field { op, .. } => op == "inLast" || op == "notInLast",
})
}
/// Bring rules saved by older versions up to date, keeping what they meant: an Album rule with
/// an operator from when Album was a number field (≥, between…) always matched as "isn't", and
/// now says so. Commands and the editor apply it before checking.
pub fn upgrade(&mut self) {
for rule in &mut self.rules {
match rule {
Rule::Group { group } => group.upgrade(),
Rule::Field { field, op, .. } if field == "album" && op != "is" && op != "isNot" => *op = "isNot".into(),
Rule::Field { .. } => {}
}
}
}
/// The rules that can't mean anything, in order: an unknown field, an operator the field
/// doesn't have, a value that isn't one of the field's (a number, a date, a choice, yes or
/// no, an album that exists and isn't a folder…), text with nothing to look for, an empty group. Commands refuse a rule
/// set with problems; the editor shows them. `cat` knows the albums and colour-label names.
pub fn check(&self, cat: &Catalog) -> Vec<Problem> {
self.check_for(cat, None)
}
/// [`RuleSet::check`] for the rules of smart album `owner` (`None`: a new album or a filter),
/// which also refuses an Album rule that would make `owner` include itself.
pub fn check_for(&self, cat: &Catalog, owner: Option<crate::AlbumId>) -> Vec<Problem> {
let mut out = Vec::new();
self.check_into(cat, owner, &mut Vec::new(), &mut out);
out
}
/// The albums the Album rules test, groups included, in order.
pub fn albums_tested(&self) -> Vec<crate::AlbumId> {
let mut out = Vec::new();
for rule in &self.rules {
match rule {
Rule::Group { group } => out.extend(group.albums_tested()),
Rule::Field { field, value, .. } if field == "album" => out.extend(album_rule_id(value)),
Rule::Field { .. } => {}
}
}
out
}
fn check_into(&self, cat: &Catalog, owner: Option<crate::AlbumId>, path: &mut Vec<usize>, out: &mut Vec<Problem>) {
for (i, rule) in self.rules.iter().enumerate() {
path.push(i);
match rule {
Rule::Group { group } if group.rules.is_empty() => {
out.push(Problem { path: path.clone(), field: None, issue: Issue::EmptyGroup, message: "an empty group".into() })
}
Rule::Group { group } => group.check_into(cat, owner, path, out),
Rule::Field { field, op, value } => {
if let Some((issue, message)) = field_problem(field, op, value, cat, owner) {
out.push(Problem { path: path.clone(), field: Some(field.clone()), issue, message });
}
}
}
path.pop();
}
}
/// A short readable summary ("rating is ≥ 3 and keywords contains travel").
pub fn describe(&self) -> String {
self.describe_in(None)
}
/// [`RuleSet::describe`] naming the albums Album rules test ("album isn't “Excluded
/// Photos”"); one that is gone shows as `#9`.
pub fn describe_with(&self, cat: &Catalog) -> String {
self.describe_in(Some(cat))
}
fn describe_in(&self, cat: Option<&Catalog>) -> String {
let join = match self.mode {
Match::All => " and ",
Match::Any => " or ",
Match::None => " nor ",
};
let parts: Vec<String> = self
.rules
.iter()
.map(|r| match r {
Rule::Group { group } => format!("({})", group.describe_in(cat)),
Rule::Field { field, op, value } => {
let label = field_label(field).unwrap_or(field).to_lowercase();
let op = field_kind(field).and_then(|k| ops_for(k).iter().find(|o| o.0 == op)).map_or(op.as_str(), |o| o.1);
let v = match value {
_ if field == "album" && cat.is_some() => album_name(value, cat),
_ if field_kind(field) == Some(Kind::Bool) => {
bool_value(value).map_or_else(|| value.to_string(), |b| bool_label(b).to_lowercase())
}
Value::String(s) => choice_label(field, s).map_or_else(|| s.clone(), str::to_lowercase),
Value::Null => String::new(),
Value::Object(o) => format!(
"{} {}",
o.get("n").map(|n| n.to_string()).unwrap_or_default(),
o.get("unit").and_then(Value::as_str).unwrap_or("days")
),
Value::Array(a) => a.iter().map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_string)).collect::<Vec<_>>().join(" – "),
other => other.to_string(),
};
format!("{label} {op} {v}").trim().to_string()
}
})
.collect();
let s = parts.join(join);
if self.mode == Match::None { format!("none of: {s}") } else { s }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ColorLabel, Flag, PhotoId, Source};
use serde_json::json;
fn photo(id: u64) -> Photo {
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00");
p.rating = 4;
p.flag = Flag::Pick;
p.label = Some(ColorLabel::Red);
p.captured = Some("2026-08-14T18:30:00".into());
p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()];
p.meta.camera = "Model X2".into();
p.meta.iso = Some(1600);
p.meta.aperture = Some(2.8);
p
}
fn rs(v: serde_json::Value) -> RuleSet {
serde_json::from_value(v).unwrap()
}
#[test]
fn fields_ops_and_groups() {
let cat = Catalog::new();
let p = photo(1);
let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}");
let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}");
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]}));
no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]}));
yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]}));
no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]}));
yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]}));
yes(
json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}),
);
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
// file path: the whole string (spaces included), either separator, case-insensitive
let mut fp = photo(3);
fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() };
let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat);
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]})));
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]})));
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]})));
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]})));
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]})));
no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path
yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
yes(
json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}),
);
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
// copyright status (unknown until set)
yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]}));
no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]}));
let mut pd = photo(2);
pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat));
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat));
// nested: rating ≥ 4 and (label is blue or keywords contain food)
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
// in the last N days, relative to now
set_now(Some("2026-09-01T00:00:00".into()));
yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]}));
no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]}));
yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]}));
set_now(None);
// an empty rule list: all → everything, any → nothing
yes(json!({"rules": []}));
no(json!({"match": "any", "rules": []}));
}
#[test]
fn problems_and_description() {
let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]}));
let messages: Vec<String> = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect();
assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
let r = rs(
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
);
assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks");
// every field has a kind with operators
for (f, _, k) in FIELDS {
assert!(!ops_for(*k).is_empty(), "{f}");
}
}
/// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path
/// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one.
#[test]
fn keyword_count_rules() {
let cat = Catalog::new();
let mut p = photo(1); // travel|italy|rome, food
let m = |p: &Photo, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2)));
assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2])));
p.meta.keywords.push("Food".into());
assert!(m(&p, "is", json!(2)), "Food and food are one keyword");
p.meta.keywords.clear();
assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2)));
assert_eq!(field_group("keywordCount"), Some("Keywords & People"));
}
/// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed
/// faces are not people; People Count counts each person once.
#[test]
fn people_rules() {
use lightcraft_meta::{Rect, Region, RegionKind};
let cat = Catalog::new();
let region = |name: Option<&str>, kind: RegionKind| Region {
rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
kind,
name: name.map(str::to_string),
description: None,
};
let mut p = photo(1);
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0)));
p.meta.regions = vec![
region(Some("Ana Lima"), RegionKind::Face),
region(Some(" ana lima "), RegionKind::Face),
region(Some("Rex"), RegionKind::Pet),
region(None, RegionKind::Face),
region(Some("Bo"), RegionKind::Face),
];
assert!(m(&p, "person", "contains", json!("ana")));
assert!(m(&p, "person", "is", json!("ANA LIMA")));
assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person");
assert!(m(&p, "person", "notContains", json!("rex")));
assert!(m(&p, "person", "isNotEmpty", json!(null)));
assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face");
assert_eq!(field_group("person"), Some("Keywords & People"));
}
/// Shutter Speed compares exposure times in seconds, written as the camera shows them: a
/// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125".
#[test]
fn shutter_speed_rules() {
let cat = Catalog::new();
let mut p = photo(1);
let m = |p: &Photo, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat)
};
assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded");
p.meta.shutter = "1/250".into();
assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60")));
assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01)));
assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing");
p.meta.shutter = "2\"".into();
assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2)));
assert_eq!(field_group("shutterSpeed"), Some("Camera Info"));
}
#[test]
fn shutter_seconds_parses_camera_notation() {
for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] {
assert_eq!(crate::parse_shutter_seconds(s), want, "{s}");
}
for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] {
assert_eq!(crate::parse_shutter_seconds(s), None, "{s}");
}
}
/// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State /
/// Province and Country are text fields of their own (Location still matches all of them).
#[test]
fn file_source_description_and_location_rules() {
let cat = Catalog::new();
let mut p = photo(1); // IMG_0042.CR2
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2")));
assert!(!m(&p, "extension", "is", json!("jpg")));
p.file_name = "README".into();
assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension");
p.file_name = "archive.tar.gz".into();
assert!(m(&p, "extension", "is", json!("gz")));
p.file_name = ".hidden".into();
assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension");
assert!(m(&p, "copyName", "isEmpty", json!(null)));
p.copy_name = Some("Black and white".into());
assert!(m(&p, "copyName", "contains", json!("black")));
p.meta.alt_text = "A red kite over a hill".into();
assert!(m(&p, "altText", "contains", json!("kite")));
p.meta.city = "Porto".into();
p.meta.state = "Porto District".into();
p.meta.country = "Portugal".into();
assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal")));
assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city");
assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country");
}
/// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment.
#[test]
fn source_file_and_develop_rules() {
use crate::{Op, Stack, StackId};
let mut cat = Catalog::new();
let a = photo(1);
let b = photo(2);
let c = photo(3);
for p in [&a, &b, &c] {
cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
}
cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap();
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true)));
assert!(m(&c, "stacked", "is", json!(false)));
let mut v = photo(4);
assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration");
v.duration = Some(95.0);
assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120])));
let mut d = photo(5);
assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color")));
let mut s = (*d.develop).clone();
s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0);
s.treatment = lightcraft_develop::Treatment::Bw;
d.develop = std::sync::Arc::new(s);
assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome")));
assert!(m(&d, "treatment", "isNot", json!("color")));
let mut s = (*d.develop).clone();
s.crop = Default::default();
s.crop.geometry.angle = 2.0;
d.develop = std::sync::Arc::new(s);
assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop");
}
/// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and
/// a crop changes its edges and its megapixels. Long / Short Edge are in pixels.
#[test]
fn size_rules_follow_orientation_and_crop() {
let cat = Catalog::new();
let mut p = photo(1); // 6000 × 4000
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000)));
assert!(m(&p, "megapixels", "is", json!(24)));
let mut s = (*p.develop).clone();
s.orientation = lightcraft_geom::Orientation::Rotate90;
p.develop = std::sync::Arc::new(s.clone());
assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000)));
// a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall
s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0);
p.develop = std::sync::Arc::new(s);
assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000)));
assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24");
assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24)));
assert!(m(&p, "aspect", "isNot", json!("portrait")));
let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00");
assert!(
!m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0))
);
assert!(m(&empty, "megapixels", "is", json!(0)));
}
/// Any Searchable Text also finds the state / province, the alt text, a person on a face and
/// a virtual copy's name, as it already finds the city and country.
#[test]
fn any_searchable_text_covers_new_fields() {
let cat = Catalog::new();
let mut p = photo(1);
let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat);
for word in ["oregon", "kite", "ana", "bluish"] {
assert!(!m(&p, word), "{word}");
}
p.meta.state = "Oregon".into();
p.meta.alt_text = "A kite".into();
p.meta.regions = vec![lightcraft_meta::Region {
rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
kind: lightcraft_meta::RegionKind::Face,
name: Some("Ana".into()),
description: None,
}];
p.copy_name = Some("Bluish".into());
for word in ["oregon", "kite", "ana", "bluish"] {
assert!(m(&p, word), "{word}");
}
}
/// Choice values have readable labels: the editor and summaries show "Public Domain" and
/// "Black & White", never ids like `publicDomain`; rules still store and accept the ids.
#[test]
fn choices_have_readable_labels() {
assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain"));
assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White"));
// own labels where a shared word would translate wrongly: "Color" is the colour noun (the
// Color panel) and "None" agrees with other nouns in gendered languages
assert_eq!(choice_label("treatment", "color"), Some("In Color"));
assert_eq!(choice_label("label", "none"), Some("No Label"));
assert_eq!(choice_label("flag", "pick"), Some("Picked"));
assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)"));
assert_eq!(choice_label("flag", "nope"), None);
assert_eq!(choice_label("rating", "pick"), None, "not a choice field");
for (field, _, kind) in FIELDS {
let Kind::Choice(choices) = kind else { continue };
for (id, label) in *choices {
assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own");
assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}");
}
}
let r = rs(
json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}),
);
assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected");
let mut p = photo(1);
p.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
assert!(r.matches(&p, &Catalog::new()), "ids still match");
}
/// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every
/// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value
/// are yes. Anything else is reported, not silently taken as yes.
#[test]
fn yes_no_values() {
for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] {
assert_eq!(bool_value(&v), Some(true), "{v}");
}
for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] {
assert_eq!(bool_value(&v), Some(false), "{v}");
}
for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] {
assert_eq!(bool_value(&v), None, "{v}");
}
assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No"));
let cat = Catalog::new();
let p = photo(1); // unedited
assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no");
assert!(
!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]}))
.matches(&p, &cat)
);
assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing");
let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]}));
assert_eq!(r.describe(), "has edits is yes and cropped is no");
let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]}));
let messages: Vec<String> = bad.check(&cat).into_iter().map(|p| p.message).collect();
assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]);
}
/// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero,
/// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means
/// "ever", so every dated photo is in it and none is outside it.
#[test]
fn in_the_last_survives_hostile_counts() {
set_now(Some("2026-09-01T00:00:00".into()));
let cat = Catalog::new();
let p = photo(1); // captured 2026-08-14
let m = |op: &str, n: serde_json::Value, unit: &str| {
rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat)
};
for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] {
for op in ["inLast", "notInLast"] {
assert!(!m(op, n.clone(), "years"), "{op} {n}");
}
}
for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] {
assert!(m("inLast", n.clone(), "years"), "{n} years is ever");
assert!(!m("notInLast", n.clone(), "years"), "{n}");
}
assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days"));
set_now(None);
}
/// Every rule's value is checked against its field, so a rule that can't mean anything is
/// reported instead of quietly matching nothing (or everything). Each problem says which rule
/// it is about: "rule 2", or "rule 2.1" inside a group.
#[test]
fn values_are_checked_against_their_field() {
use crate::{Album, AlbumId, Op};
let mut cat = Catalog::new();
cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap();
let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") };
cat.apply(Op::AddAlbum { album: smart }).unwrap();
cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap();
let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat);
let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone()));
let bad = |rule: serde_json::Value, want: &str| {
let p = check(rule.clone());
assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}");
};
let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value});
// numbers
ok(r("rating", "gte", json!(3)));
ok(r("rating", "gte", json!("4")));
ok(r("rating", "between", json!([1, 5])));
ok(r("aperture", "lte", json!("f/4")));
ok(r("focalLength", "is", json!("50mm")));
bad(r("rating", "gte", json!("abc")), "needs a number");
bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7");
bad(r("iso", "between", json!([100])), "two");
bad(r("iso", "between", json!("100")), "two");
ok(r("shutterSpeed", "lte", json!("1/60")));
bad(r("shutterSpeed", "lte", json!("fast")), "1/250");
// dates
for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] {
ok(r("captureDate", "is", json!(d)));
}
ok(r("captureDate", "between", json!(["2026-01", "2026-12"])));
for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] {
bad(r("captureDate", "after", d), "needs a date");
}
bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second");
bad(r("captureDate", "between", json!(["", "2026"])), "needs a date");
ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"})));
ok(r("captureDate", "inLast", json!(7)));
bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0");
bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights");
ok(r("captureDate", "isEmpty", json!(null)));
// choices: the ids, the flag's aliases and a colour label's custom name
ok(r("kind", "is", json!("raw")));
ok(r("flag", "is", json!("picked")));
ok(r("label", "is", json!("client")));
bad(r("label", "is", json!("pink")), "pink");
bad(r("kind", "isNot", json!("photo")), "photo");
// text needs something to look for
ok(r("title", "contains", json!("kite")));
ok(r("title", "isEmpty", json!(null)));
bad(r("title", "contains", json!(" ")), "something to look for");
bad(r("keywords", "startsWith", json!("")), "something to look for");
// albums: one that exists, plain or smart
ok(r("album", "is", json!(1)));
bad(r("album", "is", json!(999)), "no album 999");
ok(r("album", "is", json!(2)));
// groups: an empty one is a mistake; problems inside one say where they are
let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat);
assert_eq!(p.iter().map(ToString::to_string).collect::<Vec<_>>(), vec!["rule 2: an empty group".to_string()]);
let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]}))
.check(&cat);
assert_eq!(p.len(), 1);
assert_eq!(p[0].path, vec![1, 1]);
assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9");
// an empty rule list at the top is "all photos" (or none, for any), not a mistake
assert!(rs(json!({"rules": []})).check(&cat).is_empty());
}
/// The check and the matcher agree. Units are any case, singular or plural, and missing means
/// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was
/// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's
/// custom name matches in any case, accents included. Empty text hints at the right operator.
#[test]
fn check_and_matcher_agree() {
use crate::Op;
set_now(Some("2026-09-01T00:00:00".into()));
let mut cat = Catalog::new();
cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap();
let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4
let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]}));
let valid = |r: &RuleSet| r.check(&cat).is_empty();
for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] {
let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit}));
assert!(valid(&r) && r.matches(&p, &cat), "{unit}");
}
let r = rule("captureDate", "inLast", json!({"n": 30}));
assert!(valid(&r) && r.matches(&p, &cat), "no unit is days");
let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"}));
assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it");
assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat));
let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"}));
assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing");
// rating: only a rule no rating could meet
for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] {
assert!(valid(&rule("rating", op, v.clone())), "{op} {v}");
}
for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] {
assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}");
}
// a custom label name, any case, accents too
let r = rule("label", "is", json!("été"));
assert!(valid(&r) && r.matches(&p, &cat));
// "isn't" with nothing points at "isn't empty", the others at "is empty"
let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default();
assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot"));
assert!(hint("contains").contains("“is empty”"), "{}", hint("contains"));
set_now(None);
}
/// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between".
#[test]
fn album_rules_are_is_or_isnt() {
assert_eq!(field_kind("album"), Some(Kind::Album));
let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect();
assert_eq!(ops, ["is", "isNot"]);
let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]}));
assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`"));
}
/// An Album rule names its album by id, written as a number or a numeric string, the same way
/// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be
/// tested.
#[test]
fn album_rule_ids() {
use crate::{Album, AlbumId, Op};
assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3)));
assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3)));
assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3)));
for v in [json!(1.5), json!(-1), json!("x"), json!(null)] {
assert_eq!(album_rule_id(&v), None, "{v}");
}
let mut cat = Catalog::new();
cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap();
let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat);
assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}");
}
/// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a
/// number field) always meant "isn't" to the matcher; upgrading says so, so they check again.
#[test]
fn old_album_operators_upgrade_to_isnt() {
let mut r = rs(json!({"rules": [
{"field": "album", "op": "gte", "value": 1},
{"field": "album", "op": "is", "value": 1},
{"field": "rating", "op": "gte", "value": 3},
{"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}}
]}));
r.upgrade();
let ops = serde_json::to_value(&r).unwrap().to_string();
assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}");
assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}");
assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}");
assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}");
}
/// Besides its English message (for agents), a problem names its field and the kind of issue,
/// so the editor can show "Title: needs something to look for" in any language. Every issue
/// has its own short text.
#[test]
fn problems_name_their_field_and_issue() {
let cat = Catalog::new();
let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat);
let p = check(json!([{"field": "title", "op": "contains", "value": ""}]));
assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty));
let p = check(json!([{"field": "title", "op": "isNot", "value": ""}]));
assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty);
// keywords and people say "are empty", and so does their hint
let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}]));
assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty));
assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”"));
let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}]));
assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches));
assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup));
let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}]));
assert_eq!(p[0].issue, Issue::DatesReversed);
let p = check(json!([{"field": "album", "op": "is", "value": null}]));
assert_eq!(p[0].issue, Issue::ChooseAlbum);
let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect();
assert!(texts.iter().all(|t| !t.is_empty()));
texts.sort_unstable();
texts.dedup();
assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently");
}
/// A rule date reads one way for the check and the matcher: a space works as the "T"
/// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions
/// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a
/// rule may too, after the seconds; anywhere else they are no date.
#[test]
fn rule_dates_have_one_reading() {
let cat = Catalog::new();
let mut p = photo(1);
p.captured = Some("2026-10-01T10:00:00".into());
let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]}));
for (op, v) in [
("is", json!("2026-10-01 10:00")),
("is", json!(" 2026-10-01T10 ")),
("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])),
("after", json!("2026-10-01 09:59")),
] {
let r = rule(op, v.clone());
assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}");
}
// a capture time with a fraction and a zone, as imports write it
let mut zoned = photo(2);
zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into());
for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] {
let r = rule("is", json!(v));
assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}");
}
assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty());
for v in [
"2026-8-14",
"2026-08-14T1",
"2026-08-14X10",
"26-08-14",
"2026-08-14T25:00",
"2026-08-14Z",
"2026-08-14 10:00-05:00",
"2026-08-14T10:00:00.",
"2026-08-14T10:00:00+2",
] {
let p = rule("is", json!(v)).check(&cat);
assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}");
}
assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string()));
assert_eq!(rule_date("2026"), Ok("2026".to_string()));
}
/// The field menu shows every rule field once: at the top level or in exactly one group,
/// and [`FIELDS`] lists them in menu order.
#[test]
fn every_field_is_in_the_menu_exactly_once() {
let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect();
let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect();
assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order");
for (label, group) in FIELD_GROUPS {
assert!(!group.is_empty(), "group {label} is empty");
}
let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect();
labels.dedup();
assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique");
}
/// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the
/// dates, all the keyword fields.
#[test]
fn related_fields_share_a_group() {
let same = |fields: &[&str], group: &str| {
for f in fields {
assert_eq!(field_group(f), Some(group), "{f}");
}
};
same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File");
same(&["album", "virtualCopy", "copyName", "stacked"], "Source");
same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size");
same(&["edited", "cropped", "treatment"], "Develop");
same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info");
same(&["captureDate", "importDate", "editDate"], "Date");
same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People");
same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description");
same(&["location", "city", "state", "country", "hasGps"], "Location");
for f in ["rating", "flag", "label", "text"] {
assert_eq!(field_group(f), None, "{f} stays at the top level");
}
assert_eq!(field_group("nope"), None);
assert_eq!(field_label("filePath"), Some("File Path"));
assert_eq!(field_label("nope"), None);
}
}