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
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 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 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 #[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 #[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); assert_eq!(config.undo.max_entries, 100); }
181
182 #[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 #[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}