Skip to main content

videre_core/
library_config.rs

1//! Library-scoped settings: the type, its built-in defaults, and the
2//! `config.toml` under the library's reserved state directory.
3//!
4//! `db` and `jsonl` are fixed declarations, not relocation settings: if
5//! present they must equal the exact filenames the paths layer already
6//! derives, so a config copied from another library can never redirect a
7//! process into that library. A missing file means the defaults and creates
8//! nothing; a file that fails validation, or does not parse, is an error
9//! rather than a silent fallback, because a typo in the config must surface,
10//! not vanish. Unknown keys (including nested tables) are preserved by
11//! edits, which rewrite by renaming a synced scratch file into place. The
12//! bounded worker prepares only the scratch file; publication happens after
13//! that worker returns, so an I/O failure or timeout leaves prior bytes
14//! unchanged and cannot publish stale settings later.
15
16use crate::embeddings::{validate_model_id, DEFAULT_MODEL_ID};
17use crate::library::{bounded_op, root_cause_is_not_found, LibraryContext, LibraryPaths};
18use crate::marks::XmpPrecedence;
19use anyhow::{bail, Context, Result};
20use std::path::Path;
21use std::sync::atomic::{AtomicU64, Ordering};
22
23/// Settings governing how one library is processed.
24///
25/// Absent settings mean the built-in default, mirroring the global config's
26/// convention where a missing key falls back rather than erroring.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LibraryConfig {
29    /// Embedding model id, e.g. `google/siglip-base-patch16-224`. A plain
30    /// string, not a path: it must never be absolutized, the same rule the
31    /// global config's `default_model` follows.
32    pub default_model: String,
33    /// How a mark read from a file's XMP reconciles with the db on
34    /// scan/watch/import; the default is db wins and XMP fills the gaps.
35    pub xmp_precedence: XmpPrecedence,
36    /// Whether `videre watch` runs the XMP export stage each cycle. Opt-in:
37    /// absent means off, matching the global config.
38    pub export_xmp_on_watch: bool,
39    /// Assumed floor read rate in MB/s used to scale I/O timeouts to file
40    /// size; `None` means the built-in default applies
41    /// (`io_timeout::MIN_READ_RATE_MB_S_DEFAULT`).
42    pub min_read_rate_mb_s: Option<u64>,
43}
44
45impl Default for LibraryConfig {
46    /// The built-in defaults: the built-in embedding model, db-first XMP
47    /// precedence, no export on watch, and the timeout floor left at its
48    /// built-in value.
49    fn default() -> Self {
50        Self {
51            default_model: DEFAULT_MODEL_ID.to_string(),
52            xmp_precedence: XmpPrecedence::default(),
53            export_xmp_on_watch: false,
54            min_read_rate_mb_s: None,
55        }
56    }
57}
58
59/// Which supported setting an [`edit`] addresses.
60///
61/// The fixed declarations `db` and `jsonl` are deliberately absent from
62/// this vocabulary: they are not settings, and no edit can redirect library
63/// storage.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ConfigKey {
66    /// `default_model`, validated by `embeddings::validate_model_id`.
67    Model,
68    /// `min_read_rate_mb_s`, a positive integer or absent.
69    ReadRate,
70    /// `xmp_precedence`, one of the spellings `XmpPrecedence::parse` knows.
71    Xmp,
72    /// `export_xmp_on_watch`, a boolean.
73    ExportXmpOnWatch,
74}
75
76impl ConfigKey {
77    /// The serialized key this variant addresses.
78    fn name(self) -> &'static str {
79        match self {
80            ConfigKey::Model => "default_model",
81            ConfigKey::ReadRate => "min_read_rate_mb_s",
82            ConfigKey::Xmp => "xmp_precedence",
83            ConfigKey::ExportXmpOnWatch => "export_xmp_on_watch",
84        }
85    }
86}
87
88/// The serialized spelling of one precedence value. `XmpPrecedence` has no
89/// `Display`; a total match here means a new variant fails to compile
90/// rather than serializing as the wrong setting.
91fn xmp_precedence_str(p: XmpPrecedence) -> &'static str {
92    match p {
93        XmpPrecedence::Db => "db",
94        XmpPrecedence::File => "file",
95        XmpPrecedence::Newest => "newest",
96    }
97}
98
99/// The table a first edit writes: the two fixed storage declarations and
100/// every supported setting at its built-in default, so a fresh library's
101/// config documents the storage names instead of leaving them implicit.
102/// Only ever the starting point when no file exists; defaults are never
103/// written over an existing file.
104fn initial_table() -> toml::Table {
105    let defaults = LibraryConfig::default();
106    let mut table = toml::Table::new();
107    table.insert("db".into(), toml::Value::String("hashes.db".into()));
108    table.insert("jsonl".into(), toml::Value::String("hashes.jsonl".into()));
109    table.insert(
110        "default_model".into(),
111        toml::Value::String(defaults.default_model),
112    );
113    table.insert(
114        "xmp_precedence".into(),
115        toml::Value::String(xmp_precedence_str(defaults.xmp_precedence).into()),
116    );
117    table.insert(
118        "export_xmp_on_watch".into(),
119        toml::Value::Boolean(defaults.export_xmp_on_watch),
120    );
121    table
122}
123
124/// Read one string-valued setting; absent means the built-in `default`.
125/// A value of the wrong type is a hard error, matching the global config's
126/// readers: silent fallback would mask a typo.
127fn string_setting(table: &toml::Table, file: &Path, key: &str, default: &str) -> Result<String> {
128    match table.get(key) {
129        None => Ok(default.to_string()),
130        Some(toml::Value::String(s)) => Ok(s.clone()),
131        Some(other) => bail!(
132            "malformed config {}: {key} must be a string, got {}",
133            file.display(),
134            other.type_str()
135        ),
136    }
137}
138
139/// Read one boolean-valued setting; absent means the built-in `default`.
140/// A string `"true"` where a bare `true` belongs is the typo this catches.
141fn bool_setting(table: &toml::Table, file: &Path, key: &str, default: bool) -> Result<bool> {
142    match table.get(key) {
143        None => Ok(default),
144        Some(toml::Value::Boolean(b)) => Ok(*b),
145        Some(other) => bail!(
146            "malformed config {}: {key} must be a boolean, got {}",
147            file.display(),
148            other.type_str()
149        ),
150    }
151}
152
153/// Read `min_read_rate_mb_s`: absent, or a positive integer. Zero is
154/// rejected rather than clamped: as a read rate it means an unbounded
155/// timeout, which is the hang the timeout exists to prevent, and silently
156/// substituting a different number would hide a typo (the same rule the
157/// global config's `positive_int_key` states).
158fn read_rate_setting(table: &toml::Table, file: &Path) -> Result<Option<u64>> {
159    const KEY: &str = "min_read_rate_mb_s";
160    match table.get(KEY) {
161        None => Ok(None),
162        Some(toml::Value::Integer(n)) if *n > 0 => Ok(Some(*n as u64)),
163        Some(toml::Value::Integer(n)) => bail!(
164            "malformed config {}: {KEY} must be greater than 0, got {n}",
165            file.display()
166        ),
167        Some(other) => bail!(
168            "malformed config {}: {KEY} must be an integer, got {}",
169            file.display(),
170            other.type_str()
171        ),
172    }
173}
174
175/// Refuse one way a local config could try to move the library's storage:
176/// a `db` or `jsonl` key that does not equal the exact fixed filename.
177fn validate_fixed(table: &toml::Table, key: &str, expected: &str) -> Result<()> {
178    if let Some(value) = table.get(key) {
179        anyhow::ensure!(
180            value.as_str() == Some(expected),
181            "{key} must be {expected:?}; library storage cannot be redirected"
182        );
183    }
184    Ok(())
185}
186
187/// Refuse every way a local config could try to move the library's storage.
188///
189/// `db` and `jsonl` are fixed declarations relative to the state directory,
190/// not settings: if present they must equal the exact filenames the paths
191/// layer already derives, so a config copied from another library can never
192/// redirect a process into that library, and their absence resolves to the
193/// same filenames. `default_db` and `default_path` are the removed global
194/// settings; in a local config they can only be a copy-paste mistake, and
195/// interpreting them as paths would reintroduce the redirect this layout
196/// exists to make impossible. Runs before any setting is read, so a storage
197/// error is raised before any library work rather than during it.
198fn validate_storage(table: &toml::Table) -> Result<()> {
199    validate_fixed(table, "db", "hashes.db")?;
200    validate_fixed(table, "jsonl", "hashes.jsonl")?;
201    for key in ["default_db", "default_path"] {
202        anyhow::ensure!(!table.contains_key(key), "remove obsolete setting {key}");
203    }
204    Ok(())
205}
206
207/// Validate a whole parsed config table into settings.
208///
209/// Every supported key is validated, whether or not the caller is about to
210/// consume it, so one load answers for the whole file and an invalid value
211/// surfaces here, at the entrance, rather than mid-command. Absent keys
212/// resolve to the built-in defaults, the same convention the global config
213/// follows.
214fn config_from_table(table: &toml::Table, file: &Path) -> Result<LibraryConfig> {
215    validate_storage(table).with_context(|| format!("malformed config {}", file.display()))?;
216    let default_model = string_setting(table, file, "default_model", DEFAULT_MODEL_ID)?;
217    validate_model_id(&default_model)
218        .with_context(|| format!("malformed config {}", file.display()))?;
219    let xmp_default = xmp_precedence_str(XmpPrecedence::default());
220    let xmp_precedence =
221        XmpPrecedence::parse(&string_setting(table, file, "xmp_precedence", xmp_default)?)
222            .with_context(|| format!("malformed config {}", file.display()))?;
223    Ok(LibraryConfig {
224        default_model,
225        xmp_precedence,
226        export_xmp_on_watch: bool_setting(table, file, "export_xmp_on_watch", false)?,
227        min_read_rate_mb_s: read_rate_setting(table, file)?,
228    })
229}
230
231/// Read the config file, bounded: a library root can sit on a volume that
232/// stopped responding, and an unbounded read there would hang the very
233/// command that is only trying to start up. `Ok(None)` means absent, which
234/// is the only non-error way to have no config.
235fn read_config(path: &Path) -> Result<Option<String>> {
236    let owned = path.to_path_buf();
237    match bounded_op(path, "read", crate::io_timeout::STAT_TIMEOUT, move || {
238        std::fs::read_to_string(owned)
239    }) {
240        Ok(text) => Ok(Some(text)),
241        Err(e) if root_cause_is_not_found(&e) => Ok(None),
242        Err(e) => Err(e),
243    }
244}
245
246/// Load the library's config: built-in defaults when the file is absent
247/// (creating nothing), an error when it is corrupt or fails validation.
248/// Never a fallback on top of bytes that are there.
249pub fn load(paths: &LibraryPaths) -> Result<LibraryConfig> {
250    let path = &paths.config;
251    let table = match read_config(path)? {
252        None => return Ok(LibraryConfig::default()),
253        Some(text) => text
254            .parse::<toml::Table>()
255            .with_context(|| format!("malformed config {}", path.display()))?,
256    };
257    config_from_table(&table, path)
258}
259
260/// Whether the local config file exists, using the same bounded read as load.
261pub fn exists(paths: &LibraryPaths) -> Result<bool> {
262    Ok(read_config(&paths.config)?.is_some())
263}
264
265/// Check one incoming value against its key's rules before it can reach
266/// the file. The shapes mirror the load-time readers: what `load` would
267/// reject, `edit` must refuse to write, or the file and the edit disagree.
268fn validate_value(key: ConfigKey, value: &toml::Value) -> Result<()> {
269    match (key, value) {
270        (ConfigKey::Model, toml::Value::String(s)) => validate_model_id(s),
271        (ConfigKey::ReadRate, toml::Value::Integer(n)) if *n > 0 => Ok(()),
272        (ConfigKey::Xmp, toml::Value::String(s)) => XmpPrecedence::parse(s).map(|_| ()),
273        (ConfigKey::ExportXmpOnWatch, toml::Value::Boolean(_)) => Ok(()),
274        (ConfigKey::ReadRate, toml::Value::Integer(n)) => {
275            bail!("min_read_rate_mb_s must be greater than 0, got {n}")
276        }
277        (ConfigKey::Model, other) => {
278            bail!("default_model must be a string, got {}", other.type_str())
279        }
280        (ConfigKey::ReadRate, other) => bail!(
281            "min_read_rate_mb_s must be an integer, got {}",
282            other.type_str()
283        ),
284        (ConfigKey::Xmp, other) => {
285            bail!("xmp_precedence must be a string, got {}", other.type_str())
286        }
287        (ConfigKey::ExportXmpOnWatch, other) => bail!(
288            "export_xmp_on_watch must be a boolean, got {}",
289            other.type_str()
290        ),
291    }
292}
293
294/// Sequence counter making the scratch name unique within a process; the
295/// pid makes it unique across processes.
296static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
297
298/// Write the table by renaming a synced scratch file into place, the same
299/// shape `location::materialize_cities_csv` uses. The scratch file lives in
300/// the state directory so the rename never crosses a filesystem, which is
301/// what makes it atomic; validation has already completed by the time this
302/// runs, and an I/O failure at any step leaves the existing bytes untouched
303/// because the rename is the last thing to happen. After a successful rename
304/// the state directory is synced (via `library_db::sync_dir`), the same
305/// crash-durability step the database publication path takes. The bounded
306/// worker prepares and syncs the scratch file but never publishes it. Only a
307/// worker that returned within its budget reaches the rename in the calling
308/// thread, so a timed-out worker cannot overwrite a later edit. The scratch
309/// file is deliberately not removed on failure: the failing volume is why
310/// the write failed, and touching it again from the error path is the
311/// unbounded re-stat mistake `TimedOutAfter::describe` exists to prevent.
312fn write_config_with_budget_and_hooks<BeforePublish, AfterWorker>(
313    state: &Path,
314    path: &Path,
315    table: &toml::Table,
316    budget: std::time::Duration,
317    before_publish: BeforePublish,
318    after_worker: AfterWorker,
319) -> Result<()>
320where
321    BeforePublish: FnOnce() + Send + 'static,
322    AfterWorker: FnOnce() + Send + 'static,
323{
324    use std::io::Write;
325
326    let text = toml::to_string_pretty(table).context("serialize the library config")?;
327    let scratch = state.join(format!(
328        "config.toml.{}.{}.tmp",
329        std::process::id(),
330        SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed)
331    ));
332    let state = state.to_path_buf();
333    let owned_scratch = scratch.clone();
334    let write_state = state.clone();
335    bounded_op(path, "write", budget, move || {
336        std::fs::create_dir_all(&write_state)?;
337        let mut file = std::fs::File::create(&owned_scratch)?;
338        file.write_all(text.as_bytes())?;
339        file.sync_all()?;
340        drop(file);
341        before_publish();
342        after_worker();
343        Ok(())
344    })?;
345    std::fs::rename(&scratch, path)
346        .with_context(|| format!("publish library config {}", path.display()))?;
347    crate::library_db::sync_dir(&state)
348}
349
350fn write_config(state: &Path, path: &Path, table: &toml::Table) -> Result<()> {
351    write_config_with_budget_and_hooks(
352        state,
353        path,
354        table,
355        crate::io_timeout::STAT_TIMEOUT,
356        || {},
357        || {},
358    )
359}
360
361/// Write the initial five-declaration config, only when none exists.
362///
363/// Read-before-write, so a repeated call never rewrites what is there: the
364/// config `initialize` leaves behind is the one every later open sees.
365/// Crate visible because it is part of initialization, not the editing
366/// surface; it runs under the caller's init lock.
367pub(crate) fn write_initial_if_absent(ctx: &LibraryContext) -> Result<()> {
368    if read_config(&ctx.paths.config)?.is_some() {
369        return Ok(());
370    }
371    write_config(&ctx.paths.state, &ctx.paths.config, &initial_table())
372}
373
374/// Edit one supported setting in this library's `config.toml`; `None`
375/// removes the key.
376///
377/// Allowed to create the state directory and an initial config: configuring
378/// a library before its first scan is legitimate. It creates no database,
379/// writes no defaults over an existing file, and preserves unknown keys,
380/// including nested tables, through the rewrite. Unsetting against an
381/// absent config is a no-op that creates nothing; an existing file must
382/// pass the same validation `load` applies before even a no-op unset
383/// returns, so success is never a quiet blessing of a config `load` would
384/// reject. A redirected config (a symlink, or a file hard-linked into
385/// another library) is refused under the lock rather than read through the
386/// link and replaced by a local file, so an edit never silently decouples
387/// a library from whatever the link points at.
388///
389/// Concurrency: serialized against initialization and every other edit by
390/// the library's init lock (`library_locks::try_init`), held for the whole
391/// read-validate-write, so concurrent first-touch writers cannot interleave.
392/// The file is reread under that lock: the pre-lock read only decides
393/// whether this is the one no-op that must create nothing, so a file
394/// written in between is seen as it now stands. `edit` takes the init lock
395/// only and never the activity lock, so configuring a library neither waits
396/// for nor delays running work. A timed-out worker may finish its scratch
397/// file later, but it never publishes, so releasing the lock after an error
398/// cannot allow stale bytes to overwrite a later edit.
399pub fn edit(ctx: &LibraryContext, key: ConfigKey, value: Option<toml::Value>) -> Result<()> {
400    let path = &ctx.paths.config;
401    // The one case that must create nothing at all is decided before any
402    // lock: acquiring the init lock creates the locks directory, which is
403    // fine for a write but must not happen for a no-op against an absent
404    // config.
405    if value.is_none() && read_config(path)?.is_none() {
406        return Ok(());
407    }
408    // A writer creates the state the init lock lives in; an edit is a write
409    // even when validation later refuses it, because the locks directory is
410    // the library's own coordination state, not config content.
411    crate::library_locks::ensure_state_and_locks(ctx)?;
412    let _init = crate::library_locks::try_init(ctx)?;
413    // The config itself must not be redirected: without this check the
414    // rewrite below would replace a symlink with a local file, silently
415    // decoupling the library from the file it was pointing at. Absent is
416    // fine, so the no-op against a missing config keeps creating nothing.
417    crate::library_locks::reject_redirect(path, "the library config")?;
418    let mut table = match read_config(path)? {
419        Some(text) => {
420            let table = text
421                .parse::<toml::Table>()
422                .with_context(|| format!("malformed config {}", path.display()))?;
423            // The file as it stands must load before anything is done with
424            // it, including nothing: an edit can never launder a broken
425            // file into place and leave the failure to surface mid-scan,
426            // and a no-op against one must surface the breakage rather
427            // than bless it by succeeding. This is the reread under the
428            // init lock: the file may have changed since the pre-lock
429            // read, and validation answers for what is on disk now.
430            config_from_table(&table, path)?;
431            table
432        }
433        None => initial_table(),
434    };
435    match value {
436        Some(v) => {
437            validate_value(key, &v)?;
438            table.insert(key.name().to_string(), v);
439        }
440        None => {
441            if table.remove(key.name()).is_none() {
442                // The key is not there; rewriting the file would move bytes
443                // for no setting change at all. The file as a whole has
444                // already been validated above, so returning without
445                // rewriting cannot leave a broken file unexamined.
446                return Ok(());
447            }
448        }
449    }
450    write_config(&ctx.paths.state, path, &table)
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::library::LibraryContext;
457    use crate::library_test_support::write_past_test_capture;
458    use std::os::unix::fs::PermissionsExt;
459
460    /// One library whose config file holds `body`, if any. All path
461    /// expectations are built from the context itself: construction
462    /// canonicalizes the root, and on macOS a tempdir resolves under
463    /// /private, so the spelling the test created is not where the state
464    /// directory lives. The TempDir is returned so it outlives the
465    /// assertions that read the files inside it.
466    fn library_with_config(body: &str) -> (tempfile::TempDir, LibraryContext) {
467        let temp = tempfile::tempdir().unwrap();
468        let root = temp.path().join("photos");
469        std::fs::create_dir(&root).unwrap();
470        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
471        std::fs::create_dir(&ctx.paths.state).unwrap();
472        if !body.is_empty() {
473            std::fs::write(&ctx.paths.config, body).unwrap();
474        }
475        (temp, ctx)
476    }
477
478    #[test]
479    fn local_config_rejects_redirects_and_preserves_unknown_fields() {
480        let temp = tempfile::tempdir().unwrap();
481        let root = temp.path().join("photos");
482        std::fs::create_dir(&root).unwrap();
483        let ctx = crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
484        std::fs::create_dir(&ctx.paths.state).unwrap();
485        std::fs::write(&ctx.paths.config, "db = \"elsewhere.db\"\n").unwrap();
486        assert!(load(&ctx.paths).is_err());
487        std::fs::write(&ctx.paths.config, "custom = \"keep\"\n").unwrap();
488        edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(42))).unwrap();
489        let text = std::fs::read_to_string(&ctx.paths.config).unwrap();
490        let table: toml::Table = toml::from_str(&text).unwrap();
491        assert_eq!(table["custom"].as_str(), Some("keep"));
492        assert_eq!(load(&ctx.paths).unwrap().min_read_rate_mb_s, Some(42));
493        assert!(!ctx.paths.db.exists());
494    }
495
496    #[test]
497    fn an_absent_config_means_defaults_and_creates_nothing() {
498        let temp = tempfile::tempdir().unwrap();
499        let root = temp.path().join("photos");
500        std::fs::create_dir(&root).unwrap();
501        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
502        // Looking at a library must not bring its state directory into
503        // being, and reading settings must not conjure a config file.
504        assert!(!ctx.paths.state.exists());
505        assert_eq!(ctx.settings, LibraryConfig::default());
506        assert_eq!(load(&ctx.paths).unwrap(), LibraryConfig::default());
507        assert!(!ctx.paths.config.exists());
508    }
509
510    #[test]
511    fn fixed_declarations_accept_only_the_exact_filenames() {
512        for (key, fixed) in [("db", "hashes.db"), ("jsonl", "hashes.jsonl")] {
513            // The exact fixed filename passes.
514            let (_t, ctx) = library_with_config(&format!("{key} = \"{fixed}\"\n"));
515            assert!(load(&ctx.paths).is_ok(), "{key} at its fixed value");
516            // Absence resolves to the same filename, so the declarations
517            // are optional, not load-bearing.
518            let (_t, ctx) = library_with_config("custom = \"x\"\n");
519            assert!(load(&ctx.paths).is_ok(), "{key} absent");
520            // Any other value, or any other type, is an error before any
521            // library work: these are declarations, not settings.
522            for body in [
523                format!("{key} = \"elsewhere-{key}.db\"\n"),
524                format!("{key} = 3\n"),
525                format!("{key} = true\n"),
526            ] {
527                let (_t, ctx) = library_with_config(&body);
528                let err = load(&ctx.paths).unwrap_err();
529                let msg = format!("{err:#}");
530                assert!(msg.contains(key), "{body}: {msg}");
531                assert!(msg.contains("cannot be redirected"), "{body}: {msg}");
532            }
533        }
534    }
535
536    #[test]
537    fn removed_global_keys_are_rejected_with_an_actionable_error() {
538        for key in ["default_db", "default_path"] {
539            let (_t, ctx) = library_with_config(&format!("{key} = \"/elsewhere/hashes.db\"\n"));
540            let err = load(&ctx.paths).unwrap_err();
541            let msg = format!("{err:#}");
542            assert!(msg.contains(key), "{key}: {msg}");
543            assert!(msg.contains("remove"), "{key}: {msg}");
544        }
545    }
546
547    #[test]
548    fn an_invalid_model_id_is_rejected_at_load() {
549        let (_t, ctx) = library_with_config("default_model = \"owner-only-no-slash\"\n");
550        let err = load(&ctx.paths).unwrap_err();
551        assert!(format!("{err:#}").contains("invalid model id"), "{err:#}");
552        // The wrong type is the same class of error as any typed setting.
553        let (_t, ctx) = library_with_config("default_model = 42\n");
554        let err = load(&ctx.paths).unwrap_err();
555        assert!(format!("{err:#}").contains("must be a string"), "{err:#}");
556    }
557
558    #[test]
559    fn an_unknown_xmp_precedence_is_rejected_and_known_values_load() {
560        let (_t, ctx) = library_with_config("xmp_precedence = \"sideways\"\n");
561        let err = load(&ctx.paths).unwrap_err();
562        assert!(format!("{err:#}").contains("sideways"), "{err:#}");
563        for value in ["db", "file", "newest"] {
564            let (_t, ctx) = library_with_config(&format!("xmp_precedence = \"{value}\"\n"));
565            assert!(load(&ctx.paths).is_ok(), "{value}");
566        }
567        let (_t, ctx) = library_with_config("xmp_precedence = 3\n");
568        let err = load(&ctx.paths).unwrap_err();
569        assert!(format!("{err:#}").contains("must be a string"), "{err:#}");
570    }
571
572    #[test]
573    fn a_non_boolean_export_flag_is_rejected() {
574        let (_t, ctx) = library_with_config("export_xmp_on_watch = \"yes\"\n");
575        let err = load(&ctx.paths).unwrap_err();
576        assert!(format!("{err:#}").contains("must be a boolean"), "{err:#}");
577        let (_t, ctx) = library_with_config("export_xmp_on_watch = true\n");
578        assert!(load(&ctx.paths).unwrap().export_xmp_on_watch);
579    }
580
581    #[test]
582    fn read_rate_rejects_zero_negative_noninteger_and_overflow() {
583        for body in [
584            "min_read_rate_mb_s = 0\n",
585            "min_read_rate_mb_s = -5\n",
586            "min_read_rate_mb_s = \"fast\"\n",
587            // One past i64::MAX is not a TOML integer at all, so it fails
588            // the parse; the point is that it is rejected, not defaulted.
589            "min_read_rate_mb_s = 9223372036854775808\n",
590        ] {
591            let (_t, ctx) = library_with_config(body);
592            assert!(load(&ctx.paths).is_err(), "{body}");
593        }
594    }
595
596    #[test]
597    fn a_corrupt_file_is_an_error_never_defaults() {
598        let (_t, ctx) = library_with_config("not = = toml\n");
599        let err = load(&ctx.paths).unwrap_err();
600        assert!(format!("{err:#}").contains("malformed config"), "{err:#}");
601    }
602
603    #[test]
604    fn unset_against_an_absent_config_is_a_noop_creating_nothing() {
605        let temp = tempfile::tempdir().unwrap();
606        let root = temp.path().join("photos");
607        std::fs::create_dir(&root).unwrap();
608        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
609        edit(&ctx, ConfigKey::Model, None).unwrap();
610        // Not even the state directory may come into being for a no-op.
611        assert!(!ctx.paths.state.exists());
612        assert!(!ctx.paths.config.exists());
613        assert!(!ctx.paths.db.exists());
614    }
615
616    #[test]
617    fn a_noop_unset_still_validates_the_existing_file() {
618        // The unset targets a key the file does not carry, so no setting
619        // would change and no bytes need to move; the file is still one
620        // load() and LibraryContext::new reject, and a no-op succeeding
621        // against it would bless it.
622        let (_t, ctx) = library_with_config("db = \"elsewhere.db\"\n");
623        let before = std::fs::read_to_string(&ctx.paths.config).unwrap();
624        let err = edit(&ctx, ConfigKey::ReadRate, None).unwrap_err();
625        assert!(
626            format!("{err:#}").contains("cannot be redirected"),
627            "{err:#}"
628        );
629        assert_eq!(std::fs::read_to_string(&ctx.paths.config).unwrap(), before);
630    }
631
632    #[test]
633    fn an_edit_refuses_a_config_symlinked_outside_the_library() {
634        let temp = tempfile::tempdir().unwrap();
635        let root = temp.path().join("photos");
636        std::fs::create_dir(&root).unwrap();
637        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
638        std::fs::create_dir(&ctx.paths.state).unwrap();
639        // The config is a symlink to some unrelated file outside the state
640        // directory. Reading it through the link is what an unguarded edit
641        // would do before replacing the link with a local file.
642        let outside = temp.path().join("outside.toml");
643        std::fs::write(&outside, "custom = \"keep\"\n").unwrap();
644        std::os::unix::fs::symlink(&outside, &ctx.paths.config).unwrap();
645        let err = edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(7))).unwrap_err();
646        assert!(format!("{err:#}").contains("symlink"), "{err:#}");
647        // The outside file is unchanged, and the link itself was not
648        // silently replaced by a local config.
649        assert_eq!(std::fs::read(&outside).unwrap(), b"custom = \"keep\"\n");
650        assert!(std::fs::symlink_metadata(&ctx.paths.config)
651            .unwrap()
652            .file_type()
653            .is_symlink());
654        // An unset against the same redirected file is refused too, not
655        // quietly succeeded as a no-op against somebody else's bytes.
656        let err = edit(&ctx, ConfigKey::ReadRate, None).unwrap_err();
657        assert!(format!("{err:#}").contains("symlink"), "{err:#}");
658    }
659
660    #[test]
661    fn an_edit_refuses_a_hard_linked_config() {
662        let temp = tempfile::tempdir().unwrap();
663        let root = temp.path().join("photos");
664        std::fs::create_dir(&root).unwrap();
665        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
666        std::fs::create_dir(&ctx.paths.state).unwrap();
667        // A second library's config, hard-linked at this library's config
668        // path: two names for one file, which an unguarded edit would
669        // rewrite for both libraries at once.
670        let other_root = temp.path().join("other-photos");
671        std::fs::create_dir(&other_root).unwrap();
672        let other = LibraryContext::new(&other_root, &temp.path().join("cache")).unwrap();
673        std::fs::create_dir(&other.paths.state).unwrap();
674        std::fs::write(&other.paths.config, "custom = \"keep\"\n").unwrap();
675        std::fs::hard_link(&other.paths.config, &ctx.paths.config).unwrap();
676        let err = edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(7))).unwrap_err();
677        assert!(format!("{err:#}").contains("hard-linked"), "{err:#}");
678        // The shared bytes are unchanged: the other library's config is not
679        // this edit's to rewrite.
680        assert_eq!(
681            std::fs::read_to_string(&other.paths.config).unwrap(),
682            "custom = \"keep\"\n"
683        );
684    }
685
686    #[test]
687    fn an_edit_preserves_unknown_nested_tables() {
688        let (_t, ctx) = library_with_config("[future]\nsub = \"keep\"\n");
689        edit(
690            &ctx,
691            ConfigKey::ExportXmpOnWatch,
692            Some(toml::Value::Boolean(true)),
693        )
694        .unwrap();
695        let table: toml::Table =
696            toml::from_str(&std::fs::read_to_string(&ctx.paths.config).unwrap()).unwrap();
697        assert_eq!(table["future"]["sub"].as_str(), Some("keep"));
698        assert_eq!(table["export_xmp_on_watch"].as_bool(), Some(true));
699    }
700
701    #[test]
702    fn an_edit_does_not_launder_an_already_broken_file() {
703        let (_t, ctx) =
704            library_with_config("export_xmp_on_watch = \"yes\"\nmin_read_rate_mb_s = 10\n");
705        let before = std::fs::read_to_string(&ctx.paths.config).unwrap();
706        // The key being edited is valid; the file as a whole is not, and a
707        // rewrite would bless the broken half. The edit must fail with the
708        // file's own bytes unchanged.
709        assert!(edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(20))).is_err());
710        assert_eq!(std::fs::read_to_string(&ctx.paths.config).unwrap(), before);
711    }
712
713    #[test]
714    fn an_invalid_edit_value_changes_no_bytes() {
715        let (_t, ctx) = library_with_config("min_read_rate_mb_s = 10\n");
716        let before = std::fs::read_to_string(&ctx.paths.config).unwrap();
717        assert!(edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(0))).is_err());
718        assert!(edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(-3))).is_err());
719        assert!(edit(
720            &ctx,
721            ConfigKey::ReadRate,
722            Some(toml::Value::String("fast".into()))
723        )
724        .is_err());
725        assert!(edit(
726            &ctx,
727            ConfigKey::Model,
728            Some(toml::Value::String("no-slash".into()))
729        )
730        .is_err());
731        assert!(edit(
732            &ctx,
733            ConfigKey::Xmp,
734            Some(toml::Value::String("sideways".into()))
735        )
736        .is_err());
737        assert!(edit(
738            &ctx,
739            ConfigKey::ExportXmpOnWatch,
740            Some(toml::Value::Integer(1))
741        )
742        .is_err());
743        assert_eq!(std::fs::read_to_string(&ctx.paths.config).unwrap(), before);
744    }
745
746    #[test]
747    fn a_failed_write_leaves_the_prior_bytes_unchanged() {
748        let (_t, ctx) = library_with_config("custom = \"keep\"\n");
749        // The locks directory exists before the failure is staged, so the
750        // failure lands on the write itself rather than on creating the
751        // init lock's directory (which is also a legitimate write, just not
752        // the one under test here).
753        std::fs::create_dir_all(&ctx.paths.locks).unwrap();
754        // Root bypasses permission bits entirely (a stock Docker image runs
755        // as root), so the behaviour is probed rather than the uid checked,
756        // the same probe the integration suite's permissions_are_enforced
757        // uses.
758        let probe = ctx.paths.root.join("probe");
759        std::fs::write(&probe, b"x").unwrap();
760        std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).unwrap();
761        if std::fs::read(&probe).is_ok() {
762            write_past_test_capture(
763                "SKIP: running as root, so chmod 000 does not block creating a file\n",
764            );
765            return;
766        }
767        std::fs::set_permissions(&ctx.paths.state, std::fs::Permissions::from_mode(0o555)).unwrap();
768        let err = edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(7))).unwrap_err();
769        // Restore first, so the tempdir can clean itself up even if an
770        // assertion below fails.
771        let _ = std::fs::set_permissions(&ctx.paths.state, std::fs::Permissions::from_mode(0o755));
772        let msg = format!("{err:#}");
773        assert!(msg.contains("write"), "{msg}");
774        assert!(msg.contains("config.toml"), "{msg}");
775        assert_eq!(
776            std::fs::read_to_string(&ctx.paths.config).unwrap(),
777            "custom = \"keep\"\n"
778        );
779        // The scratch file was never created, and nothing else appeared.
780        // The locks directory is expected: taking the init lock creates it,
781        // and it is the lock, not a leftover, so it is allowed beside the
782        // config.
783        let entries: Vec<_> = std::fs::read_dir(&ctx.paths.state)
784            .unwrap()
785            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
786            .filter(|name| name != "locks")
787            .collect();
788        assert_eq!(entries.len(), 1, "only the config may remain: {entries:?}");
789        assert_eq!(entries[0], "config.toml");
790    }
791
792    #[test]
793    fn a_timed_out_config_write_cannot_publish_after_a_later_edit() {
794        let (_t, ctx) = library_with_config("custom = \"before\"\n");
795        let mut table = toml::Table::new();
796        table.insert("custom".into(), toml::Value::String("stale".into()));
797        let (entered_tx, entered_rx) = std::sync::mpsc::channel();
798        let (release_tx, release_rx) = std::sync::mpsc::channel();
799        let (finished_tx, finished_rx) = std::sync::mpsc::channel();
800
801        let err = write_config_with_budget_and_hooks(
802            &ctx.paths.state,
803            &ctx.paths.config,
804            &table,
805            std::time::Duration::from_millis(25),
806            move || {
807                entered_tx.send(()).unwrap();
808                release_rx.recv().unwrap();
809            },
810            move || finished_tx.send(()).unwrap(),
811        )
812        .unwrap_err();
813        entered_rx
814            .recv_timeout(std::time::Duration::from_secs(1))
815            .unwrap();
816        assert!(format!("{err:#}").contains("did not respond"), "{err:#}");
817
818        std::fs::write(&ctx.paths.config, "custom = \"newer\"\n").unwrap();
819        release_tx.send(()).unwrap();
820        finished_rx
821            .recv_timeout(std::time::Duration::from_secs(1))
822            .unwrap();
823
824        assert_eq!(
825            std::fs::read_to_string(&ctx.paths.config).unwrap(),
826            "custom = \"newer\"\n"
827        );
828    }
829
830    #[test]
831    fn a_config_read_past_its_budget_fails_closed_without_restatting_the_file() {
832        // The config layer routes every filesystem touch through
833        // `library::bounded_op` with the stat ceiling (`read_config`,
834        // `write_config`), and a real wedged mount cannot be produced
835        // portably, so the bound is proven on the layer's own operation and
836        // path with a body that reliably outlasts the budget. A tiny budget
837        // against an instantaneous read would race (the worker can buffer its
838        // result before the main thread reaches recv_timeout); a 50ms budget
839        // against a 5s-sleeping body always times out first.
840        let (_t, ctx) = library_with_config("custom = \"keep\"\n");
841        let start = std::time::Instant::now();
842        let owned = ctx.paths.config.clone();
843        let err = crate::library::bounded_op(
844            &ctx.paths.config,
845            "read",
846            std::time::Duration::from_millis(50),
847            move || {
848                std::thread::sleep(std::time::Duration::from_secs(5));
849                std::fs::read_to_string(owned).map(|_| ())
850            },
851        )
852        .unwrap_err();
853        // The file is removed before the message is formatted: an error that
854        // still names the exact path and phrasing cannot have consulted the
855        // filesystem to build itself, which is the unbounded re-stat mistake
856        // `TimedOutAfter::describe` exists to prevent.
857        std::fs::remove_file(&ctx.paths.config).unwrap();
858        let msg = format!("{err:#}");
859        assert!(msg.contains("did not respond"), "{msg}");
860        assert!(msg.contains("config.toml"), "{msg}");
861        assert!(start.elapsed() < std::time::Duration::from_secs(2));
862    }
863
864    #[test]
865    fn a_first_edit_writes_the_five_declarations_and_no_database() {
866        let temp = tempfile::tempdir().unwrap();
867        let root = temp.path().join("photos");
868        std::fs::create_dir(&root).unwrap();
869        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
870        edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(42))).unwrap();
871        let table: toml::Table =
872            toml::from_str(&std::fs::read_to_string(&ctx.paths.config).unwrap()).unwrap();
873        // The five exact declarations, so a fresh library's config documents
874        // the fixed storage names instead of leaving them implicit.
875        assert_eq!(table["db"].as_str(), Some("hashes.db"));
876        assert_eq!(table["jsonl"].as_str(), Some("hashes.jsonl"));
877        assert_eq!(
878            table["default_model"].as_str(),
879            Some(crate::embeddings::DEFAULT_MODEL_ID)
880        );
881        assert_eq!(table["xmp_precedence"].as_str(), Some("db"));
882        assert_eq!(table["export_xmp_on_watch"].as_bool(), Some(false));
883        assert_eq!(table["min_read_rate_mb_s"].as_integer(), Some(42));
884        assert_eq!(load(&ctx.paths).unwrap().min_read_rate_mb_s, Some(42));
885        // Configuring a library before its first scan is legitimate; that
886        // must not conjure a database.
887        assert!(!ctx.paths.db.exists());
888    }
889
890    #[test]
891    fn a_context_does_not_mutate_when_its_config_is_later_edited() {
892        let (_t, ctx) = library_with_config("min_read_rate_mb_s = 10\n");
893        let before = ctx.settings.clone();
894        edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(99))).unwrap();
895        assert_eq!(ctx.settings, before, "a context is a snapshot, not a view");
896        // A context built after the edit sees the new value.
897        let fresh = LibraryContext::new(&ctx.paths.root, &ctx.cache.base).unwrap();
898        assert_eq!(fresh.settings.min_read_rate_mb_s, Some(99));
899    }
900
901    #[test]
902    fn a_context_refuses_to_load_an_invalid_config() {
903        let (_t, ctx) = library_with_config("db = \"elsewhere.db\"\n");
904        let err = LibraryContext::new(&ctx.paths.root, &ctx.cache.base).unwrap_err();
905        let msg = format!("{err:#}");
906        assert!(msg.contains("cannot be redirected"), "{msg}");
907        // Corrupt bytes fail construction the same way: a malformed local
908        // config must not silently fall back to defaults.
909        let (_t, ctx) = library_with_config("not = = toml\n");
910        let err = LibraryContext::new(&ctx.paths.root, &ctx.cache.base).unwrap_err();
911        assert!(format!("{err:#}").contains("malformed config"), "{err:#}");
912    }
913
914    #[test]
915    fn unset_removes_only_its_key() {
916        let (_t, ctx) =
917            library_with_config("default_model = \"owner/custom\"\nmin_read_rate_mb_s = 10\n");
918        edit(&ctx, ConfigKey::ReadRate, None).unwrap();
919        let cfg = load(&ctx.paths).unwrap();
920        assert_eq!(cfg.min_read_rate_mb_s, None);
921        assert_eq!(cfg.default_model, "owner/custom");
922    }
923
924    #[test]
925    fn a_complete_valid_file_loads_every_setting() {
926        let (_t, ctx) = library_with_config(
927            "db = \"hashes.db\"\n\
928             jsonl = \"hashes.jsonl\"\n\
929             default_model = \"owner/custom\"\n\
930             xmp_precedence = \"file\"\n\
931             export_xmp_on_watch = true\n\
932             min_read_rate_mb_s = 12\n",
933        );
934        let cfg = load(&ctx.paths).unwrap();
935        assert_eq!(cfg.default_model, "owner/custom");
936        assert_eq!(cfg.xmp_precedence, crate::marks::XmpPrecedence::File);
937        assert!(cfg.export_xmp_on_watch);
938        assert_eq!(cfg.min_read_rate_mb_s, Some(12));
939    }
940}