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