1use anyhow::{bail, Context, Result};
2use std::path::{Path, PathBuf};
3
4pub fn videre_home() -> Result<PathBuf> {
6 if let Some(h) = std::env::var_os("VIDERE_HOME") {
7 return Ok(PathBuf::from(h));
8 }
9 match std::env::var_os("HOME") {
10 Some(h) => Ok(PathBuf::from(h).join(".videre")),
11 None => bail!("cannot locate videre home: neither VIDERE_HOME nor HOME is set"),
12 }
13}
14
15pub fn default_jsonl() -> Result<PathBuf> {
17 Ok(videre_home()?.join("hashes.jsonl"))
18}
19
20pub fn locks_dir() -> Result<PathBuf> {
32 Ok(videre_home()?.join("locks"))
33}
34
35#[derive(Debug, Default, PartialEq)]
36pub struct Config {
37 pub default_db: Option<PathBuf>,
38 pub default_path: Option<PathBuf>,
39 pub default_model: Option<String>,
42 pub min_read_rate_mb_s: Option<u64>,
45}
46
47pub fn config_path(home: &Path) -> PathBuf {
49 home.join("config.toml")
50}
51
52fn path_key(table: &toml::Table, file: &Path, key: &str) -> Result<Option<PathBuf>> {
53 match table.get(key) {
54 None => Ok(None),
55 Some(toml::Value::String(s)) => Ok(Some(PathBuf::from(s))),
56 Some(other) => bail!(
57 "malformed config {}: {} must be a string, got {}",
58 file.display(),
59 key,
60 other.type_str()
61 ),
62 }
63}
64
65fn positive_int_key(table: &toml::Table, file: &Path, key: &str) -> Result<Option<u64>> {
72 match table.get(key) {
73 None => Ok(None),
74 Some(toml::Value::Integer(n)) if *n > 0 => Ok(Some(*n as u64)),
75 Some(toml::Value::Integer(n)) => bail!(
76 "malformed config {}: {} must be greater than 0, got {}",
77 file.display(),
78 key,
79 n
80 ),
81 Some(other) => bail!(
82 "malformed config {}: {} must be an integer, got {}",
83 file.display(),
84 key,
85 other.type_str()
86 ),
87 }
88}
89
90fn string_key(table: &toml::Table, file: &Path, key: &str) -> Result<Option<String>> {
93 match table.get(key) {
94 None => Ok(None),
95 Some(toml::Value::String(s)) => Ok(Some(s.clone())),
96 Some(other) => bail!(
97 "malformed config {}: {} must be a string, got {}",
98 file.display(),
99 key,
100 other.type_str()
101 ),
102 }
103}
104
105pub fn load_config(home: &Path) -> Result<Config> {
108 let path = config_path(home);
109 let text = match std::fs::read_to_string(&path) {
110 Ok(t) => t,
111 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
112 Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
113 };
114 let table: toml::Table = text
115 .parse()
116 .with_context(|| format!("malformed config {}", path.display()))?;
117 Ok(Config {
118 default_db: path_key(&table, &path, "default_db")?,
119 default_path: path_key(&table, &path, "default_path")?,
120 default_model: string_key(&table, &path, "default_model")?,
121 min_read_rate_mb_s: positive_int_key(&table, &path, "min_read_rate_mb_s")?,
122 })
123}
124
125pub fn home_is_explicit() -> bool {
130 std::env::var_os("VIDERE_HOME").is_some()
131}
132
133pub fn resolve_db_in(home: &Path) -> Result<PathBuf> {
138 Ok(load_config(home)?
139 .default_db
140 .unwrap_or_else(|| home.join("hashes.db")))
141}
142
143pub(crate) fn decide_db(
169 home: &Path,
170 home_is_explicit: bool,
171 configured: Option<PathBuf>,
172) -> (PathBuf, Option<PathBuf>) {
173 let in_home = home.join("hashes.db");
174 match (home_is_explicit, configured) {
175 (true, Some(c)) if c != in_home => (in_home, Some(c)),
177 (true, _) => (in_home, None),
178 (false, Some(c)) => (c, None),
179 (false, None) => (in_home, None),
180 }
181}
182
183pub fn resolve_db(explicit: Option<&Path>) -> Result<PathBuf> {
184 if let Some(p) = explicit {
185 return Ok(p.to_path_buf());
187 }
188 let home = videre_home()?;
189 let (chosen, overridden) = decide_db(&home, home_is_explicit(), load_config(&home)?.default_db);
190 {
191 let in_home = chosen.clone();
192 if let Some(configured) = overridden {
193 eprintln!("videre: VIDERE_HOME is set, using {}", in_home.display());
194 eprintln!(
195 " ignoring default_db = {} from that home's config.toml; pass --db to override",
196 configured.display()
197 );
198 }
199 }
200 Ok(chosen)
201}
202
203fn set_string_key(home: &Path, key: &str, value: String) -> Result<()> {
206 std::fs::create_dir_all(home).with_context(|| format!("create {}", home.display()))?;
207 let path = config_path(home);
208 let mut table: toml::Table = match std::fs::read_to_string(&path) {
209 Ok(t) => t
210 .parse()
211 .with_context(|| format!("malformed config {}", path.display()))?,
212 Err(e) if e.kind() == std::io::ErrorKind::NotFound => toml::Table::new(),
213 Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
214 };
215 table.insert(key.to_string(), toml::Value::String(value));
216 std::fs::write(&path, toml::to_string_pretty(&table)?)
217 .with_context(|| format!("write {}", path.display()))?;
218 Ok(())
219}
220
221fn set_path_key(home: &Path, key: &str, value: &Path) -> Result<()> {
224 let abs = std::path::absolute(value)
225 .with_context(|| format!("cannot absolutize {}", value.display()))?;
226 set_string_key(home, key, abs.to_string_lossy().into_owned())
227}
228
229fn unset_key(home: &Path, key: &str) -> Result<()> {
231 let path = config_path(home);
232 let text = match std::fs::read_to_string(&path) {
233 Ok(t) => t,
234 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
235 Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
236 };
237 let mut table: toml::Table = text
238 .parse()
239 .with_context(|| format!("malformed config {}", path.display()))?;
240 if table.remove(key).is_some() {
241 std::fs::write(&path, toml::to_string_pretty(&table)?)
242 .with_context(|| format!("write {}", path.display()))?;
243 }
244 Ok(())
245}
246
247pub fn set_default_db(home: &Path, db: &Path) -> Result<()> {
248 set_path_key(home, "default_db", db)
249}
250
251pub fn unset_default_db(home: &Path) -> Result<()> {
252 unset_key(home, "default_db")
253}
254
255pub fn set_default_path(home: &Path, dir: &Path) -> Result<()> {
256 set_path_key(home, "default_path", dir)
257}
258
259pub fn unset_default_path(home: &Path) -> Result<()> {
260 unset_key(home, "default_path")
261}
262
263fn set_int_key(home: &Path, key: &str, value: i64) -> Result<()> {
266 std::fs::create_dir_all(home).with_context(|| format!("create {}", home.display()))?;
267 let path = config_path(home);
268 let mut table: toml::Table = match std::fs::read_to_string(&path) {
269 Ok(t) => t
270 .parse()
271 .with_context(|| format!("malformed config {}", path.display()))?,
272 Err(e) if e.kind() == std::io::ErrorKind::NotFound => toml::Table::new(),
273 Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
274 };
275 table.insert(key.to_string(), toml::Value::Integer(value));
276 std::fs::write(&path, toml::to_string_pretty(&table)?)
277 .with_context(|| format!("write {}", path.display()))?;
278 Ok(())
279}
280
281pub fn set_min_read_rate(home: &Path, mb_s: u64) -> Result<()> {
284 if mb_s == 0 {
285 bail!("min read rate must be greater than 0 MB/s");
286 }
287 set_int_key(home, "min_read_rate_mb_s", mb_s as i64)
288}
289
290pub fn unset_min_read_rate(home: &Path) -> Result<()> {
291 unset_key(home, "min_read_rate_mb_s")
292}
293
294pub fn set_default_model(home: &Path, model_id: &str) -> Result<()> {
295 set_string_key(home, "default_model", model_id.to_string())
296}
297
298pub fn unset_default_model(home: &Path) -> Result<()> {
299 unset_key(home, "default_model")
300}
301
302pub fn default_model() -> Result<Option<String>> {
305 Ok(load_config(&videre_home()?)?.default_model)
306}
307
308pub fn default_path() -> Result<Option<PathBuf>> {
312 Ok(load_config(&videre_home()?)?.default_path)
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use std::path::{Path, PathBuf};
319
320 fn tmp_home(tag: &str) -> PathBuf {
321 let dir = std::env::temp_dir().join(format!("videre_home_{}_{}", tag, std::process::id()));
322 let _ = std::fs::remove_dir_all(&dir);
323 std::fs::create_dir_all(&dir).unwrap();
324 dir
325 }
326
327 #[test]
328 fn missing_config_yields_defaults() {
329 let home = tmp_home("missing");
330 assert_eq!(load_config(&home).unwrap(), Config::default());
331 assert_eq!(resolve_db_in(&home).unwrap(), home.join("hashes.db"));
332 let _ = std::fs::remove_dir_all(&home);
333 }
334
335 #[test]
336 fn config_default_db_wins_over_builtin_default() {
337 let home = tmp_home("wins");
338 set_default_db(&home, Path::new("/tmp/custom.db")).unwrap();
339 assert_eq!(
340 resolve_db_in(&home).unwrap(),
341 PathBuf::from("/tmp/custom.db")
342 );
343 let _ = std::fs::remove_dir_all(&home);
344 }
345
346 #[test]
347 fn explicit_path_wins_verbatim() {
348 assert_eq!(
350 resolve_db(Some(Path::new("/x/y.db"))).unwrap(),
351 PathBuf::from("/x/y.db")
352 );
353 }
354
355 #[test]
356 fn set_default_db_absolutizes_relative_paths() {
357 let home = tmp_home("abs");
358 set_default_db(&home, Path::new("rel.db")).unwrap();
359 let db = load_config(&home).unwrap().default_db.unwrap();
360 assert!(
361 db.is_absolute(),
362 "saved path must be absolute: {}",
363 db.display()
364 );
365 assert!(db.ends_with("rel.db"));
366 let _ = std::fs::remove_dir_all(&home);
367 }
368
369 #[test]
370 fn set_preserves_unknown_keys() {
371 let home = tmp_home("preserve");
372 std::fs::write(home.join("config.toml"), "future_key = \"x\"\n").unwrap();
373 set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
374 let text = std::fs::read_to_string(home.join("config.toml")).unwrap();
375 assert!(
376 text.contains("future_key"),
377 "unknown keys must survive a rewrite: {text}"
378 );
379 assert!(text.contains("default_db"));
380 let _ = std::fs::remove_dir_all(&home);
381 }
382
383 #[test]
384 fn unset_removes_key_and_is_noop_when_missing() {
385 let home = tmp_home("unset");
386 unset_default_db(&home).unwrap(); set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
388 unset_default_db(&home).unwrap();
389 assert_eq!(load_config(&home).unwrap(), Config::default());
390 let _ = std::fs::remove_dir_all(&home);
391 }
392
393 #[test]
394 fn malformed_config_is_error() {
395 let home = tmp_home("malformed");
396 std::fs::write(home.join("config.toml"), "not = = toml").unwrap();
397 let err = load_config(&home).unwrap_err();
398 assert!(format!("{err:#}").contains("malformed config"), "{err:#}");
399 let _ = std::fs::remove_dir_all(&home);
400 }
401
402 #[test]
403 fn default_path_roundtrips_and_absolutizes() {
404 let home = tmp_home("path_roundtrip");
405 set_default_path(&home, Path::new("photos")).unwrap();
406 let dir = load_config(&home).unwrap().default_path.unwrap();
407 assert!(
408 dir.is_absolute(),
409 "saved path must be absolute: {}",
410 dir.display()
411 );
412 assert!(dir.ends_with("photos"));
413 unset_default_path(&home).unwrap();
414 assert_eq!(load_config(&home).unwrap().default_path, None);
415 let _ = std::fs::remove_dir_all(&home);
416 }
417
418 #[test]
419 fn default_model_round_trips_verbatim_without_absolutizing() {
420 let home = tmp_home("model_roundtrip");
423 set_default_model(&home, "google/siglip-base-patch16-224").unwrap();
424 assert_eq!(
425 load_config(&home).unwrap().default_model,
426 Some("google/siglip-base-patch16-224".to_string())
427 );
428 let text = std::fs::read_to_string(config_path(&home)).unwrap();
429 assert!(
430 !text.contains("/Users") && !text.contains("//"),
431 "model id must be stored verbatim, got: {text}"
432 );
433 unset_default_model(&home).unwrap();
434 assert_eq!(load_config(&home).unwrap().default_model, None);
435 let _ = std::fs::remove_dir_all(&home);
436 }
437
438 #[test]
439 fn all_three_keys_coexist_independently() {
440 let home = tmp_home("three_keys");
441 set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
442 set_default_path(&home, Path::new("/tmp/photos")).unwrap();
443 set_default_model(&home, "owner/model-224").unwrap();
444
445 let c = load_config(&home).unwrap();
446 assert_eq!(c.default_db, Some(PathBuf::from("/tmp/a.db")));
447 assert_eq!(c.default_path, Some(PathBuf::from("/tmp/photos")));
448 assert_eq!(c.default_model, Some("owner/model-224".to_string()));
449
450 unset_default_model(&home).unwrap();
452 let c = load_config(&home).unwrap();
453 assert_eq!(c.default_db, Some(PathBuf::from("/tmp/a.db")));
454 assert_eq!(c.default_path, Some(PathBuf::from("/tmp/photos")));
455 assert_eq!(c.default_model, None);
456 let _ = std::fs::remove_dir_all(&home);
457 }
458
459 #[test]
460 fn default_model_is_read_as_a_plain_string() {
461 let home = tmp_home("model_read");
462 std::fs::write(
463 config_path(&home),
464 "default_model = \"google/siglip-base-patch16-224\"\n",
465 )
466 .unwrap();
467 assert_eq!(
468 load_config(&home).unwrap().default_model,
469 Some("google/siglip-base-patch16-224".to_string())
470 );
471 let _ = std::fs::remove_dir_all(&home);
472 }
473
474 #[test]
475 fn a_non_string_default_model_is_a_hard_error() {
476 let home = tmp_home("model_badtype");
478 std::fs::write(config_path(&home), "default_model = 42\n").unwrap();
479 let err = load_config(&home).unwrap_err();
480 assert!(format!("{err:#}").contains("must be a string"), "{err:#}");
481 let _ = std::fs::remove_dir_all(&home);
482 }
483
484 #[test]
485 fn db_and_path_keys_coexist_independently() {
486 let home = tmp_home("coexist");
487 set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
488 set_default_path(&home, Path::new("/tmp/photos")).unwrap();
489 let config = load_config(&home).unwrap();
490 assert_eq!(config.default_db, Some(PathBuf::from("/tmp/a.db")));
491 assert_eq!(config.default_path, Some(PathBuf::from("/tmp/photos")));
492 unset_default_db(&home).unwrap();
494 let config = load_config(&home).unwrap();
495 assert_eq!(config.default_db, None);
496 assert_eq!(config.default_path, Some(PathBuf::from("/tmp/photos")));
497 let _ = std::fs::remove_dir_all(&home);
498 }
499}
500
501#[cfg(test)]
502mod read_rate_tests {
503 use super::*;
504
505 fn home() -> std::path::PathBuf {
506 let d = std::env::temp_dir().join(format!(
507 "videre-rate-{}-{:?}",
508 std::process::id(),
509 std::thread::current().id()
510 ));
511 let _ = std::fs::remove_dir_all(&d);
512 std::fs::create_dir_all(&d).unwrap();
513 d
514 }
515
516 #[test]
517 fn a_rate_round_trips_as_a_number_not_a_string() {
518 let h = home();
521 set_min_read_rate(&h, 50).unwrap();
522 let raw = std::fs::read_to_string(config_path(&h)).unwrap();
523 assert!(raw.contains("min_read_rate_mb_s = 50"), "got: {raw}");
524 assert_eq!(load_config(&h).unwrap().min_read_rate_mb_s, Some(50));
525 let _ = std::fs::remove_dir_all(&h);
526 }
527
528 #[test]
529 fn zero_is_refused_rather_than_written() {
530 let h = home();
533 assert!(set_min_read_rate(&h, 0).is_err());
534 let _ = std::fs::remove_dir_all(&h);
535 }
536
537 #[test]
538 fn a_zero_already_in_the_file_is_rejected_on_read() {
539 let h = home();
541 std::fs::write(config_path(&h), "min_read_rate_mb_s = 0\n").unwrap();
542 assert!(load_config(&h).is_err());
543 let _ = std::fs::remove_dir_all(&h);
544 }
545
546 #[test]
547 fn a_non_integer_is_rejected_with_a_clear_error() {
548 let h = home();
549 std::fs::write(config_path(&h), "min_read_rate_mb_s = \"fast\"\n").unwrap();
550 let e = load_config(&h).unwrap_err().to_string();
551 assert!(e.contains("must be an integer"), "got: {e}");
552 let _ = std::fs::remove_dir_all(&h);
553 }
554
555 #[test]
556 fn absent_means_the_built_in_default_applies() {
557 let h = home();
558 assert_eq!(load_config(&h).unwrap().min_read_rate_mb_s, None);
559 let _ = std::fs::remove_dir_all(&h);
560 }
561
562 #[test]
563 fn unset_removes_it() {
564 let h = home();
565 set_min_read_rate(&h, 33).unwrap();
566 unset_min_read_rate(&h).unwrap();
567 assert_eq!(load_config(&h).unwrap().min_read_rate_mb_s, None);
568 let _ = std::fs::remove_dir_all(&h);
569 }
570}
571
572#[cfg(test)]
573mod db_precedence_tests {
574 use super::*;
575
576 #[test]
583 fn videre_home_outranks_a_config_default_db() {
584 let home = Path::new("/homes/copy");
588 let configured = Some(PathBuf::from("/homes/original/hashes.db"));
589 let (chosen, overridden) = decide_db(home, true, configured.clone());
590 assert_eq!(chosen, home.join("hashes.db"));
591 assert_eq!(
592 overridden, configured,
593 "the ignored setting must be reportable"
594 );
595 }
596
597 #[test]
598 fn without_the_env_var_the_config_still_wins() {
599 let home = Path::new("/homes/default");
602 let configured = Some(PathBuf::from("/elsewhere/hashes.db"));
603 let (chosen, overridden) = decide_db(home, false, configured);
604 assert_eq!(chosen, PathBuf::from("/elsewhere/hashes.db"));
605 assert!(overridden.is_none(), "nothing was overridden");
606 }
607
608 #[test]
609 fn no_config_falls_back_to_the_home_either_way() {
610 let home = Path::new("/homes/x");
611 assert_eq!(decide_db(home, true, None).0, home.join("hashes.db"));
612 assert_eq!(decide_db(home, false, None).0, home.join("hashes.db"));
613 }
614
615 #[test]
616 fn a_config_naming_the_home_database_is_not_a_divergence() {
617 let home = Path::new("/homes/x");
620 let same = Some(home.join("hashes.db"));
621 assert!(decide_db(home, true, same).1.is_none());
622 }
623}