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!(resolve_db_in(&home).unwrap(), PathBuf::from("/tmp/custom.db"));
218        let _ = std::fs::remove_dir_all(&home);
219    }
220
221    #[test]
222    fn explicit_path_wins_verbatim() {
223        // Explicit paths never consult home or config.
224        assert_eq!(
225            resolve_db(Some(Path::new("/x/y.db"))).unwrap(),
226            PathBuf::from("/x/y.db")
227        );
228    }
229
230    #[test]
231    fn set_default_db_absolutizes_relative_paths() {
232        let home = tmp_home("abs");
233        set_default_db(&home, Path::new("rel.db")).unwrap();
234        let db = load_config(&home).unwrap().default_db.unwrap();
235        assert!(db.is_absolute(), "saved path must be absolute: {}", db.display());
236        assert!(db.ends_with("rel.db"));
237        let _ = std::fs::remove_dir_all(&home);
238    }
239
240    #[test]
241    fn set_preserves_unknown_keys() {
242        let home = tmp_home("preserve");
243        std::fs::write(home.join("config.toml"), "future_key = \"x\"\n").unwrap();
244        set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
245        let text = std::fs::read_to_string(home.join("config.toml")).unwrap();
246        assert!(text.contains("future_key"), "unknown keys must survive a rewrite: {text}");
247        assert!(text.contains("default_db"));
248        let _ = std::fs::remove_dir_all(&home);
249    }
250
251    #[test]
252    fn unset_removes_key_and_is_noop_when_missing() {
253        let home = tmp_home("unset");
254        unset_default_db(&home).unwrap(); // no file: no-op, Ok
255        set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
256        unset_default_db(&home).unwrap();
257        assert_eq!(load_config(&home).unwrap(), Config::default());
258        let _ = std::fs::remove_dir_all(&home);
259    }
260
261    #[test]
262    fn malformed_config_is_error() {
263        let home = tmp_home("malformed");
264        std::fs::write(home.join("config.toml"), "not = = toml").unwrap();
265        let err = load_config(&home).unwrap_err();
266        assert!(format!("{err:#}").contains("malformed config"), "{err:#}");
267        let _ = std::fs::remove_dir_all(&home);
268    }
269
270    #[test]
271    fn default_path_roundtrips_and_absolutizes() {
272        let home = tmp_home("path_roundtrip");
273        set_default_path(&home, Path::new("photos")).unwrap();
274        let dir = load_config(&home).unwrap().default_path.unwrap();
275        assert!(dir.is_absolute(), "saved path must be absolute: {}", dir.display());
276        assert!(dir.ends_with("photos"));
277        unset_default_path(&home).unwrap();
278        assert_eq!(load_config(&home).unwrap().default_path, None);
279        let _ = std::fs::remove_dir_all(&home);
280    }
281
282    #[test]
283    fn default_model_round_trips_verbatim_without_absolutizing() {
284        // The regression that reusing set_path_key would cause: a model id
285        // contains a slash, so absolutize() turns it into a filesystem path.
286        let home = tmp_home("model_roundtrip");
287        set_default_model(&home, "google/siglip-base-patch16-224").unwrap();
288        assert_eq!(
289            load_config(&home).unwrap().default_model,
290            Some("google/siglip-base-patch16-224".to_string())
291        );
292        let text = std::fs::read_to_string(config_path(&home)).unwrap();
293        assert!(
294            !text.contains("/Users") && !text.contains("//"),
295            "model id must be stored verbatim, got: {text}"
296        );
297        unset_default_model(&home).unwrap();
298        assert_eq!(load_config(&home).unwrap().default_model, None);
299        let _ = std::fs::remove_dir_all(&home);
300    }
301
302    #[test]
303    fn all_three_keys_coexist_independently() {
304        let home = tmp_home("three_keys");
305        set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
306        set_default_path(&home, Path::new("/tmp/photos")).unwrap();
307        set_default_model(&home, "owner/model-224").unwrap();
308
309        let c = load_config(&home).unwrap();
310        assert_eq!(c.default_db, Some(PathBuf::from("/tmp/a.db")));
311        assert_eq!(c.default_path, Some(PathBuf::from("/tmp/photos")));
312        assert_eq!(c.default_model, Some("owner/model-224".to_string()));
313
314        // Unsetting one must not disturb the others.
315        unset_default_model(&home).unwrap();
316        let c = load_config(&home).unwrap();
317        assert_eq!(c.default_db, Some(PathBuf::from("/tmp/a.db")));
318        assert_eq!(c.default_path, Some(PathBuf::from("/tmp/photos")));
319        assert_eq!(c.default_model, None);
320        let _ = std::fs::remove_dir_all(&home);
321    }
322
323    #[test]
324    fn default_model_is_read_as_a_plain_string() {
325        let home = tmp_home("model_read");
326        std::fs::write(
327            config_path(&home),
328            "default_model = \"google/siglip-base-patch16-224\"\n",
329        )
330        .unwrap();
331        assert_eq!(
332            load_config(&home).unwrap().default_model,
333            Some("google/siglip-base-patch16-224".to_string())
334        );
335        let _ = std::fs::remove_dir_all(&home);
336    }
337
338    #[test]
339    fn a_non_string_default_model_is_a_hard_error() {
340        // Same treatment as the path keys: silent fallback would mask a typo.
341        let home = tmp_home("model_badtype");
342        std::fs::write(config_path(&home), "default_model = 42\n").unwrap();
343        let err = load_config(&home).unwrap_err();
344        assert!(format!("{err:#}").contains("must be a string"), "{err:#}");
345        let _ = std::fs::remove_dir_all(&home);
346    }
347
348    #[test]
349    fn db_and_path_keys_coexist_independently() {
350        let home = tmp_home("coexist");
351        set_default_db(&home, Path::new("/tmp/a.db")).unwrap();
352        set_default_path(&home, Path::new("/tmp/photos")).unwrap();
353        let config = load_config(&home).unwrap();
354        assert_eq!(config.default_db, Some(PathBuf::from("/tmp/a.db")));
355        assert_eq!(config.default_path, Some(PathBuf::from("/tmp/photos")));
356        // unsetting one must not disturb the other
357        unset_default_db(&home).unwrap();
358        let config = load_config(&home).unwrap();
359        assert_eq!(config.default_db, None);
360        assert_eq!(config.default_path, Some(PathBuf::from("/tmp/photos")));
361        let _ = std::fs::remove_dir_all(&home);
362    }
363}