Skip to main content

ripvec_core/cache/
config.rs

1//! Repository-local configuration for ripvec cache.
2//!
3//! Stores and discovers `.ripvec/config.toml` files that pin the embedding
4//! model and enable repo-local index storage for a project.
5
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use crate::{Error, Result};
11
12/// Top-level structure for `.ripvec/config.toml`.
13#[derive(Debug, Serialize, Deserialize)]
14pub struct RepoConfig {
15    /// Cache settings for this repository.
16    pub cache: CacheConfig,
17}
18
19/// Cache configuration stored in `.ripvec/config.toml`.
20#[derive(Debug, Serialize, Deserialize)]
21pub struct CacheConfig {
22    /// Whether to use a repo-local cache directory instead of the global one.
23    pub local: bool,
24    /// The embedding model repo used to build this index (e.g. `"BAAI/bge-small-en-v1.5"`).
25    pub model: String,
26    /// Manifest format version string (e.g. `"3"`).
27    pub version: String,
28    /// Whether ripvec has configured `pull.autoStash` for this repo.
29    /// `None` = not yet asked, `Some(true)` = enabled, `Some(false)` = declined.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub auto_stash: Option<bool>,
32}
33
34impl RepoConfig {
35    /// Create a new config with `local = true` for the given model and version.
36    #[must_use]
37    pub fn new(model: impl Into<String>, version: impl Into<String>) -> Self {
38        Self {
39            cache: CacheConfig {
40                local: true,
41                model: model.into(),
42                version: version.into(),
43                auto_stash: None,
44            },
45        }
46    }
47
48    /// Serialize to a TOML string.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`Error::Other`] if serialization fails.
53    pub fn to_toml(&self) -> Result<String> {
54        toml::to_string(self)
55            .map_err(|e| Error::Other(anyhow::anyhow!("failed to serialize config: {e}")))
56    }
57
58    /// Deserialize from a TOML string.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`Error::Other`] if deserialization fails.
63    pub fn from_toml(s: &str) -> Result<Self> {
64        toml::from_str(s)
65            .map_err(|e| Error::Other(anyhow::anyhow!("failed to deserialize config: {e}")))
66    }
67
68    /// Write the config to `<path>/config.toml`, creating parent directories as needed.
69    ///
70    /// `path` should be the `.ripvec/` directory.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`Error::Io`] on filesystem errors, or [`Error::Other`] on
75    /// serialization failure.
76    pub fn save(&self, path: &Path) -> Result<()> {
77        std::fs::create_dir_all(path).map_err(|source| Error::Io {
78            path: path.display().to_string(),
79            source,
80        })?;
81        let file = path.join("config.toml");
82        let contents = self.to_toml()?;
83        std::fs::write(&file, contents).map_err(|source| Error::Io {
84            path: file.display().to_string(),
85            source,
86        })
87    }
88
89    /// Load config from `<path>/config.toml`.
90    ///
91    /// `path` should be the `.ripvec/` directory.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`Error::Io`] if the file cannot be read, or [`Error::Other`]
96    /// on parse failure.
97    pub fn load(path: &Path) -> Result<Self> {
98        let file = path.join("config.toml");
99        let contents = std::fs::read_to_string(&file).map_err(|source| Error::Io {
100            path: file.display().to_string(),
101            source,
102        })?;
103        Self::from_toml(&contents)
104    }
105}
106
107/// Walk up the directory tree from `start` looking for `.ripvec/config.toml`.
108///
109/// Returns `Some(ripvec_dir)` (the `.ripvec/` directory path) if a config is
110/// found **and** `cache.local == true`. Returns `None` if no config is found
111/// or if `local` is `false`.
112#[must_use]
113pub fn find_repo_config(start: &Path) -> Option<PathBuf> {
114    let mut current = start.to_path_buf();
115    loop {
116        let candidate = current.join(".ripvec");
117        let config_file = candidate.join("config.toml");
118        if config_file.exists() {
119            return RepoConfig::load(&candidate)
120                .ok()
121                .filter(|cfg| cfg.cache.local)
122                .map(|_| candidate);
123        }
124        match current.parent() {
125            Some(parent) => current = parent.to_path_buf(),
126            None => return None,
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use tempfile::TempDir;
135
136    #[test]
137    fn round_trip_toml() {
138        let cfg = RepoConfig::new("BAAI/bge-small-en-v1.5", "3");
139        let toml_str = cfg.to_toml().expect("serialize");
140        let restored = RepoConfig::from_toml(&toml_str).expect("deserialize");
141        assert!(restored.cache.local);
142        assert_eq!(restored.cache.model, "BAAI/bge-small-en-v1.5");
143        assert_eq!(restored.cache.version, "3");
144    }
145
146    #[test]
147    fn save_and_load() {
148        let dir = TempDir::new().expect("tempdir");
149        let ripvec_dir = dir.path().join(".ripvec");
150        let cfg = RepoConfig::new("nomic-ai/modernbert-embed-base", "3");
151        cfg.save(&ripvec_dir).expect("save");
152        assert!(ripvec_dir.join("config.toml").exists());
153        let loaded = RepoConfig::load(&ripvec_dir).expect("load");
154        assert!(loaded.cache.local);
155        assert_eq!(loaded.cache.model, "nomic-ai/modernbert-embed-base");
156        assert_eq!(loaded.cache.version, "3");
157    }
158
159    #[test]
160    fn find_repo_config_in_current_dir() {
161        let dir = TempDir::new().expect("tempdir");
162        let ripvec_dir = dir.path().join(".ripvec");
163        RepoConfig::new("BAAI/bge-small-en-v1.5", "3")
164            .save(&ripvec_dir)
165            .expect("save");
166        let found = find_repo_config(dir.path());
167        assert_eq!(found.as_deref(), Some(ripvec_dir.as_path()));
168    }
169
170    #[test]
171    fn find_repo_config_in_parent_dir() {
172        let dir = TempDir::new().expect("tempdir");
173        let ripvec_dir = dir.path().join(".ripvec");
174        RepoConfig::new("BAAI/bge-small-en-v1.5", "3")
175            .save(&ripvec_dir)
176            .expect("save");
177        let subdir = dir.path().join("src").join("foo");
178        std::fs::create_dir_all(&subdir).expect("mkdir");
179        let found = find_repo_config(&subdir);
180        assert_eq!(found.as_deref(), Some(ripvec_dir.as_path()));
181    }
182
183    #[test]
184    fn find_repo_config_not_found() {
185        let dir = TempDir::new().expect("tempdir");
186        // No .ripvec directory — should return None.
187        assert!(find_repo_config(dir.path()).is_none());
188    }
189
190    #[test]
191    fn find_repo_config_ignores_disabled() {
192        let dir = TempDir::new().expect("tempdir");
193        let ripvec_dir = dir.path().join(".ripvec");
194        // Manually write config with local = false.
195        std::fs::create_dir_all(&ripvec_dir).expect("mkdir");
196        let cfg_str =
197            "[cache]\nlocal = false\nmodel = \"BAAI/bge-small-en-v1.5\"\nversion = \"3\"\n";
198        std::fs::write(ripvec_dir.join("config.toml"), cfg_str).expect("write");
199        assert!(find_repo_config(dir.path()).is_none());
200    }
201}