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    /// Index ignore settings for this repository.
18    #[serde(default, skip_serializing_if = "IgnoreConfig::is_empty")]
19    pub ignore: IgnoreConfig,
20}
21
22/// Cache configuration stored in `.ripvec/config.toml`.
23#[derive(Debug, Serialize, Deserialize)]
24pub struct CacheConfig {
25    /// Whether to use a repo-local cache directory instead of the global one.
26    pub local: bool,
27    /// The embedding model repo used to build this index (e.g. `"BAAI/bge-small-en-v1.5"`).
28    pub model: String,
29    /// Manifest format version string (e.g. `"3"`).
30    pub version: String,
31    /// Whether ripvec has configured `pull.autoStash` for this repo.
32    /// `None` = not yet asked, `Some(true)` = enabled, `Some(false)` = declined.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub auto_stash: Option<bool>,
35}
36
37/// Ignore settings stored in `.ripvec/config.toml`.
38///
39/// Patterns use `.gitignore` syntax and are matched relative to the repository
40/// root. Examples: `"*.jsonl"`, `"docs/generated/**"`, `"!docs/keep.md"`.
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
42pub struct IgnoreConfig {
43    /// Additional gitignore-style patterns to exclude from the ripvec index.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub patterns: Vec<String>,
46}
47
48impl IgnoreConfig {
49    #[must_use]
50    pub fn is_empty(&self) -> bool {
51        self.patterns.is_empty()
52    }
53}
54
55impl RepoConfig {
56    /// Create a new config with `local = true` for the given model and version.
57    #[must_use]
58    pub fn new(model: impl Into<String>, version: impl Into<String>) -> Self {
59        Self {
60            cache: CacheConfig {
61                local: true,
62                model: model.into(),
63                version: version.into(),
64                auto_stash: None,
65            },
66            ignore: IgnoreConfig::default(),
67        }
68    }
69
70    /// Serialize to a TOML string.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`Error::Other`] if serialization fails.
75    pub fn to_toml(&self) -> Result<String> {
76        toml::to_string(self)
77            .map_err(|e| Error::Other(anyhow::anyhow!("failed to serialize config: {e}")))
78    }
79
80    /// Deserialize from a TOML string.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`Error::Other`] if deserialization fails.
85    pub fn from_toml(s: &str) -> Result<Self> {
86        toml::from_str(s)
87            .map_err(|e| Error::Other(anyhow::anyhow!("failed to deserialize config: {e}")))
88    }
89
90    /// Write the config to `<path>/config.toml`, creating parent directories as needed.
91    ///
92    /// `path` should be the `.ripvec/` directory.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`Error::Io`] on filesystem errors, or [`Error::Other`] on
97    /// serialization failure.
98    pub fn save(&self, path: &Path) -> Result<()> {
99        std::fs::create_dir_all(path).map_err(|source| Error::Io {
100            path: path.display().to_string(),
101            source,
102        })?;
103        let file = path.join("config.toml");
104        let contents = self.to_toml()?;
105        std::fs::write(&file, contents).map_err(|source| Error::Io {
106            path: file.display().to_string(),
107            source,
108        })
109    }
110
111    /// Load config from `<path>/config.toml`.
112    ///
113    /// `path` should be the `.ripvec/` directory.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`Error::Io`] if the file cannot be read, or [`Error::Other`]
118    /// on parse failure.
119    pub fn load(path: &Path) -> Result<Self> {
120        let file = path.join("config.toml");
121        let contents = std::fs::read_to_string(&file).map_err(|source| Error::Io {
122            path: file.display().to_string(),
123            source,
124        })?;
125        Self::from_toml(&contents)
126    }
127}
128
129/// Walk up the directory tree from `start` looking for `.ripvec/config.toml`.
130///
131/// Returns `Some(ripvec_dir)` (the `.ripvec/` directory path) if a config is
132/// found **and** `cache.local == true`. Returns `None` if no config is found
133/// or if `local` is `false`.
134#[must_use]
135pub fn find_repo_config(start: &Path) -> Option<PathBuf> {
136    find_config(start)
137        .filter(|(_, cfg)| cfg.cache.local)
138        .map(|(ripvec_dir, _)| ripvec_dir)
139}
140
141/// Walk up the directory tree from `start` looking for `.ripvec/config.toml`.
142///
143/// Returns the `.ripvec/` directory and parsed config regardless of
144/// `cache.local`. This is used for non-cache settings such as index ignores.
145#[must_use]
146pub fn find_config(start: &Path) -> Option<(PathBuf, RepoConfig)> {
147    let mut current = start.to_path_buf();
148    loop {
149        let candidate = current.join(".ripvec");
150        let config_file = candidate.join("config.toml");
151        if config_file.exists() {
152            return RepoConfig::load(&candidate)
153                .ok()
154                .map(|config| (candidate, config));
155        }
156        match current.parent() {
157            Some(parent) => current = parent.to_path_buf(),
158            None => return None,
159        }
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use tempfile::TempDir;
167
168    #[test]
169    fn round_trip_toml() {
170        let cfg = RepoConfig::new("BAAI/bge-small-en-v1.5", "3");
171        let toml_str = cfg.to_toml().expect("serialize");
172        let restored = RepoConfig::from_toml(&toml_str).expect("deserialize");
173        assert!(restored.cache.local);
174        assert_eq!(restored.cache.model, "BAAI/bge-small-en-v1.5");
175        assert_eq!(restored.cache.version, "3");
176    }
177
178    #[test]
179    fn round_trip_ignore_patterns() {
180        let cfg = RepoConfig {
181            cache: CacheConfig {
182                local: true,
183                model: "nomic-ai/modernbert-embed-base".to_string(),
184                version: "3".to_string(),
185                auto_stash: None,
186            },
187            ignore: IgnoreConfig {
188                patterns: vec!["*.jsonl".to_string(), "docs/generated/**".to_string()],
189            },
190        };
191
192        let toml_str = cfg.to_toml().expect("serialize");
193
194        assert!(toml_str.contains("[ignore]"));
195        assert!(toml_str.contains("patterns"));
196        let restored = RepoConfig::from_toml(&toml_str).expect("deserialize");
197        assert_eq!(restored.ignore.patterns, ["*.jsonl", "docs/generated/**"]);
198    }
199
200    #[test]
201    fn missing_ignore_section_defaults_to_empty_patterns() {
202        let cfg_str =
203            "[cache]\nlocal = true\nmodel = \"BAAI/bge-small-en-v1.5\"\nversion = \"3\"\n";
204        let cfg = RepoConfig::from_toml(cfg_str).expect("deserialize");
205        assert!(cfg.ignore.patterns.is_empty());
206    }
207
208    #[test]
209    fn save_and_load() {
210        let dir = TempDir::new().expect("tempdir");
211        let ripvec_dir = dir.path().join(".ripvec");
212        let cfg = RepoConfig::new("nomic-ai/modernbert-embed-base", "3");
213        cfg.save(&ripvec_dir).expect("save");
214        assert!(ripvec_dir.join("config.toml").exists());
215        let loaded = RepoConfig::load(&ripvec_dir).expect("load");
216        assert!(loaded.cache.local);
217        assert_eq!(loaded.cache.model, "nomic-ai/modernbert-embed-base");
218        assert_eq!(loaded.cache.version, "3");
219    }
220
221    #[test]
222    fn find_repo_config_in_current_dir() {
223        let dir = TempDir::new().expect("tempdir");
224        let ripvec_dir = dir.path().join(".ripvec");
225        RepoConfig::new("BAAI/bge-small-en-v1.5", "3")
226            .save(&ripvec_dir)
227            .expect("save");
228        let found = find_repo_config(dir.path());
229        assert_eq!(found.as_deref(), Some(ripvec_dir.as_path()));
230    }
231
232    #[test]
233    fn find_repo_config_in_parent_dir() {
234        let dir = TempDir::new().expect("tempdir");
235        let ripvec_dir = dir.path().join(".ripvec");
236        RepoConfig::new("BAAI/bge-small-en-v1.5", "3")
237            .save(&ripvec_dir)
238            .expect("save");
239        let subdir = dir.path().join("src").join("foo");
240        std::fs::create_dir_all(&subdir).expect("mkdir");
241        let found = find_repo_config(&subdir);
242        assert_eq!(found.as_deref(), Some(ripvec_dir.as_path()));
243    }
244
245    #[test]
246    fn find_repo_config_not_found() {
247        let dir = TempDir::new().expect("tempdir");
248        // No .ripvec directory — should return None.
249        assert!(find_repo_config(dir.path()).is_none());
250    }
251
252    #[test]
253    fn find_repo_config_ignores_disabled() {
254        let dir = TempDir::new().expect("tempdir");
255        let ripvec_dir = dir.path().join(".ripvec");
256        // Manually write config with local = false.
257        std::fs::create_dir_all(&ripvec_dir).expect("mkdir");
258        let cfg_str =
259            "[cache]\nlocal = false\nmodel = \"BAAI/bge-small-en-v1.5\"\nversion = \"3\"\n";
260        std::fs::write(ripvec_dir.join("config.toml"), cfg_str).expect("write");
261        assert!(find_repo_config(dir.path()).is_none());
262    }
263}