Skip to main content

ripsed_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4/// Project-level configuration from `.ripsed.toml`.
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct Config {
7    #[serde(default)]
8    pub defaults: DefaultsConfig,
9    #[serde(default)]
10    pub agent: AgentConfig,
11    #[serde(default)]
12    pub ignore: IgnoreConfig,
13    #[serde(default)]
14    pub undo: UndoConfig,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DefaultsConfig {
19    #[serde(default)]
20    pub backup: bool,
21    #[serde(default = "default_true")]
22    pub gitignore: bool,
23    pub max_depth: Option<usize>,
24}
25
26impl Default for DefaultsConfig {
27    fn default() -> Self {
28        Self {
29            backup: false,
30            gitignore: true,
31            max_depth: None,
32        }
33    }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AgentConfig {
38    #[serde(default = "default_true")]
39    pub dry_run: bool,
40    #[serde(default = "default_context_lines")]
41    pub context_lines: usize,
42}
43
44impl Default for AgentConfig {
45    fn default() -> Self {
46        Self {
47            dry_run: true,
48            context_lines: 3,
49        }
50    }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54pub struct IgnoreConfig {
55    #[serde(default)]
56    pub patterns: Vec<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct UndoConfig {
61    #[serde(default = "default_max_entries")]
62    pub max_entries: usize,
63}
64
65impl Default for UndoConfig {
66    fn default() -> Self {
67        Self { max_entries: 100 }
68    }
69}
70
71fn default_true() -> bool {
72    true
73}
74
75fn default_context_lines() -> usize {
76    3
77}
78
79fn default_max_entries() -> usize {
80    100
81}
82
83impl Config {
84    /// Load configuration by walking up from `start_dir` looking for `.ripsed.toml`.
85    pub fn discover(start_dir: &Path) -> Option<(PathBuf, Config)> {
86        let mut dir = start_dir.to_path_buf();
87        loop {
88            let config_path = dir.join(".ripsed.toml");
89            if config_path.exists() {
90                match std::fs::read_to_string(&config_path) {
91                    Ok(content) => match toml::from_str::<Config>(&content) {
92                        Ok(config) => return Some((config_path, config)),
93                        Err(_) => return None,
94                    },
95                    Err(_) => return None,
96                }
97            }
98            if !dir.pop() {
99                break;
100            }
101        }
102        None
103    }
104
105    /// Load from a specific path.
106    pub fn load(path: &Path) -> Result<Config, String> {
107        let content = std::fs::read_to_string(path)
108            .map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
109        toml::from_str(&content).map_err(|e| format!("Invalid TOML in {}: {e}", path.display()))
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::fs;
117    use tempfile::TempDir;
118
119    // ── Default values ──
120
121    #[test]
122    fn config_default_has_expected_values() {
123        let config = Config::default();
124        assert!(!config.defaults.backup);
125        assert!(config.defaults.gitignore);
126        assert!(config.defaults.max_depth.is_none());
127        assert!(config.agent.dry_run);
128        assert_eq!(config.agent.context_lines, 3);
129        assert!(config.ignore.patterns.is_empty());
130        assert_eq!(config.undo.max_entries, 100);
131    }
132
133    // ── TOML parsing ──
134
135    #[test]
136    fn parse_full_config() {
137        let toml = r#"
138[defaults]
139backup = true
140gitignore = false
141max_depth = 5
142
143[agent]
144dry_run = false
145context_lines = 5
146
147[ignore]
148patterns = ["*.log", "target/"]
149
150[undo]
151max_entries = 50
152"#;
153        let config: Config = toml::from_str(toml).unwrap();
154        assert!(config.defaults.backup);
155        assert!(!config.defaults.gitignore);
156        assert_eq!(config.defaults.max_depth, Some(5));
157        assert!(!config.agent.dry_run);
158        assert_eq!(config.agent.context_lines, 5);
159        assert_eq!(config.ignore.patterns, vec!["*.log", "target/"]);
160        assert_eq!(config.undo.max_entries, 50);
161    }
162
163    #[test]
164    fn parse_empty_toml_uses_defaults() {
165        let config: Config = toml::from_str("").unwrap();
166        assert!(config.defaults.gitignore);
167        assert!(config.agent.dry_run);
168        assert_eq!(config.undo.max_entries, 100);
169    }
170
171    #[test]
172    fn parse_partial_config_fills_defaults() {
173        let toml = r#"
174[defaults]
175backup = true
176"#;
177        let config: Config = toml::from_str(toml).unwrap();
178        assert!(config.defaults.backup);
179        assert!(config.defaults.gitignore); // default preserved
180        assert_eq!(config.undo.max_entries, 100); // default preserved
181    }
182
183    // ── File discovery ──
184
185    #[test]
186    fn discover_finds_config_in_current_dir() {
187        let dir = TempDir::new().unwrap();
188        let config_path = dir.path().join(".ripsed.toml");
189        fs::write(&config_path, "[defaults]\nbackup = true\n").unwrap();
190
191        let (found_path, config) = Config::discover(dir.path()).unwrap();
192        assert_eq!(found_path, config_path);
193        assert!(config.defaults.backup);
194    }
195
196    #[test]
197    fn discover_walks_up_to_parent() {
198        let dir = TempDir::new().unwrap();
199        let child = dir.path().join("sub/deep");
200        fs::create_dir_all(&child).unwrap();
201        fs::write(
202            dir.path().join(".ripsed.toml"),
203            "[undo]\nmax_entries = 42\n",
204        )
205        .unwrap();
206
207        let (_, config) = Config::discover(&child).unwrap();
208        assert_eq!(config.undo.max_entries, 42);
209    }
210
211    #[test]
212    fn discover_returns_none_when_not_found() {
213        let dir = TempDir::new().unwrap();
214        assert!(Config::discover(dir.path()).is_none());
215    }
216
217    #[test]
218    fn discover_returns_none_for_invalid_toml() {
219        let dir = TempDir::new().unwrap();
220        fs::write(dir.path().join(".ripsed.toml"), "not [valid toml!!!").unwrap();
221        assert!(Config::discover(dir.path()).is_none());
222    }
223
224    // ── Config::load ──
225
226    #[test]
227    fn load_reads_valid_config() {
228        let dir = TempDir::new().unwrap();
229        let path = dir.path().join("config.toml");
230        fs::write(&path, "[agent]\ndry_run = false\n").unwrap();
231
232        let config = Config::load(&path).unwrap();
233        assert!(!config.agent.dry_run);
234    }
235
236    #[test]
237    fn load_returns_error_for_missing_file() {
238        let result = Config::load(Path::new("/nonexistent/config.toml"));
239        assert!(result.is_err());
240        assert!(result.unwrap_err().contains("Cannot read"));
241    }
242
243    #[test]
244    fn load_returns_error_for_invalid_toml() {
245        let dir = TempDir::new().unwrap();
246        let path = dir.path().join("bad.toml");
247        fs::write(&path, "{{{{not valid").unwrap();
248
249        let result = Config::load(&path);
250        assert!(result.is_err());
251        assert!(result.unwrap_err().contains("Invalid TOML"));
252    }
253}