1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4#[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 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 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 #[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 #[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); assert_eq!(config.undo.max_entries, 100); }
182
183 #[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 #[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}