ferrous_forge/
config.rs

1//! Configuration management for Ferrous Forge
2
3use crate::{Result, Error};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use tokio::fs;
7
8/// Ferrous Forge configuration
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Config {
11    /// Whether Ferrous Forge has been initialized
12    pub initialized: bool,
13    /// Version of the configuration format
14    pub version: String,
15    /// Update channel (stable, beta, nightly)
16    pub update_channel: String,
17    /// Whether to automatically check for updates
18    pub auto_update: bool,
19    /// Custom clippy rules
20    pub clippy_rules: Vec<String>,
21    /// File size limit in lines
22    pub max_file_lines: usize,
23    /// Function size limit in lines
24    pub max_function_lines: usize,
25    /// Whether to enforce Edition 2024
26    pub enforce_edition_2024: bool,
27    /// Whether to ban underscore bandaid patterns
28    pub ban_underscore_bandaid: bool,
29    /// Whether to require documentation
30    pub require_documentation: bool,
31    /// Custom validation rules
32    pub custom_rules: Vec<CustomRule>,
33}
34
35/// Custom validation rule
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CustomRule {
38    /// Name of the rule
39    pub name: String,
40    /// Pattern to match (regex)
41    pub pattern: String,
42    /// Error message to display
43    pub message: String,
44    /// Whether this rule is enabled
45    pub enabled: bool,
46}
47
48impl Default for Config {
49    fn default() -> Self {
50        Self {
51            initialized: false,
52            version: "0.1.0".to_string(),
53            update_channel: "stable".to_string(),
54            auto_update: true,
55            clippy_rules: vec![
56                "-D warnings".to_string(),
57                "-D clippy::unwrap_used".to_string(),
58                "-D clippy::expect_used".to_string(),
59                "-D clippy::panic".to_string(),
60                "-D clippy::unimplemented".to_string(),
61                "-D clippy::todo".to_string(),
62            ],
63            max_file_lines: 300,
64            max_function_lines: 50,
65            enforce_edition_2024: true,
66            ban_underscore_bandaid: true,
67            require_documentation: true,
68            custom_rules: vec![],
69        }
70    }
71}
72
73impl Config {
74    /// Load configuration from file, or return default if not found
75    pub async fn load_or_default() -> Result<Self> {
76        match Self::load().await {
77            Ok(config) => Ok(config),
78            Err(_) => Ok(Self::default()),
79        }
80    }
81
82    /// Load configuration from file
83    pub async fn load() -> Result<Self> {
84        let config_path = Self::config_file_path()?;
85        let contents = fs::read_to_string(&config_path).await
86            .map_err(|e| Error::config(format!("Failed to read config file: {}", e)))?;
87        
88        let config: Config = toml::from_str(&contents)
89            .map_err(|e| Error::config(format!("Failed to parse config file: {}", e)))?;
90        
91        Ok(config)
92    }
93
94    /// Save configuration to file
95    pub async fn save(&self) -> Result<()> {
96        let config_path = Self::config_file_path()?;
97        
98        // Ensure parent directory exists
99        if let Some(parent) = config_path.parent() {
100            fs::create_dir_all(parent).await?;
101        }
102        
103        let contents = toml::to_string_pretty(self)
104            .map_err(|e| Error::config(format!("Failed to serialize config: {}", e)))?;
105        
106        fs::write(&config_path, contents).await
107            .map_err(|e| Error::config(format!("Failed to write config file: {}", e)))?;
108        
109        Ok(())
110    }
111
112    /// Get the path to the configuration file
113    pub fn config_file_path() -> Result<PathBuf> {
114        let config_dir = dirs::config_dir()
115            .ok_or_else(|| Error::config("Could not find config directory"))?;
116        
117        Ok(config_dir.join("ferrous-forge").join("config.toml"))
118    }
119
120    /// Get the path to the configuration directory
121    pub fn config_dir_path() -> Result<PathBuf> {
122        let config_dir = dirs::config_dir()
123            .ok_or_else(|| Error::config("Could not find config directory"))?;
124        
125        Ok(config_dir.join("ferrous-forge"))
126    }
127
128    /// Ensure configuration directories exist
129    pub async fn ensure_directories(&self) -> Result<()> {
130        let config_dir = Self::config_dir_path()?;
131        fs::create_dir_all(&config_dir).await?;
132        
133        // Create subdirectories
134        fs::create_dir_all(config_dir.join("templates")).await?;
135        fs::create_dir_all(config_dir.join("rules")).await?;
136        fs::create_dir_all(config_dir.join("backups")).await?;
137        
138        Ok(())
139    }
140
141    /// Check if Ferrous Forge is initialized
142    pub fn is_initialized(&self) -> bool {
143        self.initialized
144    }
145
146    /// Mark Ferrous Forge as initialized
147    pub fn mark_initialized(&mut self) {
148        self.initialized = true;
149    }
150
151    /// Get a configuration value by key
152    pub fn get(&self, key: &str) -> Option<String> {
153        match key {
154            "update_channel" => Some(self.update_channel.clone()),
155            "auto_update" => Some(self.auto_update.to_string()),
156            "max_file_lines" => Some(self.max_file_lines.to_string()),
157            "max_function_lines" => Some(self.max_function_lines.to_string()),
158            "enforce_edition_2024" => Some(self.enforce_edition_2024.to_string()),
159            "ban_underscore_bandaid" => Some(self.ban_underscore_bandaid.to_string()),
160            "require_documentation" => Some(self.require_documentation.to_string()),
161            _ => None,
162        }
163    }
164
165    /// Set a configuration value by key
166    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
167        match key {
168            "update_channel" => {
169                if !["stable", "beta", "nightly"].contains(&value) {
170                    return Err(Error::config("Invalid update channel. Must be: stable, beta, or nightly"));
171                }
172                self.update_channel = value.to_string();
173            }
174            "auto_update" => {
175                self.auto_update = value.parse()
176                    .map_err(|_| Error::config("Invalid boolean value for auto_update"))?;
177            }
178            "max_file_lines" => {
179                self.max_file_lines = value.parse()
180                    .map_err(|_| Error::config("Invalid number for max_file_lines"))?;
181            }
182            "max_function_lines" => {
183                self.max_function_lines = value.parse()
184                    .map_err(|_| Error::config("Invalid number for max_function_lines"))?;
185            }
186            "enforce_edition_2024" => {
187                self.enforce_edition_2024 = value.parse()
188                    .map_err(|_| Error::config("Invalid boolean value for enforce_edition_2024"))?;
189            }
190            "ban_underscore_bandaid" => {
191                self.ban_underscore_bandaid = value.parse()
192                    .map_err(|_| Error::config("Invalid boolean value for ban_underscore_bandaid"))?;
193            }
194            "require_documentation" => {
195                self.require_documentation = value.parse()
196                    .map_err(|_| Error::config("Invalid boolean value for require_documentation"))?;
197            }
198            _ => return Err(Error::config(format!("Unknown configuration key: {}", key))),
199        }
200        
201        Ok(())
202    }
203
204    /// List all configuration keys and values
205    pub fn list(&self) -> Vec<(String, String)> {
206        vec![
207            ("update_channel".to_string(), self.update_channel.clone()),
208            ("auto_update".to_string(), self.auto_update.to_string()),
209            ("max_file_lines".to_string(), self.max_file_lines.to_string()),
210            ("max_function_lines".to_string(), self.max_function_lines.to_string()),
211            ("enforce_edition_2024".to_string(), self.enforce_edition_2024.to_string()),
212            ("ban_underscore_bandaid".to_string(), self.ban_underscore_bandaid.to_string()),
213            ("require_documentation".to_string(), self.require_documentation.to_string()),
214        ]
215    }
216
217    /// Reset configuration to defaults
218    pub fn reset(&mut self) {
219        *self = Self::default();
220        self.initialized = true; // Keep initialized state
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_config_default() {
230        let config = Config::default();
231        
232        assert!(!config.initialized);
233        assert_eq!(config.version, "0.1.0");
234        assert_eq!(config.update_channel, "stable");
235        assert!(config.auto_update);
236        assert_eq!(config.max_file_lines, 300);
237        assert_eq!(config.max_function_lines, 50);
238        assert!(config.enforce_edition_2024);
239        assert!(config.ban_underscore_bandaid);
240        assert!(config.require_documentation);
241        assert!(!config.clippy_rules.is_empty());
242        assert!(config.custom_rules.is_empty());
243    }
244
245    #[test]
246    fn test_config_initialization() {
247        let mut config = Config::default();
248        
249        assert!(!config.is_initialized());
250        config.mark_initialized();
251        assert!(config.is_initialized());
252    }
253
254    #[test]
255    fn test_config_get() {
256        let config = Config::default();
257        
258        assert_eq!(config.get("update_channel"), Some("stable".to_string()));
259        assert_eq!(config.get("auto_update"), Some("true".to_string()));
260        assert_eq!(config.get("max_file_lines"), Some("300".to_string()));
261        assert_eq!(config.get("max_function_lines"), Some("50".to_string()));
262        assert_eq!(config.get("enforce_edition_2024"), Some("true".to_string()));
263        assert_eq!(config.get("ban_underscore_bandaid"), Some("true".to_string()));
264        assert_eq!(config.get("require_documentation"), Some("true".to_string()));
265        assert_eq!(config.get("nonexistent"), None);
266    }
267
268    #[test]
269    fn test_config_set_update_channel() {
270        let mut config = Config::default();
271        
272        // Valid channels
273        assert!(config.set("update_channel", "stable").is_ok());
274        assert_eq!(config.update_channel, "stable");
275        
276        assert!(config.set("update_channel", "beta").is_ok());
277        assert_eq!(config.update_channel, "beta");
278        
279        assert!(config.set("update_channel", "nightly").is_ok());
280        assert_eq!(config.update_channel, "nightly");
281        
282        // Invalid channel
283        assert!(config.set("update_channel", "invalid").is_err());
284    }
285
286    #[test]
287    fn test_config_set_boolean_values() {
288        let mut config = Config::default();
289        
290        // Test auto_update
291        assert!(config.set("auto_update", "false").is_ok());
292        assert!(!config.auto_update);
293        assert!(config.set("auto_update", "true").is_ok());
294        assert!(config.auto_update);
295        assert!(config.set("auto_update", "invalid").is_err());
296        
297        // Test enforce_edition_2024
298        assert!(config.set("enforce_edition_2024", "false").is_ok());
299        assert!(!config.enforce_edition_2024);
300        
301        // Test ban_underscore_bandaid
302        assert!(config.set("ban_underscore_bandaid", "false").is_ok());
303        assert!(!config.ban_underscore_bandaid);
304        
305        // Test require_documentation
306        assert!(config.set("require_documentation", "false").is_ok());
307        assert!(!config.require_documentation);
308    }
309
310    #[test]
311    fn test_config_set_numeric_values() {
312        let mut config = Config::default();
313        
314        // Test max_file_lines
315        assert!(config.set("max_file_lines", "500").is_ok());
316        assert_eq!(config.max_file_lines, 500);
317        assert!(config.set("max_file_lines", "invalid").is_err());
318        
319        // Test max_function_lines
320        assert!(config.set("max_function_lines", "100").is_ok());
321        assert_eq!(config.max_function_lines, 100);
322        assert!(config.set("max_function_lines", "invalid").is_err());
323    }
324
325    #[test]
326    fn test_config_set_unknown_key() {
327        let mut config = Config::default();
328        assert!(config.set("unknown_key", "value").is_err());
329    }
330
331    #[test]
332    fn test_config_list() {
333        let config = Config::default();
334        let list = config.list();
335        
336        assert_eq!(list.len(), 7);
337        assert!(list.iter().any(|(k, v)| k == "update_channel" && v == "stable"));
338        assert!(list.iter().any(|(k, v)| k == "auto_update" && v == "true"));
339        assert!(list.iter().any(|(k, v)| k == "max_file_lines" && v == "300"));
340        assert!(list.iter().any(|(k, v)| k == "max_function_lines" && v == "50"));
341    }
342
343    #[test]
344    fn test_config_reset() {
345        let mut config = Config::default();
346        config.mark_initialized();
347        config.update_channel = "beta".to_string();
348        config.auto_update = false;
349        
350        config.reset();
351        
352        assert!(config.is_initialized()); // Should keep initialized state
353        assert_eq!(config.update_channel, "stable"); // Should reset to default
354        assert!(config.auto_update); // Should reset to default
355    }
356
357    #[test]
358    fn test_custom_rule() {
359        let rule = CustomRule {
360            name: "test_rule".to_string(),
361            pattern: r"test_.*".to_string(),
362            message: "Test message".to_string(),
363            enabled: true,
364        };
365        
366        assert_eq!(rule.name, "test_rule");
367        assert_eq!(rule.pattern, r"test_.*");
368        assert_eq!(rule.message, "Test message");
369        assert!(rule.enabled);
370    }
371
372    // Note: Tests that would require environment variable manipulation are excluded
373    // because this crate forbids unsafe code. In practice, the save/load functionality
374    // would be tested in integration tests where unsafe code restrictions don't apply.
375
376    #[test]
377    fn test_config_file_path() {
378        let result = Config::config_file_path();
379        assert!(result.is_ok());
380        let path = result.expect("Should get config file path");
381        assert!(path.to_string_lossy().contains("ferrous-forge"));
382        assert!(path.to_string_lossy().ends_with("config.toml"));
383    }
384
385    #[test]
386    fn test_config_dir_path() {
387        let result = Config::config_dir_path();
388        assert!(result.is_ok());
389        let path = result.expect("Should get config dir path");
390        assert!(path.to_string_lossy().contains("ferrous-forge"));
391    }
392
393    // Property-based tests using proptest
394    #[cfg(feature = "proptest")]
395    mod property_tests {
396        use super::*;
397        use proptest::prelude::*;
398
399        proptest! {
400            #[test]
401            fn test_config_get_set_roundtrip(
402                channel in prop::sample::select(vec!["stable", "beta", "nightly"]),
403                auto_update in any::<bool>(),
404                max_file_lines in 1usize..10000,
405                max_function_lines in 1usize..1000,
406            ) {
407                let mut config = Config::default();
408                
409                prop_assert!(config.set("update_channel", &channel).is_ok());
410                prop_assert_eq!(config.get("update_channel"), Some(channel));
411                
412                prop_assert!(config.set("auto_update", &auto_update.to_string()).is_ok());
413                prop_assert_eq!(config.get("auto_update"), Some(auto_update.to_string()));
414                
415                prop_assert!(config.set("max_file_lines", &max_file_lines.to_string()).is_ok());
416                prop_assert_eq!(config.get("max_file_lines"), Some(max_file_lines.to_string()));
417                
418                prop_assert!(config.set("max_function_lines", &max_function_lines.to_string()).is_ok());
419                prop_assert_eq!(config.get("max_function_lines"), Some(max_function_lines.to_string()));
420            }
421        }
422    }
423}