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    /// Files at least this large stream straight to the output temp file
25    /// in constant memory instead of being buffered (only when no undo
26    /// entry will be recorded). Buffering is faster (whole-buffer fast
27    /// paths apply), so this stays high — it exists so files larger than
28    /// RAM are editable at all. `0` disables streaming.
29    #[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    /// Files larger than this (decoded bytes) get no undo entry — the undo
76    /// log stores a full copy of the original text, which dominates the
77    /// cost of editing very large files. `0` disables the cap.
78    #[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    // Generous for source files (rarely above a few hundred KB) while
103    // keeping huge-file edits from paying a full-copy serialization.
104    4 * 1024 * 1024
105}
106
107impl Config {
108    /// Load configuration by walking up from `start_dir` looking for `.ripsed.toml`.
109    ///
110    /// Returns `Ok(None)` if no config file is found.
111    /// Returns `Err` if a config file is found but cannot be read or parsed.
112    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    /// Load from a specific path.
131    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    // ── Default values ──
145
146    #[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        // Omitted -> default cap; 0 -> unlimited.
164        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    // ── TOML parsing ──
171
172    #[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); // default preserved
217        assert_eq!(config.undo.max_entries, 100); // default preserved
218    }
219
220    // ── File discovery ──
221
222    #[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    // ── Config::load ──
264
265    #[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}