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 #[serde(default = "default_stream_min_bytes")]
30 pub stream_min_bytes: u64,
31}
32
33impl Default for DefaultsConfig {
34 fn default() -> Self {
35 Self {
36 backup: false,
37 gitignore: true,
38 max_depth: None,
39 stream_min_bytes: default_stream_min_bytes(),
40 }
41 }
42}
43
44fn default_stream_min_bytes() -> u64 {
45 256 * 1024 * 1024
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct AgentConfig {
50 #[serde(default = "default_true")]
51 pub dry_run: bool,
52 #[serde(default = "default_context_lines")]
53 pub context_lines: usize,
54}
55
56impl Default for AgentConfig {
57 fn default() -> Self {
58 Self {
59 dry_run: true,
60 context_lines: 3,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct IgnoreConfig {
67 #[serde(default)]
68 pub patterns: Vec<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct UndoConfig {
73 #[serde(default = "default_max_entries")]
74 pub max_entries: usize,
75 #[serde(default = "default_max_file_bytes")]
79 pub max_file_bytes: u64,
80}
81
82impl Default for UndoConfig {
83 fn default() -> Self {
84 Self {
85 max_entries: 100,
86 max_file_bytes: default_max_file_bytes(),
87 }
88 }
89}
90
91use crate::default_true;
92
93fn default_context_lines() -> usize {
94 3
95}
96
97fn default_max_entries() -> usize {
98 100
99}
100
101fn default_max_file_bytes() -> u64 {
102 4 * 1024 * 1024
105}
106
107impl Config {
108 pub fn discover(start_dir: &Path) -> Result<Option<(PathBuf, Config)>, String> {
113 let mut dir = start_dir.to_path_buf();
114 loop {
115 let config_path = dir.join(".ripsed.toml");
116 if config_path.exists() {
117 let content = std::fs::read_to_string(&config_path)
118 .map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
119 let config = toml::from_str::<Config>(&content)
120 .map_err(|e| format!("Invalid TOML in {}: {e}", config_path.display()))?;
121 return Ok(Some((config_path, config)));
122 }
123 if !dir.pop() {
124 break;
125 }
126 }
127 Ok(None)
128 }
129
130 pub fn load(path: &Path) -> Result<Config, String> {
132 let content = std::fs::read_to_string(path)
133 .map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
134 toml::from_str(&content).map_err(|e| format!("Invalid TOML in {}: {e}", path.display()))
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use std::fs;
142 use tempfile::TempDir;
143
144 #[test]
147 fn config_default_has_expected_values() {
148 let config = Config::default();
149 assert!(!config.defaults.backup);
150 assert!(config.defaults.gitignore);
151 assert!(config.defaults.max_depth.is_none());
152 assert!(config.agent.dry_run);
153 assert_eq!(config.agent.context_lines, 3);
154 assert!(config.ignore.patterns.is_empty());
155 assert_eq!(config.undo.max_entries, 100);
156 assert_eq!(config.undo.max_file_bytes, 4 * 1024 * 1024);
157 }
158
159 #[test]
160 fn parse_undo_max_file_bytes() {
161 let config: Config = toml::from_str("[undo]\nmax_file_bytes = 16\n").unwrap();
162 assert_eq!(config.undo.max_file_bytes, 16);
163 let config: Config = toml::from_str("[undo]\nmax_entries = 5\n").unwrap();
165 assert_eq!(config.undo.max_file_bytes, 4 * 1024 * 1024);
166 let config: Config = toml::from_str("[undo]\nmax_file_bytes = 0\n").unwrap();
167 assert_eq!(config.undo.max_file_bytes, 0);
168 }
169
170 #[test]
173 fn parse_full_config() {
174 let toml = r#"
175[defaults]
176backup = true
177gitignore = false
178max_depth = 5
179
180[agent]
181dry_run = false
182context_lines = 5
183
184[ignore]
185patterns = ["*.log", "target/"]
186
187[undo]
188max_entries = 50
189"#;
190 let config: Config = toml::from_str(toml).unwrap();
191 assert!(config.defaults.backup);
192 assert!(!config.defaults.gitignore);
193 assert_eq!(config.defaults.max_depth, Some(5));
194 assert!(!config.agent.dry_run);
195 assert_eq!(config.agent.context_lines, 5);
196 assert_eq!(config.ignore.patterns, vec!["*.log", "target/"]);
197 assert_eq!(config.undo.max_entries, 50);
198 }
199
200 #[test]
201 fn parse_empty_toml_uses_defaults() {
202 let config: Config = toml::from_str("").unwrap();
203 assert!(config.defaults.gitignore);
204 assert!(config.agent.dry_run);
205 assert_eq!(config.undo.max_entries, 100);
206 }
207
208 #[test]
209 fn parse_partial_config_fills_defaults() {
210 let toml = r#"
211[defaults]
212backup = true
213"#;
214 let config: Config = toml::from_str(toml).unwrap();
215 assert!(config.defaults.backup);
216 assert!(config.defaults.gitignore); assert_eq!(config.undo.max_entries, 100); }
219
220 #[test]
223 fn discover_finds_config_in_current_dir() {
224 let dir = TempDir::new().unwrap();
225 let config_path = dir.path().join(".ripsed.toml");
226 fs::write(&config_path, "[defaults]\nbackup = true\n").unwrap();
227
228 let (found_path, config) = Config::discover(dir.path()).unwrap().unwrap();
229 assert_eq!(found_path, config_path);
230 assert!(config.defaults.backup);
231 }
232
233 #[test]
234 fn discover_walks_up_to_parent() {
235 let dir = TempDir::new().unwrap();
236 let child = dir.path().join("sub/deep");
237 fs::create_dir_all(&child).unwrap();
238 fs::write(
239 dir.path().join(".ripsed.toml"),
240 "[undo]\nmax_entries = 42\n",
241 )
242 .unwrap();
243
244 let (_, config) = Config::discover(&child).unwrap().unwrap();
245 assert_eq!(config.undo.max_entries, 42);
246 }
247
248 #[test]
249 fn discover_returns_none_when_not_found() {
250 let dir = TempDir::new().unwrap();
251 assert!(Config::discover(dir.path()).unwrap().is_none());
252 }
253
254 #[test]
255 fn discover_returns_error_for_invalid_toml() {
256 let dir = TempDir::new().unwrap();
257 fs::write(dir.path().join(".ripsed.toml"), "not [valid toml!!!").unwrap();
258 let result = Config::discover(dir.path());
259 assert!(result.is_err());
260 assert!(result.unwrap_err().contains("Invalid TOML"));
261 }
262
263 #[test]
266 fn load_reads_valid_config() {
267 let dir = TempDir::new().unwrap();
268 let path = dir.path().join("config.toml");
269 fs::write(&path, "[agent]\ndry_run = false\n").unwrap();
270
271 let config = Config::load(&path).unwrap();
272 assert!(!config.agent.dry_run);
273 }
274
275 #[test]
276 fn load_returns_error_for_missing_file() {
277 let result = Config::load(Path::new("/nonexistent/config.toml"));
278 assert!(result.is_err());
279 assert!(result.unwrap_err().contains("Cannot read"));
280 }
281
282 #[test]
283 fn load_returns_error_for_invalid_toml() {
284 let dir = TempDir::new().unwrap();
285 let path = dir.path().join("bad.toml");
286 fs::write(&path, "{{{{not valid").unwrap();
287
288 let result = Config::load(&path);
289 assert!(result.is_err());
290 assert!(result.unwrap_err().contains("Invalid TOML"));
291 }
292}