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
71use crate::default_true;
72
73fn default_context_lines() -> usize {
74    3
75}
76
77fn default_max_entries() -> usize {
78    100
79}
80
81impl Config {
82    /// Load configuration by walking up from `start_dir` looking for `.ripsed.toml`.
83    ///
84    /// Returns `Ok(None)` if no config file is found.
85    /// Returns `Err` if a config file is found but cannot be read or parsed.
86    pub fn discover(start_dir: &Path) -> Result<Option<(PathBuf, Config)>, String> {
87        let mut dir = start_dir.to_path_buf();
88        loop {
89            let config_path = dir.join(".ripsed.toml");
90            if config_path.exists() {
91                let content = std::fs::read_to_string(&config_path)
92                    .map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
93                let config = toml::from_str::<Config>(&content)
94                    .map_err(|e| format!("Invalid TOML in {}: {e}", config_path.display()))?;
95                return Ok(Some((config_path, config)));
96            }
97            if !dir.pop() {
98                break;
99            }
100        }
101        Ok(None)
102    }
103
104    /// Load from a specific path.
105    pub fn load(path: &Path) -> Result<Config, String> {
106        let content = std::fs::read_to_string(path)
107            .map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
108        toml::from_str(&content).map_err(|e| format!("Invalid TOML in {}: {e}", path.display()))
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::fs;
116    use tempfile::TempDir;
117
118    // ── Default values ──
119
120    #[test]
121    fn config_default_has_expected_values() {
122        let config = Config::default();
123        assert!(!config.defaults.backup);
124        assert!(config.defaults.gitignore);
125        assert!(config.defaults.max_depth.is_none());
126        assert!(config.agent.dry_run);
127        assert_eq!(config.agent.context_lines, 3);
128        assert!(config.ignore.patterns.is_empty());
129        assert_eq!(config.undo.max_entries, 100);
130    }
131
132    // ── TOML parsing ──
133
134    #[test]
135    fn parse_full_config() {
136        let toml = r#"
137[defaults]
138backup = true
139gitignore = false
140max_depth = 5
141
142[agent]
143dry_run = false
144context_lines = 5
145
146[ignore]
147patterns = ["*.log", "target/"]
148
149[undo]
150max_entries = 50
151"#;
152        let config: Config = toml::from_str(toml).unwrap();
153        assert!(config.defaults.backup);
154        assert!(!config.defaults.gitignore);
155        assert_eq!(config.defaults.max_depth, Some(5));
156        assert!(!config.agent.dry_run);
157        assert_eq!(config.agent.context_lines, 5);
158        assert_eq!(config.ignore.patterns, vec!["*.log", "target/"]);
159        assert_eq!(config.undo.max_entries, 50);
160    }
161
162    #[test]
163    fn parse_empty_toml_uses_defaults() {
164        let config: Config = toml::from_str("").unwrap();
165        assert!(config.defaults.gitignore);
166        assert!(config.agent.dry_run);
167        assert_eq!(config.undo.max_entries, 100);
168    }
169
170    #[test]
171    fn parse_partial_config_fills_defaults() {
172        let toml = r#"
173[defaults]
174backup = true
175"#;
176        let config: Config = toml::from_str(toml).unwrap();
177        assert!(config.defaults.backup);
178        assert!(config.defaults.gitignore); // default preserved
179        assert_eq!(config.undo.max_entries, 100); // default preserved
180    }
181
182    // ── File discovery ──
183
184    #[test]
185    fn discover_finds_config_in_current_dir() {
186        let dir = TempDir::new().unwrap();
187        let config_path = dir.path().join(".ripsed.toml");
188        fs::write(&config_path, "[defaults]\nbackup = true\n").unwrap();
189
190        let (found_path, config) = Config::discover(dir.path()).unwrap().unwrap();
191        assert_eq!(found_path, config_path);
192        assert!(config.defaults.backup);
193    }
194
195    #[test]
196    fn discover_walks_up_to_parent() {
197        let dir = TempDir::new().unwrap();
198        let child = dir.path().join("sub/deep");
199        fs::create_dir_all(&child).unwrap();
200        fs::write(
201            dir.path().join(".ripsed.toml"),
202            "[undo]\nmax_entries = 42\n",
203        )
204        .unwrap();
205
206        let (_, config) = Config::discover(&child).unwrap().unwrap();
207        assert_eq!(config.undo.max_entries, 42);
208    }
209
210    #[test]
211    fn discover_returns_none_when_not_found() {
212        let dir = TempDir::new().unwrap();
213        assert!(Config::discover(dir.path()).unwrap().is_none());
214    }
215
216    #[test]
217    fn discover_returns_error_for_invalid_toml() {
218        let dir = TempDir::new().unwrap();
219        fs::write(dir.path().join(".ripsed.toml"), "not [valid toml!!!").unwrap();
220        let result = Config::discover(dir.path());
221        assert!(result.is_err());
222        assert!(result.unwrap_err().contains("Invalid TOML"));
223    }
224
225    // ── Config::load ──
226
227    #[test]
228    fn load_reads_valid_config() {
229        let dir = TempDir::new().unwrap();
230        let path = dir.path().join("config.toml");
231        fs::write(&path, "[agent]\ndry_run = false\n").unwrap();
232
233        let config = Config::load(&path).unwrap();
234        assert!(!config.agent.dry_run);
235    }
236
237    #[test]
238    fn load_returns_error_for_missing_file() {
239        let result = Config::load(Path::new("/nonexistent/config.toml"));
240        assert!(result.is_err());
241        assert!(result.unwrap_err().contains("Cannot read"));
242    }
243
244    #[test]
245    fn load_returns_error_for_invalid_toml() {
246        let dir = TempDir::new().unwrap();
247        let path = dir.path().join("bad.toml");
248        fs::write(&path, "{{{{not valid").unwrap();
249
250        let result = Config::load(&path);
251        assert!(result.is_err());
252        assert!(result.unwrap_err().contains("Invalid TOML"));
253    }
254}