1use 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LibraryConfig {
29 pub default_model: String,
33 pub xmp_precedence: XmpPrecedence,
36 pub export_xmp_on_watch: bool,
39 pub min_read_rate_mb_s: Option<u64>,
43}
44
45impl Default for LibraryConfig {
46 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ConfigKey {
66 Model,
68 ReadRate,
70 Xmp,
72 ExportXmpOnWatch,
74}
75
76impl ConfigKey {
77 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
88fn 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
99fn 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
124fn 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
139fn 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
153fn 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
175fn 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
187fn 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
207fn 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
231fn 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
246pub 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
260pub fn exists(paths: &LibraryPaths) -> Result<bool> {
262 Ok(read_config(&paths.config)?.is_some())
263}
264
265fn 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
294static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
297
298fn 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
361pub(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
374pub fn edit(ctx: &LibraryContext, key: ConfigKey, value: Option<toml::Value>) -> Result<()> {
400 let path = &ctx.paths.config;
401 if value.is_none() && read_config(path)?.is_none() {
406 return Ok(());
407 }
408 crate::library_locks::ensure_state_and_locks(ctx)?;
412 let _init = crate::library_locks::try_init(ctx)?;
413 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 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 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 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 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 let (_t, ctx) = library_with_config(&format!("{key} = \"{fixed}\"\n"));
515 assert!(load(&ctx.paths).is_ok(), "{key} at its fixed value");
516 let (_t, ctx) = library_with_config("custom = \"x\"\n");
519 assert!(load(&ctx.paths).is_ok(), "{key} absent");
520 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 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 "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 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 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 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 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 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 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 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 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 std::fs::create_dir_all(&ctx.paths.locks).unwrap();
754 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 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 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 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 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 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 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 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 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}