Skip to main content

ferrous_forge/safety/
config.rs

1//! Safety pipeline configuration
2
3use crate::{Error, Result};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use std::time::Duration;
7use tokio::fs;
8
9use super::{CheckType, PipelineStage};
10
11/// Safety pipeline configuration
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SafetyConfig {
14    /// Whether safety pipeline is enabled
15    pub enabled: bool,
16    /// Strict mode - block operations on failure
17    pub strict_mode: bool,
18    /// Show progress indicators
19    pub show_progress: bool,
20    /// Run checks in parallel when possible
21    pub parallel_checks: bool,
22    /// Pre-commit configuration
23    pub pre_commit: StageConfig,
24    /// Pre-push configuration
25    pub pre_push: StageConfig,
26    /// Publish configuration
27    pub publish: StageConfig,
28    /// Bypass configuration
29    pub bypass: BypassConfig,
30}
31
32/// Configuration for a specific pipeline stage
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct StageConfig {
35    /// Whether this stage is enabled
36    pub enabled: bool,
37    /// Timeout for this stage
38    pub timeout_seconds: u64,
39    /// Checks to run in this stage
40    pub checks: Vec<CheckType>,
41    /// Whether to continue on non-critical failures
42    pub continue_on_warning: bool,
43}
44
45/// Bypass system configuration
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct BypassConfig {
48    /// Whether bypass is enabled
49    pub enabled: bool,
50    /// Require reason for bypass
51    pub require_reason: bool,
52    /// Require confirmation for bypass
53    pub require_confirmation: bool,
54    /// Log all bypasses for audit
55    pub log_bypasses: bool,
56    /// Maximum bypasses per day
57    pub max_bypasses_per_day: u32,
58}
59
60impl Default for BypassConfig {
61    fn default() -> Self {
62        Self {
63            enabled: true,
64            require_reason: true,
65            require_confirmation: true,
66            log_bypasses: true,
67            max_bypasses_per_day: 3,
68        }
69    }
70}
71
72impl BypassConfig {
73    /// Load bypass configuration or return defaults
74    ///
75    /// This function falls back to defaults on error and does not itself
76    /// return errors.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error only if the serialization fails (unlikely with defaults).
81    pub async fn load_or_default() -> Result<Self> {
82        // Get the safety config and extract the bypass config from it
83        match SafetyConfig::load().await {
84            Ok(config) => Ok(config.bypass),
85            Err(_) => Ok(Self::default()),
86        }
87    }
88}
89
90impl Default for SafetyConfig {
91    fn default() -> Self {
92        Self {
93            enabled: true,
94            strict_mode: true,
95            show_progress: true,
96            parallel_checks: true,
97            pre_commit: StageConfig {
98                enabled: true,
99                timeout_seconds: 300, // 5 minutes
100                checks: CheckType::for_stage(PipelineStage::PreCommit),
101                continue_on_warning: false,
102            },
103            pre_push: StageConfig {
104                enabled: true,
105                timeout_seconds: 600, // 10 minutes
106                checks: CheckType::for_stage(PipelineStage::PrePush),
107                continue_on_warning: false,
108            },
109            publish: StageConfig {
110                enabled: true,
111                timeout_seconds: 900, // 15 minutes
112                checks: CheckType::for_stage(PipelineStage::Publish),
113                continue_on_warning: false,
114            },
115            bypass: BypassConfig {
116                enabled: true, // Enabled by default - blocking is mandatory, bypass requires reason
117                require_reason: true,
118                require_confirmation: true,
119                log_bypasses: true,
120                max_bypasses_per_day: 3,
121            },
122        }
123    }
124}
125
126impl SafetyConfig {
127    /// Load configuration from file, or return default if not found
128    ///
129    /// # Errors
130    ///
131    /// This function falls back to defaults on error and does not itself
132    /// return errors.
133    pub async fn load_or_default() -> Result<Self> {
134        match Self::load().await {
135            Ok(config) => Ok(config),
136            Err(_) => Ok(Self::default()),
137        }
138    }
139
140    /// Load configuration from file
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if the config file cannot be read or parsed.
145    pub async fn load() -> Result<Self> {
146        let config_path = Self::config_file_path()?;
147        let contents = fs::read_to_string(&config_path)
148            .await
149            .map_err(|e| Error::config(format!("Failed to read safety config: {}", e)))?;
150
151        let config: Self = toml::from_str(&contents)
152            .map_err(|e| Error::config(format!("Failed to parse safety config: {}", e)))?;
153
154        Ok(config)
155    }
156
157    /// Save configuration to file
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if the config directory cannot be created, the
162    /// configuration cannot be serialized, or the file cannot be written.
163    pub async fn save(&self) -> Result<()> {
164        let config_path = Self::config_file_path()?;
165
166        // Ensure parent directory exists
167        if let Some(parent) = config_path.parent() {
168            fs::create_dir_all(parent).await?;
169        }
170
171        let contents = toml::to_string_pretty(self)
172            .map_err(|e| Error::config(format!("Failed to serialize safety config: {}", e)))?;
173
174        fs::write(&config_path, contents)
175            .await
176            .map_err(|e| Error::config(format!("Failed to write safety config: {}", e)))?;
177
178        Ok(())
179    }
180
181    /// Get the path to the safety configuration file
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the config directory path cannot be resolved.
186    pub fn config_file_path() -> Result<PathBuf> {
187        let config_dir = crate::config::Config::config_dir_path()?;
188        Ok(config_dir.join("safety.toml"))
189    }
190
191    /// Get configuration for a specific stage
192    pub fn get_stage_config(&self, stage: PipelineStage) -> &StageConfig {
193        match stage {
194            PipelineStage::PreCommit => &self.pre_commit,
195            PipelineStage::PrePush => &self.pre_push,
196            PipelineStage::Publish => &self.publish,
197        }
198    }
199
200    /// Get mutable configuration for a specific stage
201    pub fn get_stage_config_mut(&mut self, stage: PipelineStage) -> &mut StageConfig {
202        match stage {
203            PipelineStage::PreCommit => &mut self.pre_commit,
204            PipelineStage::PrePush => &mut self.pre_push,
205            PipelineStage::Publish => &mut self.publish,
206        }
207    }
208
209    /// Check if a specific check is enabled for a stage
210    pub fn is_check_enabled(&self, stage: PipelineStage, check: CheckType) -> bool {
211        let stage_config = self.get_stage_config(stage);
212        stage_config.enabled && stage_config.checks.contains(&check)
213    }
214
215    /// Get timeout for a specific stage
216    pub fn get_timeout(&self, stage: PipelineStage) -> Duration {
217        Duration::from_secs(self.get_stage_config(stage).timeout_seconds)
218    }
219
220    /// Set a configuration value
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the key is unknown or the value cannot be parsed
225    /// into the expected type.
226    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
227        match key {
228            key if key.starts_with("bypass.") => self.set_bypass_config(key, value),
229            key if key.contains('.') => self.set_stage_config(key, value),
230            _ => self.set_main_config(key, value),
231        }
232    }
233
234    /// Set main configuration values
235    fn set_main_config(&mut self, key: &str, value: &str) -> Result<()> {
236        match key {
237            "enabled" => {
238                self.enabled = self.parse_bool(value, "enabled")?;
239            }
240            "strict_mode" => {
241                self.strict_mode = self.parse_bool(value, "strict_mode")?;
242            }
243            "show_progress" => {
244                self.show_progress = self.parse_bool(value, "show_progress")?;
245            }
246            "parallel_checks" => {
247                self.parallel_checks = self.parse_bool(value, "parallel_checks")?;
248            }
249            _ => return Err(Error::config(format!("Unknown safety config key: {}", key))),
250        }
251        Ok(())
252    }
253
254    /// Set stage-specific configuration values
255    fn set_stage_config(&mut self, key: &str, value: &str) -> Result<()> {
256        let (stage, field) = key
257            .split_once('.')
258            .ok_or_else(|| Error::config(format!("Invalid config key format: {}", key)))?;
259
260        match (stage, field) {
261            ("pre_commit", "enabled") => {
262                self.pre_commit.enabled = self.parse_bool(value, "pre_commit.enabled")?;
263            }
264            ("pre_commit", "timeout_seconds") => {
265                self.pre_commit.timeout_seconds =
266                    self.parse_u64(value, "pre_commit.timeout_seconds")?;
267            }
268            ("pre_push", "enabled") => {
269                self.pre_push.enabled = self.parse_bool(value, "pre_push.enabled")?;
270            }
271            ("pre_push", "timeout_seconds") => {
272                self.pre_push.timeout_seconds =
273                    self.parse_u64(value, "pre_push.timeout_seconds")?;
274            }
275            ("publish", "enabled") => {
276                self.publish.enabled = self.parse_bool(value, "publish.enabled")?;
277            }
278            ("publish", "timeout_seconds") => {
279                self.publish.timeout_seconds = self.parse_u64(value, "publish.timeout_seconds")?;
280            }
281            _ => return Err(Error::config(format!("Unknown safety config key: {}", key))),
282        }
283        Ok(())
284    }
285
286    /// Set bypass configuration values
287    fn set_bypass_config(&mut self, key: &str, value: &str) -> Result<()> {
288        match key {
289            "bypass.enabled" => {
290                self.bypass.enabled = self.parse_bool(value, "bypass.enabled")?;
291            }
292            _ => return Err(Error::config(format!("Unknown safety config key: {}", key))),
293        }
294        Ok(())
295    }
296
297    /// Parse boolean value with error context
298    fn parse_bool(&self, value: &str, field: &str) -> Result<bool> {
299        value
300            .parse()
301            .map_err(|_| Error::config(format!("Invalid boolean value for {}", field)))
302    }
303
304    /// Parse u64 value with error context
305    fn parse_u64(&self, value: &str, field: &str) -> Result<u64> {
306        value
307            .parse()
308            .map_err(|_| Error::config(format!("Invalid number for {}", field)))
309    }
310
311    /// Get a configuration value
312    pub fn get(&self, key: &str) -> Option<String> {
313        match key {
314            "enabled" => Some(self.enabled.to_string()),
315            "strict_mode" => Some(self.strict_mode.to_string()),
316            "show_progress" => Some(self.show_progress.to_string()),
317            "parallel_checks" => Some(self.parallel_checks.to_string()),
318            "pre_commit.enabled" => Some(self.pre_commit.enabled.to_string()),
319            "pre_commit.timeout_seconds" => Some(self.pre_commit.timeout_seconds.to_string()),
320            "pre_push.enabled" => Some(self.pre_push.enabled.to_string()),
321            "pre_push.timeout_seconds" => Some(self.pre_push.timeout_seconds.to_string()),
322            "publish.enabled" => Some(self.publish.enabled.to_string()),
323            "publish.timeout_seconds" => Some(self.publish.timeout_seconds.to_string()),
324            "bypass.enabled" => Some(self.bypass.enabled.to_string()),
325            _ => None,
326        }
327    }
328}