Skip to main content

ferrous_forge/config/hierarchy/
partial.rs

1//! Partial configuration for hierarchical merging
2
3use super::ConfigLevel;
4use crate::config::{Config, CustomRule};
5use crate::{Error, Result};
6use serde::{Deserialize, Serialize};
7use tokio::fs;
8use tracing::debug;
9
10/// Partial configuration that allows optional fields for merging
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct PartialConfig {
13    /// Whether Ferrous Forge has been initialized
14    pub initialized: Option<bool>,
15    /// Version of the configuration format
16    pub version: Option<String>,
17    /// Update channel (stable, beta, nightly)
18    pub update_channel: Option<String>,
19    /// Whether to automatically check for updates
20    pub auto_update: Option<bool>,
21    /// Custom clippy rules
22    pub clippy_rules: Option<Vec<String>>,
23    /// File size limit in lines
24    pub max_file_lines: Option<usize>,
25    /// Function size limit in lines
26    pub max_function_lines: Option<usize>,
27    /// Required Rust edition (locked)
28    pub required_edition: Option<String>,
29    /// Required minimum rust-version (locked)
30    pub required_rust_version: Option<String>,
31    /// Whether to ban underscore bandaid patterns
32    pub ban_underscore_bandaid: Option<bool>,
33    /// Whether to require documentation
34    pub require_documentation: Option<bool>,
35    /// Custom validation rules
36    pub custom_rules: Option<Vec<CustomRule>>,
37}
38
39impl PartialConfig {
40    /// Load partial config from a specific level
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if reading or parsing the config file fails.
45    pub async fn load_from_level(level: ConfigLevel) -> Result<Option<Self>> {
46        let path = level.path()?;
47
48        if !path.exists() {
49            debug!(
50                "No config found at {} level: {}",
51                level.display_name(),
52                path.display()
53            );
54            return Ok(None);
55        }
56
57        let contents = fs::read_to_string(&path).await.map_err(|e| {
58            Error::config(format!(
59                "Failed to read {} config: {}",
60                level.display_name(),
61                e
62            ))
63        })?;
64
65        let partial: PartialConfig = toml::from_str(&contents).map_err(|e| {
66            Error::config(format!(
67                "Failed to parse {} config: {}",
68                level.display_name(),
69                e
70            ))
71        })?;
72
73        tracing::info!(
74            "Loaded {} configuration from {}",
75            level.display_name(),
76            path.display()
77        );
78        Ok(Some(partial))
79    }
80
81    /// Merge another partial config into this one (other takes precedence)
82    pub fn merge(mut self, other: PartialConfig) -> Self {
83        if other.initialized.is_some() {
84            self.initialized = other.initialized;
85        }
86        if other.version.is_some() {
87            self.version = other.version;
88        }
89        if other.update_channel.is_some() {
90            self.update_channel = other.update_channel;
91        }
92        if other.auto_update.is_some() {
93            self.auto_update = other.auto_update;
94        }
95        if other.clippy_rules.is_some() {
96            self.clippy_rules = other.clippy_rules;
97        }
98        if other.max_file_lines.is_some() {
99            self.max_file_lines = other.max_file_lines;
100        }
101        if other.max_function_lines.is_some() {
102            self.max_function_lines = other.max_function_lines;
103        }
104        if other.required_edition.is_some() {
105            self.required_edition = other.required_edition;
106        }
107        if other.required_rust_version.is_some() {
108            self.required_rust_version = other.required_rust_version;
109        }
110        if other.ban_underscore_bandaid.is_some() {
111            self.ban_underscore_bandaid = other.ban_underscore_bandaid;
112        }
113        if other.require_documentation.is_some() {
114            self.require_documentation = other.require_documentation;
115        }
116        if other.custom_rules.is_some() {
117            self.custom_rules = other.custom_rules;
118        }
119        self
120    }
121
122    /// Convert to full config, using defaults for missing values
123    pub fn to_full_config(self) -> Config {
124        let default = Config::default();
125        Config {
126            initialized: self.initialized.unwrap_or(default.initialized),
127            version: self.version.unwrap_or(default.version),
128            update_channel: self.update_channel.unwrap_or(default.update_channel),
129            auto_update: self.auto_update.unwrap_or(default.auto_update),
130            clippy_rules: self.clippy_rules.unwrap_or(default.clippy_rules),
131            max_file_lines: self.max_file_lines.unwrap_or(default.max_file_lines),
132            max_function_lines: self
133                .max_function_lines
134                .unwrap_or(default.max_function_lines),
135            required_edition: self.required_edition.unwrap_or(default.required_edition),
136            required_rust_version: self
137                .required_rust_version
138                .unwrap_or(default.required_rust_version),
139            ban_underscore_bandaid: self
140                .ban_underscore_bandaid
141                .unwrap_or(default.ban_underscore_bandaid),
142            require_documentation: self
143                .require_documentation
144                .unwrap_or(default.require_documentation),
145            custom_rules: self.custom_rules.unwrap_or(default.custom_rules),
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_partial_config_merge() {
156        let base = PartialConfig {
157            max_file_lines: Some(300),
158            max_function_lines: Some(50),
159            ..Default::default()
160        };
161
162        let override_config = PartialConfig {
163            max_file_lines: Some(400),
164            required_edition: Some("2021".to_string()),
165            ..Default::default()
166        };
167
168        let merged = base.merge(override_config);
169        assert_eq!(merged.max_file_lines, Some(400));
170        assert_eq!(merged.max_function_lines, Some(50));
171        assert_eq!(merged.required_edition, Some("2021".to_string()));
172    }
173
174    #[test]
175    fn test_partial_to_full_config() {
176        let partial = PartialConfig {
177            max_file_lines: Some(500),
178            ..Default::default()
179        };
180
181        let full = partial.to_full_config();
182        assert_eq!(full.max_file_lines, 500);
183        assert_eq!(full.max_function_lines, 50); // Default value
184    }
185}