ferrous_forge/
config.rs

1//! Configuration management for Ferrous Forge
2
3use crate::{Error, Result};
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)
86            .await
87            .map_err(|e| Error::config(format!("Failed to read config file: {}", e)))?;
88
89        let config: Config = toml::from_str(&contents)
90            .map_err(|e| Error::config(format!("Failed to parse config file: {}", e)))?;
91
92        Ok(config)
93    }
94
95    /// Save configuration to file
96    pub async fn save(&self) -> Result<()> {
97        let config_path = Self::config_file_path()?;
98
99        // Ensure parent directory exists
100        if let Some(parent) = config_path.parent() {
101            fs::create_dir_all(parent).await?;
102        }
103
104        let contents = toml::to_string_pretty(self)
105            .map_err(|e| Error::config(format!("Failed to serialize config: {}", e)))?;
106
107        fs::write(&config_path, contents)
108            .await
109            .map_err(|e| Error::config(format!("Failed to write config file: {}", e)))?;
110
111        Ok(())
112    }
113
114    /// Get the path to the configuration file
115    pub fn config_file_path() -> Result<PathBuf> {
116        let config_dir =
117            dirs::config_dir().ok_or_else(|| Error::config("Could not find config directory"))?;
118
119        Ok(config_dir.join("ferrous-forge").join("config.toml"))
120    }
121
122    /// Get the path to the configuration directory
123    pub fn config_dir_path() -> Result<PathBuf> {
124        let config_dir =
125            dirs::config_dir().ok_or_else(|| Error::config("Could not find config directory"))?;
126
127        Ok(config_dir.join("ferrous-forge"))
128    }
129
130    /// Ensure configuration directories exist
131    pub async fn ensure_directories(&self) -> Result<()> {
132        let config_dir = Self::config_dir_path()?;
133        fs::create_dir_all(&config_dir).await?;
134
135        // Create subdirectories
136        fs::create_dir_all(config_dir.join("templates")).await?;
137        fs::create_dir_all(config_dir.join("rules")).await?;
138        fs::create_dir_all(config_dir.join("backups")).await?;
139
140        Ok(())
141    }
142
143    /// Check if Ferrous Forge is initialized
144    pub fn is_initialized(&self) -> bool {
145        self.initialized
146    }
147
148    /// Mark Ferrous Forge as initialized
149    pub fn mark_initialized(&mut self) {
150        self.initialized = true;
151    }
152
153    /// Get a configuration value by key
154    pub fn get(&self, key: &str) -> Option<String> {
155        match key {
156            "update_channel" => Some(self.update_channel.clone()),
157            "auto_update" => Some(self.auto_update.to_string()),
158            "max_file_lines" => Some(self.max_file_lines.to_string()),
159            "max_function_lines" => Some(self.max_function_lines.to_string()),
160            "enforce_edition_2024" => Some(self.enforce_edition_2024.to_string()),
161            "ban_underscore_bandaid" => Some(self.ban_underscore_bandaid.to_string()),
162            "require_documentation" => Some(self.require_documentation.to_string()),
163            _ => None,
164        }
165    }
166
167    /// Set a configuration value by key
168    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
169        match key {
170            "update_channel" => {
171                if !["stable", "beta", "nightly"].contains(&value) {
172                    return Err(Error::config(
173                        "Invalid update channel. Must be: stable, beta, or nightly",
174                    ));
175                }
176                self.update_channel = value.to_string();
177            }
178            "auto_update" => {
179                self.auto_update = value
180                    .parse()
181                    .map_err(|_| Error::config("Invalid boolean value for auto_update"))?;
182            }
183            "max_file_lines" => {
184                self.max_file_lines = value
185                    .parse()
186                    .map_err(|_| Error::config("Invalid number for max_file_lines"))?;
187            }
188            "max_function_lines" => {
189                self.max_function_lines = value
190                    .parse()
191                    .map_err(|_| Error::config("Invalid number for max_function_lines"))?;
192            }
193            "enforce_edition_2024" => {
194                self.enforce_edition_2024 = value
195                    .parse()
196                    .map_err(|_| Error::config("Invalid boolean value for enforce_edition_2024"))?;
197            }
198            "ban_underscore_bandaid" => {
199                self.ban_underscore_bandaid = value.parse().map_err(|_| {
200                    Error::config("Invalid boolean value for ban_underscore_bandaid")
201                })?;
202            }
203            "require_documentation" => {
204                self.require_documentation = value.parse().map_err(|_| {
205                    Error::config("Invalid boolean value for require_documentation")
206                })?;
207            }
208            _ => return Err(Error::config(format!("Unknown configuration key: {}", key))),
209        }
210
211        Ok(())
212    }
213
214    /// List all configuration keys and values
215    pub fn list(&self) -> Vec<(String, String)> {
216        vec![
217            ("update_channel".to_string(), self.update_channel.clone()),
218            ("auto_update".to_string(), self.auto_update.to_string()),
219            (
220                "max_file_lines".to_string(),
221                self.max_file_lines.to_string(),
222            ),
223            (
224                "max_function_lines".to_string(),
225                self.max_function_lines.to_string(),
226            ),
227            (
228                "enforce_edition_2024".to_string(),
229                self.enforce_edition_2024.to_string(),
230            ),
231            (
232                "ban_underscore_bandaid".to_string(),
233                self.ban_underscore_bandaid.to_string(),
234            ),
235            (
236                "require_documentation".to_string(),
237                self.require_documentation.to_string(),
238            ),
239        ]
240    }
241
242    /// Reset configuration to defaults
243    pub fn reset(&mut self) {
244        *self = Self::default();
245        self.initialized = true; // Keep initialized state
246    }
247}
248
249#[cfg(test)]
250#[allow(clippy::expect_used, clippy::unwrap_used)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_config_default() {
256        let config = Config::default();
257
258        assert!(!config.initialized);
259        assert_eq!(config.version, "0.1.0");
260        assert_eq!(config.update_channel, "stable");
261        assert!(config.auto_update);
262        assert_eq!(config.max_file_lines, 300);
263        assert_eq!(config.max_function_lines, 50);
264        assert!(config.enforce_edition_2024);
265        assert!(config.ban_underscore_bandaid);
266        assert!(config.require_documentation);
267        assert!(!config.clippy_rules.is_empty());
268        assert!(config.custom_rules.is_empty());
269    }
270
271    #[test]
272    fn test_config_initialization() {
273        let mut config = Config::default();
274
275        assert!(!config.is_initialized());
276        config.mark_initialized();
277        assert!(config.is_initialized());
278    }
279
280    #[test]
281    fn test_config_get() {
282        let config = Config::default();
283
284        assert_eq!(config.get("update_channel"), Some("stable".to_string()));
285        assert_eq!(config.get("auto_update"), Some("true".to_string()));
286        assert_eq!(config.get("max_file_lines"), Some("300".to_string()));
287        assert_eq!(config.get("max_function_lines"), Some("50".to_string()));
288        assert_eq!(config.get("enforce_edition_2024"), Some("true".to_string()));
289        assert_eq!(
290            config.get("ban_underscore_bandaid"),
291            Some("true".to_string())
292        );
293        assert_eq!(
294            config.get("require_documentation"),
295            Some("true".to_string())
296        );
297        assert_eq!(config.get("nonexistent"), None);
298    }
299
300    #[test]
301    fn test_config_set_update_channel() {
302        let mut config = Config::default();
303
304        // Valid channels
305        assert!(config.set("update_channel", "stable").is_ok());
306        assert_eq!(config.update_channel, "stable");
307
308        assert!(config.set("update_channel", "beta").is_ok());
309        assert_eq!(config.update_channel, "beta");
310
311        assert!(config.set("update_channel", "nightly").is_ok());
312        assert_eq!(config.update_channel, "nightly");
313
314        // Invalid channel
315        assert!(config.set("update_channel", "invalid").is_err());
316    }
317
318    #[test]
319    fn test_config_set_boolean_values() {
320        let mut config = Config::default();
321
322        // Test auto_update
323        assert!(config.set("auto_update", "false").is_ok());
324        assert!(!config.auto_update);
325        assert!(config.set("auto_update", "true").is_ok());
326        assert!(config.auto_update);
327        assert!(config.set("auto_update", "invalid").is_err());
328
329        // Test enforce_edition_2024
330        assert!(config.set("enforce_edition_2024", "false").is_ok());
331        assert!(!config.enforce_edition_2024);
332
333        // Test ban_underscore_bandaid
334        assert!(config.set("ban_underscore_bandaid", "false").is_ok());
335        assert!(!config.ban_underscore_bandaid);
336
337        // Test require_documentation
338        assert!(config.set("require_documentation", "false").is_ok());
339        assert!(!config.require_documentation);
340    }
341
342    #[test]
343    fn test_config_set_numeric_values() {
344        let mut config = Config::default();
345
346        // Test max_file_lines
347        assert!(config.set("max_file_lines", "500").is_ok());
348        assert_eq!(config.max_file_lines, 500);
349        assert!(config.set("max_file_lines", "invalid").is_err());
350
351        // Test max_function_lines
352        assert!(config.set("max_function_lines", "100").is_ok());
353        assert_eq!(config.max_function_lines, 100);
354        assert!(config.set("max_function_lines", "invalid").is_err());
355    }
356
357    #[test]
358    fn test_config_set_unknown_key() {
359        let mut config = Config::default();
360        assert!(config.set("unknown_key", "value").is_err());
361    }
362
363    #[test]
364    fn test_config_list() {
365        let config = Config::default();
366        let list = config.list();
367
368        assert_eq!(list.len(), 7);
369        assert!(list
370            .iter()
371            .any(|(k, v)| k == "update_channel" && v == "stable"));
372        assert!(list.iter().any(|(k, v)| k == "auto_update" && v == "true"));
373        assert!(list
374            .iter()
375            .any(|(k, v)| k == "max_file_lines" && v == "300"));
376        assert!(list
377            .iter()
378            .any(|(k, v)| k == "max_function_lines" && v == "50"));
379    }
380
381    #[test]
382    fn test_config_reset() {
383        let mut config = Config::default();
384        config.mark_initialized();
385        config.update_channel = "beta".to_string();
386        config.auto_update = false;
387
388        config.reset();
389
390        assert!(config.is_initialized()); // Should keep initialized state
391        assert_eq!(config.update_channel, "stable"); // Should reset to default
392        assert!(config.auto_update); // Should reset to default
393    }
394
395    #[test]
396    fn test_custom_rule() {
397        let rule = CustomRule {
398            name: "test_rule".to_string(),
399            pattern: r"test_.*".to_string(),
400            message: "Test message".to_string(),
401            enabled: true,
402        };
403
404        assert_eq!(rule.name, "test_rule");
405        assert_eq!(rule.pattern, r"test_.*");
406        assert_eq!(rule.message, "Test message");
407        assert!(rule.enabled);
408    }
409
410    // Note: Tests that would require environment variable manipulation are excluded
411    // because this crate forbids unsafe code. In practice, the save/load functionality
412    // would be tested in integration tests where unsafe code restrictions don't apply.
413
414    #[test]
415    fn test_config_file_path() {
416        let result = Config::config_file_path();
417        assert!(result.is_ok());
418        let path = result.expect("Should get config file path");
419        assert!(path.to_string_lossy().contains("ferrous-forge"));
420        assert!(path.to_string_lossy().ends_with("config.toml"));
421    }
422
423    #[test]
424    fn test_config_dir_path() {
425        let result = Config::config_dir_path();
426        assert!(result.is_ok());
427        let path = result.expect("Should get config dir path");
428        assert!(path.to_string_lossy().contains("ferrous-forge"));
429    }
430
431    // Property-based tests using proptest
432    #[cfg(test)]
433    mod property_tests {
434        use super::*;
435        use proptest::prelude::*;
436
437        proptest! {
438            #[test]
439            fn test_config_get_set_roundtrip(
440                channel in prop::sample::select(vec!["stable", "beta", "nightly"]),
441                auto_update in any::<bool>(),
442                max_file_lines in 1usize..10000,
443                max_function_lines in 1usize..1000,
444            ) {
445                let mut config = Config::default();
446
447                prop_assert!(config.set("update_channel", channel).is_ok());
448                prop_assert_eq!(config.get("update_channel"), Some(channel.to_string()));
449
450                prop_assert!(config.set("auto_update", &auto_update.to_string()).is_ok());
451                prop_assert_eq!(config.get("auto_update"), Some(auto_update.to_string()));
452
453                prop_assert!(config.set("max_file_lines", &max_file_lines.to_string()).is_ok());
454                prop_assert_eq!(config.get("max_file_lines"), Some(max_file_lines.to_string()));
455
456                prop_assert!(config.set("max_function_lines", &max_function_lines.to_string()).is_ok());
457                prop_assert_eq!(config.get("max_function_lines"), Some(max_function_lines.to_string()));
458            }
459        }
460    }
461}