Skip to main content

kintsugi_model/
config.rs

1//! Persisted model selection.
2//!
3//! Which GGUF the daemon should load is stored in a one-line file in the data
4//! dir, so the choice survives a daemon restart without depending on a shell
5//! env var (`KINTSUGI_MODEL_FILE`). `kintsugi model use` writes it; the daemon's
6//! `LlamaScorer::autoload` reads it after the env override. This is the
7//! bring-your-own-weights path: any GGUF works, so a model can be swapped at any
8//! time without updating Kintsugi itself.
9//!
10//! Always compiled (not behind `llama`) so the CLI can manage the selection even
11//! when the installed daemon has no inference engine.
12
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context, Result};
16
17/// The data dir Kintsugi uses for the event log, socket, and this config.
18/// `KINTSUGI_DATA_DIR` overrides the platform default (and keeps tests
19/// deterministic); it must match the daemon's resolution so both read one file.
20fn data_dir() -> Option<PathBuf> {
21    if let Ok(d) = std::env::var("KINTSUGI_DATA_DIR") {
22        return Some(PathBuf::from(d));
23    }
24    directories::ProjectDirs::from("", "", "kintsugi").map(|p| p.data_dir().to_path_buf())
25}
26
27/// The file recording the configured model path (one line: an absolute path).
28pub fn model_config_path() -> Option<PathBuf> {
29    data_dir().map(|d| d.join("model.path"))
30}
31
32/// The persisted model path, if one is recorded. Returns the path even when the
33/// file no longer exists on disk, so callers can report a stale selection rather
34/// than silently ignore it.
35pub fn configured_model() -> Option<PathBuf> {
36    let cfg = model_config_path()?;
37    let raw = std::fs::read_to_string(&cfg).ok()?;
38    let trimmed = raw.trim();
39    if trimmed.is_empty() {
40        return None;
41    }
42    Some(PathBuf::from(trimmed))
43}
44
45/// Persist `path` as the configured model, creating the data dir if needed.
46pub fn set_configured_model(path: &Path) -> Result<()> {
47    let cfg = model_config_path().context("could not resolve the Kintsugi data dir")?;
48    if let Some(parent) = cfg.parent() {
49        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
50    }
51    std::fs::write(&cfg, format!("{}\n", path.display()))
52        .with_context(|| format!("write {}", cfg.display()))?;
53    Ok(())
54}
55
56/// Forget the persisted selection. A no-op if none is set.
57pub fn clear_configured_model() -> Result<()> {
58    if let Some(cfg) = model_config_path() {
59        if cfg.exists() {
60            std::fs::remove_file(&cfg).with_context(|| format!("remove {}", cfg.display()))?;
61        }
62    }
63    Ok(())
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    /// A guard that points the data dir at a temp dir for the duration of a test.
71    /// `KINTSUGI_DATA_DIR` is process-global, so the tests run serially under a
72    /// lock to avoid cross-test interference.
73    fn with_data_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
74        use std::sync::{Mutex, OnceLock};
75        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
76        let _g = LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
77        let tmp = tempfile::tempdir().unwrap();
78        std::env::set_var("KINTSUGI_DATA_DIR", tmp.path());
79        let out = f(tmp.path());
80        std::env::remove_var("KINTSUGI_DATA_DIR");
81        out
82    }
83
84    #[test]
85    fn round_trips_a_selection() {
86        with_data_dir(|_| {
87            assert!(configured_model().is_none(), "starts empty");
88            let model = PathBuf::from("/models/qwen.gguf");
89            set_configured_model(&model).unwrap();
90            assert_eq!(configured_model(), Some(model));
91        });
92    }
93
94    #[test]
95    fn clear_removes_the_selection() {
96        with_data_dir(|_| {
97            set_configured_model(Path::new("/models/a.gguf")).unwrap();
98            clear_configured_model().unwrap();
99            assert!(configured_model().is_none());
100            // Clearing again is a no-op, not an error.
101            clear_configured_model().unwrap();
102        });
103    }
104
105    #[test]
106    fn blank_or_whitespace_file_reads_as_unset() {
107        with_data_dir(|_| {
108            let cfg = model_config_path().unwrap();
109            std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
110            std::fs::write(&cfg, "   \n").unwrap();
111            assert!(configured_model().is_none());
112        });
113    }
114
115    #[test]
116    fn set_creates_the_data_dir() {
117        with_data_dir(|root| {
118            // Point at a not-yet-created nested dir to exercise create_dir_all.
119            let nested = root.join("nested/deeper");
120            std::env::set_var("KINTSUGI_DATA_DIR", &nested);
121            set_configured_model(Path::new("/m/x.gguf")).unwrap();
122            assert!(nested.join("model.path").is_file());
123        });
124    }
125}