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}
43
44pub 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
62fn 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
77pub 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
96pub 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
103pub 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
112fn 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
130fn 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
138fn 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
180pub fn default_model() -> Result<Option<String>> {
183 Ok(load_config(&videre_home()?)?.default_model)
184}
185
186pub 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 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(); 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 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 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 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 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}