Skip to main content

videre_core/
home.rs

1use anyhow::{bail, Context, Result};
2use std::path::{Path, PathBuf};
3
4/// Root of videre's per-user state: $VIDERE_HOME if set, else $HOME/.videre.
5pub 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
15/// Default JSONL output path (used by `dedupe --output` with no value).
16pub fn default_jsonl() -> Result<PathBuf> {
17    Ok(videre_home()?.join("hashes.jsonl"))
18}
19
20/// Directory holding `flock` sidecar lock files: `<home>/locks`.
21///
22/// Locks used to sit next to the database as `<db path>.<command>.lock`, which
23/// scattered them into whatever directory the database lived in, cluttering
24/// `~/.videre` for the default database, and the user's own folders for any
25/// `--db` elsewhere. Collecting them here keeps that state in one place.
26///
27/// Only the path is computed; creating the directory is the caller's job, so
28/// readers (`videre stats` probing liveness) never bring videre's home into
29/// existence just by looking, same lazily-created-by-writers rule the rest of
30/// the home directory follows.
31pub 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    /// Embedding model id, e.g. `google/siglip-base-patch16-224`. A plain
40    /// string, not a path: it must never be absolutized.
41    pub default_model: Option<String>,
42}
43
44/// Path of the config file inside a given home dir: <home>/config.toml.
45pub fn config_path(home: &Path) -> PathBuf {
46    home.join("config.toml")
47}
48
49fn path_key(table: &toml::Table, file: &Path, key: &str) -> Result<Option<PathBuf>> {
50    match table.get(key) {
51        None => Ok(None),
52        Some(toml::Value::String(s)) => Ok(Some(PathBuf::from(s))),
53        Some(other) => bail!(
54            "malformed config {}: {} must be a string, got {}",
55            file.display(),
56            key,
57            other.type_str()
58        ),
59    }
60}
61
62/// Read a string-valued key. Separate from `path_key` because a model id is
63/// not a path and must survive verbatim.
64fn string_key(table: &toml::Table, file: &Path, key: &str) -> Result<Option<String>> {
65    match table.get(key) {
66        None => Ok(None),
67        Some(toml::Value::String(s)) => Ok(Some(s.clone())),
68        Some(other) => bail!(
69            "malformed config {}: {} must be a string, got {}",
70            file.display(),
71            key,
72            other.type_str()
73        ),
74    }
75}
76
77/// Load <home>/config.toml. A missing file is the default config; a file that
78/// does not parse is a hard error (silent fallback would mask typos).
79pub fn load_config(home: &Path) -> Result<Config> {
80    let path = config_path(home);
81    let text = match std::fs::read_to_string(&path) {
82        Ok(t) => t,
83        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
84        Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
85    };
86    let table: toml::Table = text
87        .parse()
88        .with_context(|| format!("malformed config {}", path.display()))?;
89    Ok(Config {
90        default_db: path_key(&table, &path, "default_db")?,
91        default_path: path_key(&table, &path, "default_path")?,
92        default_model: string_key(&table, &path, "default_model")?,
93    })
94}
95
96/// Resolution for a given home: config default_db, else <home>/hashes.db.
97pub fn resolve_db_in(home: &Path) -> Result<PathBuf> {
98    Ok(load_config(home)?
99        .default_db
100        .unwrap_or_else(|| home.join("hashes.db")))
101}
102
103/// Full chain: explicit CLI path > config default_db > <home>/hashes.db.
104/// Explicit paths are used verbatim and never consult home or config.
105pub fn resolve_db(explicit: Option<&Path>) -> Result<PathBuf> {
106    match explicit {
107        Some(p) => Ok(p.to_path_buf()),
108        None => resolve_db_in(&videre_home()?),
109    }
110}
111
112/// Write one string-valued key into <home>/config.toml, creating the home
113/// dir. Unknown keys already in the file are preserved.
114fn set_string_key(home: &Path, key: &str, value: String) -> Result<()> {
115    std::fs::create_dir_all(home).with_context(|| format!("create {}", home.display()))?;
116    let path = config_path(home);
117    let mut table: toml::Table = match std::fs::read_to_string(&path) {
118        Ok(t) => t
119            .parse()
120            .with_context(|| format!("malformed config {}", path.display()))?,
121        Err(e) if e.kind() == std::io::ErrorKind::NotFound => toml::Table::new(),
122        Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
123    };
124    table.insert(key.to_string(), toml::Value::String(value));
125    std::fs::write(&path, toml::to_string_pretty(&table)?)
126        .with_context(|| format!("write {}", path.display()))?;
127    Ok(())
128}
129
130/// Write one path-valued key, absolutized. The target need not exist yet (you
131/// may set it before the first scan).
132fn set_path_key(home: &Path, key: &str, value: &Path) -> Result<()> {
133    let abs = std::path::absolute(value)
134        .with_context(|| format!("cannot absolutize {}", value.display()))?;
135    set_string_key(home, key, abs.to_string_lossy().into_owned())
136}
137
138/// Remove one key from <home>/config.toml. Missing file or key is a no-op.
139fn unset_key(home: &Path, key: &str) -> Result<()> {
140    let path = config_path(home);
141    let text = match std::fs::read_to_string(&path) {
142        Ok(t) => t,
143        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
144        Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
145    };
146    let mut table: toml::Table = text
147        .parse()
148        .with_context(|| format!("malformed config {}", path.display()))?;
149    if table.remove(key).is_some() {
150        std::fs::write(&path, toml::to_string_pretty(&table)?)
151            .with_context(|| format!("write {}", path.display()))?;
152    }
153    Ok(())
154}
155
156pub fn set_default_db(home: &Path, db: &Path) -> Result<()> {
157    set_path_key(home, "default_db", db)
158}
159
160pub fn unset_default_db(home: &Path) -> Result<()> {
161    unset_key(home, "default_db")
162}
163
164pub fn set_default_path(home: &Path, dir: &Path) -> Result<()> {
165    set_path_key(home, "default_path", dir)
166}
167
168pub fn unset_default_path(home: &Path) -> Result<()> {
169    unset_key(home, "default_path")
170}
171
172pub fn set_default_model(home: &Path, model_id: &str) -> Result<()> {
173    set_string_key(home, "default_model", model_id.to_string())
174}
175
176pub fn unset_default_model(home: &Path) -> Result<()> {
177    unset_key(home, "default_model")
178}
179
180/// The configured default embedding model, if any. None means the built-in
181/// default applies (see `videre_core::embeddings::DEFAULT_MODEL_ID`).
182pub fn default_model() -> Result<Option<String>> {
183    Ok(load_config(&videre_home()?)?.default_model)
184}
185
186/// The configured default scan/watch directory, if any (config `path` key,
187/// stored as `default_path`). There is no built-in fallback: None means the
188/// user must pass a directory explicitly.
189pub fn default_path() -> Result<Option<PathBuf>> {
190    Ok(load_config(&videre_home()?)?.default_path)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::path::{Path, PathBuf};
197
198    fn tmp_home(tag: &str) -> PathBuf {
199        let dir = std::env::temp_dir().join(format!("videre_home_{}_{}", tag, std::process::id()));
200        let _ = std::fs::remove_dir_all(&dir);
201        std::fs::create_dir_all(&dir).unwrap();
202        dir
203    }
204
205    #[test]
206    fn missing_config_yields_defaults() {
207        let home = tmp_home("missing");
208        assert_eq!(load_config(&home).unwrap(), Config::default());
209        assert_eq!(resolve_db_in(&home).unwrap(), home.join("hashes.db"));
210        let _ = std::fs::remove_dir_all(&home);
211    }
212
213    #[test]
214    fn config_default_db_wins_over_builtin_default() {
215        let home = tmp_home("wins");
216        set_default_db(&home, Path::new("/tmp/custom.db")).unwrap();
217        assert_eq!(
218            resolve_db_in(&home).unwrap(),
219            PathBuf::from("/tmp/custom.db")
220        );
221        let _ = std::fs::remove_dir_all(&home);
222    }
223
224    #[test]
225    fn explicit_path_wins_verbatim() {
226        // Explicit paths never consult home or config.
227        assert_eq!(
228            resolve_db(Some(Path::new("/x/y.db"))).unwrap(),
229            PathBuf::from("/x/y.db")
230        );
231    }
232
233    #[test]
234    fn set_default_db_absolutizes_relative_paths() {
235        let home = tmp_home("abs");
236        set_default_db(&home, Path::new("rel.db")).unwrap();
237        let db = load_config(&home).unwrap().default_db.unwrap();
238        assert!(
239            db.is_absolute(),
240            "saved path must be absolute: {}",
241            db.display()
242        );
243        assert!(db.ends_with("rel.db"));
244        let _ = std::fs::remove_dir_all(&home);
245    }
246
247    #[test]
248    fn set_preserves_unknown_keys() {
249        let home = tmp_home("preserve");
250        std::fs::write(home.join("config.toml"), "future_key = \"x\"\n").unwrap();
251        set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
252        let text = std::fs::read_to_string(home.join("config.toml")).unwrap();
253        assert!(
254            text.contains("future_key"),
255            "unknown keys must survive a rewrite: {text}"
256        );
257        assert!(text.contains("default_db"));
258        let _ = std::fs::remove_dir_all(&home);
259    }
260
261    #[test]
262    fn unset_removes_key_and_is_noop_when_missing() {
263        let home = tmp_home("unset");
264        unset_default_db(&home).unwrap(); // no file: no-op, Ok
265        set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
266        unset_default_db(&home).unwrap();
267        assert_eq!(load_config(&home).unwrap(), Config::default());
268        let _ = std::fs::remove_dir_all(&home);
269    }
270
271    #[test]
272    fn malformed_config_is_error() {
273        let home = tmp_home("malformed");
274        std::fs::write(home.join("config.toml"), "not = = toml").unwrap();
275        let err = load_config(&home).unwrap_err();
276        assert!(format!("{err:#}").contains("malformed config"), "{err:#}");
277        let _ = std::fs::remove_dir_all(&home);
278    }
279
280    #[test]
281    fn default_path_roundtrips_and_absolutizes() {
282        let home = tmp_home("path_roundtrip");
283        set_default_path(&home, Path::new("photos")).unwrap();
284        let dir = load_config(&home).unwrap().default_path.unwrap();
285        assert!(
286            dir.is_absolute(),
287            "saved path must be absolute: {}",
288            dir.display()
289        );
290        assert!(dir.ends_with("photos"));
291        unset_default_path(&home).unwrap();
292        assert_eq!(load_config(&home).unwrap().default_path, None);
293        let _ = std::fs::remove_dir_all(&home);
294    }
295
296    #[test]
297    fn default_model_round_trips_verbatim_without_absolutizing() {
298        // The regression that reusing set_path_key would cause: a model id
299        // contains a slash, so absolutize() turns it into a filesystem path.
300        let home = tmp_home("model_roundtrip");
301        set_default_model(&home, "google/siglip-base-patch16-224").unwrap();
302        assert_eq!(
303            load_config(&home).unwrap().default_model,
304            Some("google/siglip-base-patch16-224".to_string())
305        );
306        let text = std::fs::read_to_string(config_path(&home)).unwrap();
307        assert!(
308            !text.contains("/Users") && !text.contains("//"),
309            "model id must be stored verbatim, got: {text}"
310        );
311        unset_default_model(&home).unwrap();
312        assert_eq!(load_config(&home).unwrap().default_model, None);
313        let _ = std::fs::remove_dir_all(&home);
314    }
315
316    #[test]
317    fn all_three_keys_coexist_independently() {
318        let home = tmp_home("three_keys");
319        set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
320        set_default_path(&home, Path::new("/tmp/photos")).unwrap();
321        set_default_model(&home, "owner/model-224").unwrap();
322
323        let c = load_config(&home).unwrap();
324        assert_eq!(c.default_db, Some(PathBuf::from("/tmp/a.db")));
325        assert_eq!(c.default_path, Some(PathBuf::from("/tmp/photos")));
326        assert_eq!(c.default_model, Some("owner/model-224".to_string()));
327
328        // Unsetting one must not disturb the others.
329        unset_default_model(&home).unwrap();
330        let c = load_config(&home).unwrap();
331        assert_eq!(c.default_db, Some(PathBuf::from("/tmp/a.db")));
332        assert_eq!(c.default_path, Some(PathBuf::from("/tmp/photos")));
333        assert_eq!(c.default_model, None);
334        let _ = std::fs::remove_dir_all(&home);
335    }
336
337    #[test]
338    fn default_model_is_read_as_a_plain_string() {
339        let home = tmp_home("model_read");
340        std::fs::write(
341            config_path(&home),
342            "default_model = \"google/siglip-base-patch16-224\"\n",
343        )
344        .unwrap();
345        assert_eq!(
346            load_config(&home).unwrap().default_model,
347            Some("google/siglip-base-patch16-224".to_string())
348        );
349        let _ = std::fs::remove_dir_all(&home);
350    }
351
352    #[test]
353    fn a_non_string_default_model_is_a_hard_error() {
354        // Same treatment as the path keys: silent fallback would mask a typo.
355        let home = tmp_home("model_badtype");
356        std::fs::write(config_path(&home), "default_model = 42\n").unwrap();
357        let err = load_config(&home).unwrap_err();
358        assert!(format!("{err:#}").contains("must be a string"), "{err:#}");
359        let _ = std::fs::remove_dir_all(&home);
360    }
361
362    #[test]
363    fn db_and_path_keys_coexist_independently() {
364        let home = tmp_home("coexist");
365        set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
366        set_default_path(&home, Path::new("/tmp/photos")).unwrap();
367        let config = load_config(&home).unwrap();
368        assert_eq!(config.default_db, Some(PathBuf::from("/tmp/a.db")));
369        assert_eq!(config.default_path, Some(PathBuf::from("/tmp/photos")));
370        // unsetting one must not disturb the other
371        unset_default_db(&home).unwrap();
372        let config = load_config(&home).unwrap();
373        assert_eq!(config.default_db, None);
374        assert_eq!(config.default_path, Some(PathBuf::from("/tmp/photos")));
375        let _ = std::fs::remove_dir_all(&home);
376    }
377}