use crate::embeddings::{validate_model_id, DEFAULT_MODEL_ID};
use crate::library::{bounded_op, root_cause_is_not_found, LibraryContext, LibraryPaths};
use crate::marks::XmpPrecedence;
use anyhow::{bail, Context, Result};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LibraryConfig {
pub default_model: String,
pub xmp_precedence: XmpPrecedence,
pub export_xmp_on_watch: bool,
pub min_read_rate_mb_s: Option<u64>,
}
impl Default for LibraryConfig {
fn default() -> Self {
Self {
default_model: DEFAULT_MODEL_ID.to_string(),
xmp_precedence: XmpPrecedence::default(),
export_xmp_on_watch: false,
min_read_rate_mb_s: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigKey {
Model,
ReadRate,
Xmp,
ExportXmpOnWatch,
}
impl ConfigKey {
fn name(self) -> &'static str {
match self {
ConfigKey::Model => "default_model",
ConfigKey::ReadRate => "min_read_rate_mb_s",
ConfigKey::Xmp => "xmp_precedence",
ConfigKey::ExportXmpOnWatch => "export_xmp_on_watch",
}
}
}
fn xmp_precedence_str(p: XmpPrecedence) -> &'static str {
match p {
XmpPrecedence::Db => "db",
XmpPrecedence::File => "file",
XmpPrecedence::Newest => "newest",
}
}
fn initial_table() -> toml::Table {
let defaults = LibraryConfig::default();
let mut table = toml::Table::new();
table.insert("db".into(), toml::Value::String("hashes.db".into()));
table.insert("jsonl".into(), toml::Value::String("hashes.jsonl".into()));
table.insert(
"default_model".into(),
toml::Value::String(defaults.default_model),
);
table.insert(
"xmp_precedence".into(),
toml::Value::String(xmp_precedence_str(defaults.xmp_precedence).into()),
);
table.insert(
"export_xmp_on_watch".into(),
toml::Value::Boolean(defaults.export_xmp_on_watch),
);
table
}
fn string_setting(table: &toml::Table, file: &Path, key: &str, default: &str) -> Result<String> {
match table.get(key) {
None => Ok(default.to_string()),
Some(toml::Value::String(s)) => Ok(s.clone()),
Some(other) => bail!(
"malformed config {}: {key} must be a string, got {}",
file.display(),
other.type_str()
),
}
}
fn bool_setting(table: &toml::Table, file: &Path, key: &str, default: bool) -> Result<bool> {
match table.get(key) {
None => Ok(default),
Some(toml::Value::Boolean(b)) => Ok(*b),
Some(other) => bail!(
"malformed config {}: {key} must be a boolean, got {}",
file.display(),
other.type_str()
),
}
}
fn read_rate_setting(table: &toml::Table, file: &Path) -> Result<Option<u64>> {
const KEY: &str = "min_read_rate_mb_s";
match table.get(KEY) {
None => Ok(None),
Some(toml::Value::Integer(n)) if *n > 0 => Ok(Some(*n as u64)),
Some(toml::Value::Integer(n)) => bail!(
"malformed config {}: {KEY} must be greater than 0, got {n}",
file.display()
),
Some(other) => bail!(
"malformed config {}: {KEY} must be an integer, got {}",
file.display(),
other.type_str()
),
}
}
fn validate_fixed(table: &toml::Table, key: &str, expected: &str) -> Result<()> {
if let Some(value) = table.get(key) {
anyhow::ensure!(
value.as_str() == Some(expected),
"{key} must be {expected:?}; library storage cannot be redirected"
);
}
Ok(())
}
fn validate_storage(table: &toml::Table) -> Result<()> {
validate_fixed(table, "db", "hashes.db")?;
validate_fixed(table, "jsonl", "hashes.jsonl")?;
for key in ["default_db", "default_path"] {
anyhow::ensure!(!table.contains_key(key), "remove obsolete setting {key}");
}
Ok(())
}
fn config_from_table(table: &toml::Table, file: &Path) -> Result<LibraryConfig> {
validate_storage(table).with_context(|| format!("malformed config {}", file.display()))?;
let default_model = string_setting(table, file, "default_model", DEFAULT_MODEL_ID)?;
validate_model_id(&default_model)
.with_context(|| format!("malformed config {}", file.display()))?;
let xmp_default = xmp_precedence_str(XmpPrecedence::default());
let xmp_precedence =
XmpPrecedence::parse(&string_setting(table, file, "xmp_precedence", xmp_default)?)
.with_context(|| format!("malformed config {}", file.display()))?;
Ok(LibraryConfig {
default_model,
xmp_precedence,
export_xmp_on_watch: bool_setting(table, file, "export_xmp_on_watch", false)?,
min_read_rate_mb_s: read_rate_setting(table, file)?,
})
}
fn read_config(path: &Path) -> Result<Option<String>> {
let owned = path.to_path_buf();
match bounded_op(path, "read", crate::io_timeout::STAT_TIMEOUT, move || {
std::fs::read_to_string(owned)
}) {
Ok(text) => Ok(Some(text)),
Err(e) if root_cause_is_not_found(&e) => Ok(None),
Err(e) => Err(e),
}
}
pub fn load(paths: &LibraryPaths) -> Result<LibraryConfig> {
let path = &paths.config;
let table = match read_config(path)? {
None => return Ok(LibraryConfig::default()),
Some(text) => text
.parse::<toml::Table>()
.with_context(|| format!("malformed config {}", path.display()))?,
};
config_from_table(&table, path)
}
pub fn exists(paths: &LibraryPaths) -> Result<bool> {
Ok(read_config(&paths.config)?.is_some())
}
fn validate_value(key: ConfigKey, value: &toml::Value) -> Result<()> {
match (key, value) {
(ConfigKey::Model, toml::Value::String(s)) => validate_model_id(s),
(ConfigKey::ReadRate, toml::Value::Integer(n)) if *n > 0 => Ok(()),
(ConfigKey::Xmp, toml::Value::String(s)) => XmpPrecedence::parse(s).map(|_| ()),
(ConfigKey::ExportXmpOnWatch, toml::Value::Boolean(_)) => Ok(()),
(ConfigKey::ReadRate, toml::Value::Integer(n)) => {
bail!("min_read_rate_mb_s must be greater than 0, got {n}")
}
(ConfigKey::Model, other) => {
bail!("default_model must be a string, got {}", other.type_str())
}
(ConfigKey::ReadRate, other) => bail!(
"min_read_rate_mb_s must be an integer, got {}",
other.type_str()
),
(ConfigKey::Xmp, other) => {
bail!("xmp_precedence must be a string, got {}", other.type_str())
}
(ConfigKey::ExportXmpOnWatch, other) => bail!(
"export_xmp_on_watch must be a boolean, got {}",
other.type_str()
),
}
}
static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
fn write_config_with_budget_and_hooks<BeforePublish, AfterWorker>(
state: &Path,
path: &Path,
table: &toml::Table,
budget: std::time::Duration,
before_publish: BeforePublish,
after_worker: AfterWorker,
) -> Result<()>
where
BeforePublish: FnOnce() + Send + 'static,
AfterWorker: FnOnce() + Send + 'static,
{
use std::io::Write;
let text = toml::to_string_pretty(table).context("serialize the library config")?;
let scratch = state.join(format!(
"config.toml.{}.{}.tmp",
std::process::id(),
SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed)
));
let state = state.to_path_buf();
let owned_scratch = scratch.clone();
let write_state = state.clone();
bounded_op(path, "write", budget, move || {
std::fs::create_dir_all(&write_state)?;
let mut file = std::fs::File::create(&owned_scratch)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
drop(file);
before_publish();
after_worker();
Ok(())
})?;
std::fs::rename(&scratch, path)
.with_context(|| format!("publish library config {}", path.display()))?;
crate::library_db::sync_dir(&state)
}
fn write_config(state: &Path, path: &Path, table: &toml::Table) -> Result<()> {
write_config_with_budget_and_hooks(
state,
path,
table,
crate::io_timeout::STAT_TIMEOUT,
|| {},
|| {},
)
}
pub(crate) fn write_initial_if_absent(ctx: &LibraryContext) -> Result<()> {
if read_config(&ctx.paths.config)?.is_some() {
return Ok(());
}
write_config(&ctx.paths.state, &ctx.paths.config, &initial_table())
}
pub fn edit(ctx: &LibraryContext, key: ConfigKey, value: Option<toml::Value>) -> Result<()> {
let path = &ctx.paths.config;
if value.is_none() && read_config(path)?.is_none() {
return Ok(());
}
crate::library_locks::ensure_state_and_locks(ctx)?;
let _init = crate::library_locks::try_init(ctx)?;
crate::library_locks::reject_redirect(path, "the library config")?;
let mut table = match read_config(path)? {
Some(text) => {
let table = text
.parse::<toml::Table>()
.with_context(|| format!("malformed config {}", path.display()))?;
config_from_table(&table, path)?;
table
}
None => initial_table(),
};
match value {
Some(v) => {
validate_value(key, &v)?;
table.insert(key.name().to_string(), v);
}
None => {
if table.remove(key.name()).is_none() {
return Ok(());
}
}
}
write_config(&ctx.paths.state, path, &table)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::LibraryContext;
use crate::library_test_support::write_past_test_capture;
use std::os::unix::fs::PermissionsExt;
fn library_with_config(body: &str) -> (tempfile::TempDir, LibraryContext) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
std::fs::create_dir(&ctx.paths.state).unwrap();
if !body.is_empty() {
std::fs::write(&ctx.paths.config, body).unwrap();
}
(temp, ctx)
}
#[test]
fn local_config_rejects_redirects_and_preserves_unknown_fields() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
std::fs::create_dir(&ctx.paths.state).unwrap();
std::fs::write(&ctx.paths.config, "db = \"elsewhere.db\"\n").unwrap();
assert!(load(&ctx.paths).is_err());
std::fs::write(&ctx.paths.config, "custom = \"keep\"\n").unwrap();
edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(42))).unwrap();
let text = std::fs::read_to_string(&ctx.paths.config).unwrap();
let table: toml::Table = toml::from_str(&text).unwrap();
assert_eq!(table["custom"].as_str(), Some("keep"));
assert_eq!(load(&ctx.paths).unwrap().min_read_rate_mb_s, Some(42));
assert!(!ctx.paths.db.exists());
}
#[test]
fn an_absent_config_means_defaults_and_creates_nothing() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
assert!(!ctx.paths.state.exists());
assert_eq!(ctx.settings, LibraryConfig::default());
assert_eq!(load(&ctx.paths).unwrap(), LibraryConfig::default());
assert!(!ctx.paths.config.exists());
}
#[test]
fn fixed_declarations_accept_only_the_exact_filenames() {
for (key, fixed) in [("db", "hashes.db"), ("jsonl", "hashes.jsonl")] {
let (_t, ctx) = library_with_config(&format!("{key} = \"{fixed}\"\n"));
assert!(load(&ctx.paths).is_ok(), "{key} at its fixed value");
let (_t, ctx) = library_with_config("custom = \"x\"\n");
assert!(load(&ctx.paths).is_ok(), "{key} absent");
for body in [
format!("{key} = \"elsewhere-{key}.db\"\n"),
format!("{key} = 3\n"),
format!("{key} = true\n"),
] {
let (_t, ctx) = library_with_config(&body);
let err = load(&ctx.paths).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains(key), "{body}: {msg}");
assert!(msg.contains("cannot be redirected"), "{body}: {msg}");
}
}
}
#[test]
fn removed_global_keys_are_rejected_with_an_actionable_error() {
for key in ["default_db", "default_path"] {
let (_t, ctx) = library_with_config(&format!("{key} = \"/elsewhere/hashes.db\"\n"));
let err = load(&ctx.paths).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains(key), "{key}: {msg}");
assert!(msg.contains("remove"), "{key}: {msg}");
}
}
#[test]
fn an_invalid_model_id_is_rejected_at_load() {
let (_t, ctx) = library_with_config("default_model = \"owner-only-no-slash\"\n");
let err = load(&ctx.paths).unwrap_err();
assert!(format!("{err:#}").contains("invalid model id"), "{err:#}");
let (_t, ctx) = library_with_config("default_model = 42\n");
let err = load(&ctx.paths).unwrap_err();
assert!(format!("{err:#}").contains("must be a string"), "{err:#}");
}
#[test]
fn an_unknown_xmp_precedence_is_rejected_and_known_values_load() {
let (_t, ctx) = library_with_config("xmp_precedence = \"sideways\"\n");
let err = load(&ctx.paths).unwrap_err();
assert!(format!("{err:#}").contains("sideways"), "{err:#}");
for value in ["db", "file", "newest"] {
let (_t, ctx) = library_with_config(&format!("xmp_precedence = \"{value}\"\n"));
assert!(load(&ctx.paths).is_ok(), "{value}");
}
let (_t, ctx) = library_with_config("xmp_precedence = 3\n");
let err = load(&ctx.paths).unwrap_err();
assert!(format!("{err:#}").contains("must be a string"), "{err:#}");
}
#[test]
fn a_non_boolean_export_flag_is_rejected() {
let (_t, ctx) = library_with_config("export_xmp_on_watch = \"yes\"\n");
let err = load(&ctx.paths).unwrap_err();
assert!(format!("{err:#}").contains("must be a boolean"), "{err:#}");
let (_t, ctx) = library_with_config("export_xmp_on_watch = true\n");
assert!(load(&ctx.paths).unwrap().export_xmp_on_watch);
}
#[test]
fn read_rate_rejects_zero_negative_noninteger_and_overflow() {
for body in [
"min_read_rate_mb_s = 0\n",
"min_read_rate_mb_s = -5\n",
"min_read_rate_mb_s = \"fast\"\n",
"min_read_rate_mb_s = 9223372036854775808\n",
] {
let (_t, ctx) = library_with_config(body);
assert!(load(&ctx.paths).is_err(), "{body}");
}
}
#[test]
fn a_corrupt_file_is_an_error_never_defaults() {
let (_t, ctx) = library_with_config("not = = toml\n");
let err = load(&ctx.paths).unwrap_err();
assert!(format!("{err:#}").contains("malformed config"), "{err:#}");
}
#[test]
fn unset_against_an_absent_config_is_a_noop_creating_nothing() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
edit(&ctx, ConfigKey::Model, None).unwrap();
assert!(!ctx.paths.state.exists());
assert!(!ctx.paths.config.exists());
assert!(!ctx.paths.db.exists());
}
#[test]
fn a_noop_unset_still_validates_the_existing_file() {
let (_t, ctx) = library_with_config("db = \"elsewhere.db\"\n");
let before = std::fs::read_to_string(&ctx.paths.config).unwrap();
let err = edit(&ctx, ConfigKey::ReadRate, None).unwrap_err();
assert!(
format!("{err:#}").contains("cannot be redirected"),
"{err:#}"
);
assert_eq!(std::fs::read_to_string(&ctx.paths.config).unwrap(), before);
}
#[test]
fn an_edit_refuses_a_config_symlinked_outside_the_library() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
std::fs::create_dir(&ctx.paths.state).unwrap();
let outside = temp.path().join("outside.toml");
std::fs::write(&outside, "custom = \"keep\"\n").unwrap();
std::os::unix::fs::symlink(&outside, &ctx.paths.config).unwrap();
let err = edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(7))).unwrap_err();
assert!(format!("{err:#}").contains("symlink"), "{err:#}");
assert_eq!(std::fs::read(&outside).unwrap(), b"custom = \"keep\"\n");
assert!(std::fs::symlink_metadata(&ctx.paths.config)
.unwrap()
.file_type()
.is_symlink());
let err = edit(&ctx, ConfigKey::ReadRate, None).unwrap_err();
assert!(format!("{err:#}").contains("symlink"), "{err:#}");
}
#[test]
fn an_edit_refuses_a_hard_linked_config() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
std::fs::create_dir(&ctx.paths.state).unwrap();
let other_root = temp.path().join("other-photos");
std::fs::create_dir(&other_root).unwrap();
let other = LibraryContext::new(&other_root, &temp.path().join("cache")).unwrap();
std::fs::create_dir(&other.paths.state).unwrap();
std::fs::write(&other.paths.config, "custom = \"keep\"\n").unwrap();
std::fs::hard_link(&other.paths.config, &ctx.paths.config).unwrap();
let err = edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(7))).unwrap_err();
assert!(format!("{err:#}").contains("hard-linked"), "{err:#}");
assert_eq!(
std::fs::read_to_string(&other.paths.config).unwrap(),
"custom = \"keep\"\n"
);
}
#[test]
fn an_edit_preserves_unknown_nested_tables() {
let (_t, ctx) = library_with_config("[future]\nsub = \"keep\"\n");
edit(
&ctx,
ConfigKey::ExportXmpOnWatch,
Some(toml::Value::Boolean(true)),
)
.unwrap();
let table: toml::Table =
toml::from_str(&std::fs::read_to_string(&ctx.paths.config).unwrap()).unwrap();
assert_eq!(table["future"]["sub"].as_str(), Some("keep"));
assert_eq!(table["export_xmp_on_watch"].as_bool(), Some(true));
}
#[test]
fn an_edit_does_not_launder_an_already_broken_file() {
let (_t, ctx) =
library_with_config("export_xmp_on_watch = \"yes\"\nmin_read_rate_mb_s = 10\n");
let before = std::fs::read_to_string(&ctx.paths.config).unwrap();
assert!(edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(20))).is_err());
assert_eq!(std::fs::read_to_string(&ctx.paths.config).unwrap(), before);
}
#[test]
fn an_invalid_edit_value_changes_no_bytes() {
let (_t, ctx) = library_with_config("min_read_rate_mb_s = 10\n");
let before = std::fs::read_to_string(&ctx.paths.config).unwrap();
assert!(edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(0))).is_err());
assert!(edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(-3))).is_err());
assert!(edit(
&ctx,
ConfigKey::ReadRate,
Some(toml::Value::String("fast".into()))
)
.is_err());
assert!(edit(
&ctx,
ConfigKey::Model,
Some(toml::Value::String("no-slash".into()))
)
.is_err());
assert!(edit(
&ctx,
ConfigKey::Xmp,
Some(toml::Value::String("sideways".into()))
)
.is_err());
assert!(edit(
&ctx,
ConfigKey::ExportXmpOnWatch,
Some(toml::Value::Integer(1))
)
.is_err());
assert_eq!(std::fs::read_to_string(&ctx.paths.config).unwrap(), before);
}
#[test]
fn a_failed_write_leaves_the_prior_bytes_unchanged() {
let (_t, ctx) = library_with_config("custom = \"keep\"\n");
std::fs::create_dir_all(&ctx.paths.locks).unwrap();
let probe = ctx.paths.root.join("probe");
std::fs::write(&probe, b"x").unwrap();
std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&probe).is_ok() {
write_past_test_capture(
"SKIP: running as root, so chmod 000 does not block creating a file\n",
);
return;
}
std::fs::set_permissions(&ctx.paths.state, std::fs::Permissions::from_mode(0o555)).unwrap();
let err = edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(7))).unwrap_err();
let _ = std::fs::set_permissions(&ctx.paths.state, std::fs::Permissions::from_mode(0o755));
let msg = format!("{err:#}");
assert!(msg.contains("write"), "{msg}");
assert!(msg.contains("config.toml"), "{msg}");
assert_eq!(
std::fs::read_to_string(&ctx.paths.config).unwrap(),
"custom = \"keep\"\n"
);
let entries: Vec<_> = std::fs::read_dir(&ctx.paths.state)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name != "locks")
.collect();
assert_eq!(entries.len(), 1, "only the config may remain: {entries:?}");
assert_eq!(entries[0], "config.toml");
}
#[test]
fn a_timed_out_config_write_cannot_publish_after_a_later_edit() {
let (_t, ctx) = library_with_config("custom = \"before\"\n");
let mut table = toml::Table::new();
table.insert("custom".into(), toml::Value::String("stale".into()));
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let (finished_tx, finished_rx) = std::sync::mpsc::channel();
let err = write_config_with_budget_and_hooks(
&ctx.paths.state,
&ctx.paths.config,
&table,
std::time::Duration::from_millis(25),
move || {
entered_tx.send(()).unwrap();
release_rx.recv().unwrap();
},
move || finished_tx.send(()).unwrap(),
)
.unwrap_err();
entered_rx
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap();
assert!(format!("{err:#}").contains("did not respond"), "{err:#}");
std::fs::write(&ctx.paths.config, "custom = \"newer\"\n").unwrap();
release_tx.send(()).unwrap();
finished_rx
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap();
assert_eq!(
std::fs::read_to_string(&ctx.paths.config).unwrap(),
"custom = \"newer\"\n"
);
}
#[test]
fn a_config_read_past_its_budget_fails_closed_without_restatting_the_file() {
let (_t, ctx) = library_with_config("custom = \"keep\"\n");
let start = std::time::Instant::now();
let owned = ctx.paths.config.clone();
let err = crate::library::bounded_op(
&ctx.paths.config,
"read",
std::time::Duration::from_millis(50),
move || {
std::thread::sleep(std::time::Duration::from_secs(5));
std::fs::read_to_string(owned).map(|_| ())
},
)
.unwrap_err();
std::fs::remove_file(&ctx.paths.config).unwrap();
let msg = format!("{err:#}");
assert!(msg.contains("did not respond"), "{msg}");
assert!(msg.contains("config.toml"), "{msg}");
assert!(start.elapsed() < std::time::Duration::from_secs(2));
}
#[test]
fn a_first_edit_writes_the_five_declarations_and_no_database() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(42))).unwrap();
let table: toml::Table =
toml::from_str(&std::fs::read_to_string(&ctx.paths.config).unwrap()).unwrap();
assert_eq!(table["db"].as_str(), Some("hashes.db"));
assert_eq!(table["jsonl"].as_str(), Some("hashes.jsonl"));
assert_eq!(
table["default_model"].as_str(),
Some(crate::embeddings::DEFAULT_MODEL_ID)
);
assert_eq!(table["xmp_precedence"].as_str(), Some("db"));
assert_eq!(table["export_xmp_on_watch"].as_bool(), Some(false));
assert_eq!(table["min_read_rate_mb_s"].as_integer(), Some(42));
assert_eq!(load(&ctx.paths).unwrap().min_read_rate_mb_s, Some(42));
assert!(!ctx.paths.db.exists());
}
#[test]
fn a_context_does_not_mutate_when_its_config_is_later_edited() {
let (_t, ctx) = library_with_config("min_read_rate_mb_s = 10\n");
let before = ctx.settings.clone();
edit(&ctx, ConfigKey::ReadRate, Some(toml::Value::Integer(99))).unwrap();
assert_eq!(ctx.settings, before, "a context is a snapshot, not a view");
let fresh = LibraryContext::new(&ctx.paths.root, &ctx.cache.base).unwrap();
assert_eq!(fresh.settings.min_read_rate_mb_s, Some(99));
}
#[test]
fn a_context_refuses_to_load_an_invalid_config() {
let (_t, ctx) = library_with_config("db = \"elsewhere.db\"\n");
let err = LibraryContext::new(&ctx.paths.root, &ctx.cache.base).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("cannot be redirected"), "{msg}");
let (_t, ctx) = library_with_config("not = = toml\n");
let err = LibraryContext::new(&ctx.paths.root, &ctx.cache.base).unwrap_err();
assert!(format!("{err:#}").contains("malformed config"), "{err:#}");
}
#[test]
fn unset_removes_only_its_key() {
let (_t, ctx) =
library_with_config("default_model = \"owner/custom\"\nmin_read_rate_mb_s = 10\n");
edit(&ctx, ConfigKey::ReadRate, None).unwrap();
let cfg = load(&ctx.paths).unwrap();
assert_eq!(cfg.min_read_rate_mb_s, None);
assert_eq!(cfg.default_model, "owner/custom");
}
#[test]
fn a_complete_valid_file_loads_every_setting() {
let (_t, ctx) = library_with_config(
"db = \"hashes.db\"\n\
jsonl = \"hashes.jsonl\"\n\
default_model = \"owner/custom\"\n\
xmp_precedence = \"file\"\n\
export_xmp_on_watch = true\n\
min_read_rate_mb_s = 12\n",
);
let cfg = load(&ctx.paths).unwrap();
assert_eq!(cfg.default_model, "owner/custom");
assert_eq!(cfg.xmp_precedence, crate::marks::XmpPrecedence::File);
assert!(cfg.export_xmp_on_watch);
assert_eq!(cfg.min_read_rate_mb_s, Some(12));
}
}