1use crate::{Result, Error};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use tokio::fs;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Config {
11 pub initialized: bool,
13 pub version: String,
15 pub update_channel: String,
17 pub auto_update: bool,
19 pub clippy_rules: Vec<String>,
21 pub max_file_lines: usize,
23 pub max_function_lines: usize,
25 pub enforce_edition_2024: bool,
27 pub ban_underscore_bandaid: bool,
29 pub require_documentation: bool,
31 pub custom_rules: Vec<CustomRule>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CustomRule {
38 pub name: String,
40 pub pattern: String,
42 pub message: String,
44 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 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 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 pub async fn save(&self) -> Result<()> {
96 let config_path = Self::config_file_path()?;
97
98 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 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 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 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 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 pub fn is_initialized(&self) -> bool {
143 self.initialized
144 }
145
146 pub fn mark_initialized(&mut self) {
148 self.initialized = true;
149 }
150
151 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 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 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 pub fn reset(&mut self) {
219 *self = Self::default();
220 self.initialized = true; }
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 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 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 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 assert!(config.set("enforce_edition_2024", "false").is_ok());
299 assert!(!config.enforce_edition_2024);
300
301 assert!(config.set("ban_underscore_bandaid", "false").is_ok());
303 assert!(!config.ban_underscore_bandaid);
304
305 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 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 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()); assert_eq!(config.update_channel, "stable"); assert!(config.auto_update); }
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 #[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 #[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}