Skip to main content

ferrous_forge/config/
sharing.rs

1//! Configuration sharing utilities for team-wide config export/import
2//!
3//! Provides functionality to export and import configuration at different levels,
4//! enabling team-wide standard sharing across the organization.
5//!
6//! @task T018
7//! @epic T014
8
9use crate::config::hierarchy::{ConfigLevel, HierarchicalConfig, PartialConfig};
10use crate::config::locking::HierarchicalLockManager;
11use crate::{Error, Result};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use tokio::fs;
16use tracing::info;
17
18/// Shareable team configuration format
19///
20/// This structure represents a complete configuration snapshot that can be
21/// exported from one level and imported to another, preserving both
22/// configuration values and lock states.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SharedConfig {
25    /// Version of the shared config format
26    pub version: String,
27    /// Configuration level this was exported from
28    pub exported_from: ConfigLevel,
29    /// Username who exported the config
30    pub exported_by: String,
31    /// Timestamp of export (ISO 8601)
32    pub exported_at: String,
33    /// Description of this config (optional)
34    pub description: Option<String>,
35    /// The configuration values
36    pub config: PartialConfig,
37    /// Locked configuration keys
38    pub locks: HashMap<String, LockExport>,
39}
40
41/// Lock entry for export (simplified from `LockEntry`)
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct LockExport {
44    /// The locked value
45    pub value: String,
46    /// Reason for locking (for documentation)
47    pub reason: String,
48}
49
50impl SharedConfig {
51    /// Create a new shared config from a specific level
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if the hierarchical configuration cannot be loaded.
56    pub async fn from_level(level: ConfigLevel) -> Result<Self> {
57        let hier_config = HierarchicalConfig::load().await?;
58        let lock_manager = HierarchicalLockManager::load().await?;
59
60        // Get the partial config for this level
61        let partial = match level {
62            ConfigLevel::System => hier_config.system.clone(),
63            ConfigLevel::User => hier_config.user.clone(),
64            ConfigLevel::Project => hier_config.project.clone(),
65        };
66
67        // If no config at this level, create empty partial
68        let config = partial.unwrap_or_default();
69
70        // Get locks at this level
71        let locks = match level {
72            ConfigLevel::System => lock_manager.get_locks_at_level(ConfigLevel::System).await?,
73            ConfigLevel::User => lock_manager.get_locks_at_level(ConfigLevel::User).await?,
74            ConfigLevel::Project => {
75                lock_manager
76                    .get_locks_at_level(ConfigLevel::Project)
77                    .await?
78            }
79        };
80
81        // Convert locks to export format
82        let lock_exports: HashMap<String, LockExport> = locks
83            .into_iter()
84            .map(|(key, entry)| {
85                (
86                    key,
87                    LockExport {
88                        value: entry.value,
89                        reason: entry.reason,
90                    },
91                )
92            })
93            .collect();
94
95        Ok(Self {
96            version: env!("CARGO_PKG_VERSION").to_string(),
97            exported_from: level,
98            exported_by: whoami::username(),
99            exported_at: chrono::Utc::now().to_rfc3339(),
100            description: None,
101            config,
102            locks: lock_exports,
103        })
104    }
105
106    /// Set a description for this shared config
107    pub fn with_description(mut self, description: impl Into<String>) -> Self {
108        self.description = Some(description.into());
109        self
110    }
111
112    /// Export to TOML string
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if serialization fails.
117    pub fn to_toml(&self) -> Result<String> {
118        toml::to_string_pretty(self)
119            .map_err(|e| Error::config(format!("Failed to serialize shared config: {}", e)))
120    }
121
122    /// Parse from TOML string
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if deserialization fails.
127    pub fn from_toml(toml_str: &str) -> Result<Self> {
128        toml::from_str(toml_str)
129            .map_err(|e| Error::config(format!("Failed to parse shared config: {}", e)))
130    }
131
132    /// Export to a file
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if serialization fails or the file cannot be written.
137    pub async fn export_to_file(&self, path: &PathBuf) -> Result<()> {
138        let contents = self.to_toml()?;
139
140        // Ensure parent directory exists
141        if let Some(parent) = path.parent() {
142            fs::create_dir_all(parent).await.map_err(|e| {
143                Error::config(format!("Failed to create directory for export: {}", e))
144            })?;
145        }
146
147        fs::write(path, contents)
148            .await
149            .map_err(|e| Error::config(format!("Failed to write shared config to file: {}", e)))?;
150
151        info!("Exported configuration to {}", path.display());
152        Ok(())
153    }
154
155    /// Import from a file
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if the file cannot be read or parsed.
160    pub async fn import_from_file(path: &PathBuf) -> Result<Self> {
161        if !path.exists() {
162            return Err(Error::config(format!(
163                "Shared config file not found: {}",
164                path.display()
165            )));
166        }
167
168        let contents = fs::read_to_string(path)
169            .await
170            .map_err(|e| Error::config(format!("Failed to read shared config file: {}", e)))?;
171
172        Self::from_toml(&contents)
173    }
174
175    /// Get a summary of this shared config for display
176    pub fn summary(&self) -> String {
177        let mut summary = format!("Shared Configuration\n");
178        summary.push_str(&format!("  Version: {}\n", self.version));
179        summary.push_str(&format!(
180            "  Exported from: {} level\n",
181            self.exported_from.display_name()
182        ));
183        summary.push_str(&format!("  Exported by: {}\n", self.exported_by));
184        summary.push_str(&format!("  Exported at: {}\n", self.exported_at));
185        if let Some(ref desc) = self.description {
186            summary.push_str(&format!("  Description: {}\n", desc));
187        }
188        summary.push_str(&format!(
189            "\n  Configuration entries: {}\n",
190            self.count_config_entries()
191        ));
192        summary.push_str(&format!("  Locked keys: {}\n", self.locks.len()));
193        summary
194    }
195
196    /// Count non-None configuration entries
197    fn count_config_entries(&self) -> usize {
198        let mut count = 0;
199        if self.config.initialized.is_some() {
200            count += 1;
201        }
202        if self.config.version.is_some() {
203            count += 1;
204        }
205        if self.config.update_channel.is_some() {
206            count += 1;
207        }
208        if self.config.auto_update.is_some() {
209            count += 1;
210        }
211        if self.config.clippy_rules.is_some() {
212            count += 1;
213        }
214        if self.config.max_file_lines.is_some() {
215            count += 1;
216        }
217        if self.config.max_function_lines.is_some() {
218            count += 1;
219        }
220        if self.config.required_edition.is_some() {
221            count += 1;
222        }
223        if self.config.required_rust_version.is_some() {
224            count += 1;
225        }
226        if self.config.ban_underscore_bandaid.is_some() {
227            count += 1;
228        }
229        if self.config.require_documentation.is_some() {
230            count += 1;
231        }
232        if self.config.custom_rules.is_some() {
233            count += 1;
234        }
235        count
236    }
237}
238
239/// Import options for merging shared configs
240#[derive(Debug, Clone)]
241pub struct ImportOptions {
242    /// Target level to import to
243    pub target_level: ConfigLevel,
244    /// Whether to overwrite existing values
245    pub overwrite: bool,
246    /// Whether to import locks as well
247    pub import_locks: bool,
248    /// Whether to require justification for overriding locks
249    pub require_justification: bool,
250}
251
252impl Default for ImportOptions {
253    fn default() -> Self {
254        Self {
255            target_level: ConfigLevel::Project,
256            overwrite: true,
257            import_locks: true,
258            require_justification: true,
259        }
260    }
261}
262
263/// Import a shared configuration to a specific level
264///
265/// This merges the shared config into the existing configuration at the target level.
266/// If locks are included, they will be applied at the target level as well.
267///
268/// # Errors
269///
270/// Returns an error if the import fails due to lock conflicts or file I/O errors.
271pub async fn import_shared_config(
272    shared: &SharedConfig,
273    options: ImportOptions,
274) -> Result<ImportReport> {
275    let mut report = ImportReport::default();
276
277    // Load existing config at target level
278    let hier_config = HierarchicalConfig::load().await?;
279    let mut lock_manager = HierarchicalLockManager::load().await?;
280
281    // Get or create the partial config at target level
282    let existing_partial = match options.target_level {
283        ConfigLevel::System => hier_config.system.clone().unwrap_or_default(),
284        ConfigLevel::User => hier_config.user.clone().unwrap_or_default(),
285        ConfigLevel::Project => hier_config.project.clone().unwrap_or_default(),
286    };
287
288    // Merge the shared config into existing
289    let merged_partial = if options.overwrite {
290        existing_partial.merge(shared.config.clone())
291    } else {
292        // If not overwriting, shared config fills in gaps only
293        shared.config.clone().merge(existing_partial)
294    };
295
296    // Check for lock conflicts before applying
297    let mut conflicts = Vec::new();
298    if options.require_justification {
299        // Check if any values we're trying to set are locked at higher levels
300        let locks = lock_manager.get_effective_locks();
301        for key in shared.config.list_keys() {
302            if let Some((level, entry)) = locks.get(&key) {
303                // Check if this lock is at a higher level than our target
304                if *level > options.target_level {
305                    conflicts.push(LockConflict {
306                        key: key.clone(),
307                        locked_at: *level,
308                        current_value: entry.value.clone(),
309                        attempted_value: shared.config.get_value(&key).unwrap_or_default(),
310                    });
311                }
312            }
313        }
314    }
315
316    if !conflicts.is_empty() {
317        report.conflicts = conflicts;
318        return Ok(report);
319    }
320
321    // Save the merged config
322    HierarchicalConfig::save_partial_at_level(&merged_partial, options.target_level).await?;
323    report.config_imported = true;
324    report.config_keys_updated = merged_partial.count_set_fields();
325
326    // Import locks if requested
327    if options.import_locks && !shared.locks.is_empty() {
328        for (key, lock_export) in &shared.locks {
329            // Check if already locked at this level
330            if lock_manager
331                .is_locked_at_level(key, options.target_level)
332                .is_some()
333            {
334                report.locks_skipped.push(key.clone());
335                continue;
336            }
337
338            // Create lock entry
339            let entry = crate::config::locking::LockEntry::new(
340                &lock_export.value,
341                format!("Imported from shared config: {}", lock_export.reason),
342                options.target_level,
343            );
344
345            lock_manager.lock_with_entry(key, entry).await?;
346            report.locks_imported.push(key.clone());
347        }
348    }
349
350    info!(
351        "Imported {} config keys and {} locks to {} level",
352        report.config_keys_updated,
353        report.locks_imported.len(),
354        options.target_level.display_name()
355    );
356
357    Ok(report)
358}
359
360/// Report of an import operation
361#[derive(Debug, Clone, Default)]
362pub struct ImportReport {
363    /// Whether the config was imported
364    pub config_imported: bool,
365    /// Number of configuration keys updated
366    pub config_keys_updated: usize,
367    /// Locks that were imported
368    pub locks_imported: Vec<String>,
369    /// Locks that were skipped (already existed)
370    pub locks_skipped: Vec<String>,
371    /// Lock conflicts that prevented import
372    pub conflicts: Vec<LockConflict>,
373}
374
375/// A lock conflict during import
376#[derive(Debug, Clone)]
377pub struct LockConflict {
378    /// The configuration key in conflict
379    pub key: String,
380    /// The level where the lock exists
381    pub locked_at: ConfigLevel,
382    /// The currently locked value
383    pub current_value: String,
384    /// The value we tried to set
385    pub attempted_value: String,
386}
387
388/// Extension trait for `PartialConfig` to get list of keys
389pub trait PartialConfigExt {
390    /// List all keys that have values set
391    fn list_keys(&self) -> Vec<String>;
392    /// Get value for a key as string
393    fn get_value(&self, key: &str) -> Option<String>;
394    /// Count number of fields that are Some
395    fn count_set_fields(&self) -> usize;
396}
397
398impl PartialConfigExt for PartialConfig {
399    fn list_keys(&self) -> Vec<String> {
400        let mut keys = Vec::new();
401        if self.initialized.is_some() {
402            keys.push("initialized".to_string());
403        }
404        if self.version.is_some() {
405            keys.push("version".to_string());
406        }
407        if self.update_channel.is_some() {
408            keys.push("update_channel".to_string());
409        }
410        if self.auto_update.is_some() {
411            keys.push("auto_update".to_string());
412        }
413        if self.clippy_rules.is_some() {
414            keys.push("clippy_rules".to_string());
415        }
416        if self.max_file_lines.is_some() {
417            keys.push("max_file_lines".to_string());
418        }
419        if self.max_function_lines.is_some() {
420            keys.push("max_function_lines".to_string());
421        }
422        if self.required_edition.is_some() {
423            keys.push("required_edition".to_string());
424        }
425        if self.required_rust_version.is_some() {
426            keys.push("required_rust_version".to_string());
427        }
428        if self.ban_underscore_bandaid.is_some() {
429            keys.push("ban_underscore_bandaid".to_string());
430        }
431        if self.require_documentation.is_some() {
432            keys.push("require_documentation".to_string());
433        }
434        if self.custom_rules.is_some() {
435            keys.push("custom_rules".to_string());
436        }
437        keys
438    }
439
440    fn get_value(&self, key: &str) -> Option<String> {
441        match key {
442            "initialized" => self.initialized.map(|v| v.to_string()),
443            "version" => self.version.clone(),
444            "update_channel" => self.update_channel.clone(),
445            "auto_update" => self.auto_update.map(|v| v.to_string()),
446            "clippy_rules" => self.clippy_rules.as_ref().map(|v| format!("{:?}", v)),
447            "max_file_lines" => self.max_file_lines.map(|v| v.to_string()),
448            "max_function_lines" => self.max_function_lines.map(|v| v.to_string()),
449            "required_edition" => self.required_edition.clone(),
450            "required_rust_version" => self.required_rust_version.clone(),
451            "ban_underscore_bandaid" => self.ban_underscore_bandaid.map(|v| v.to_string()),
452            "require_documentation" => self.require_documentation.map(|v| v.to_string()),
453            "custom_rules" => self.custom_rules.as_ref().map(|v| format!("{:?}", v)),
454            _ => None,
455        }
456    }
457
458    fn count_set_fields(&self) -> usize {
459        self.list_keys().len()
460    }
461}
462
463/// Extension trait for `HierarchicalLockManager` to get locks at specific level
464pub trait HierarchicalLockManagerExt {
465    /// Get all locks at a specific level
466    #[allow(async_fn_in_trait)]
467    async fn get_locks_at_level(
468        &self,
469        level: ConfigLevel,
470    ) -> Result<HashMap<String, crate::config::locking::LockEntry>>;
471    /// Lock with a pre-built entry
472    #[allow(async_fn_in_trait)]
473    async fn lock_with_entry(
474        &mut self,
475        key: &str,
476        entry: crate::config::locking::LockEntry,
477    ) -> Result<()>;
478}
479
480impl HierarchicalLockManagerExt for HierarchicalLockManager {
481    async fn get_locks_at_level(
482        &self,
483        level: ConfigLevel,
484    ) -> Result<HashMap<String, crate::config::locking::LockEntry>> {
485        use crate::config::locking::LockedConfig;
486
487        if let Some(locked) = LockedConfig::load_from_level(level).await? {
488            Ok(locked.locks)
489        } else {
490            Ok(HashMap::new())
491        }
492    }
493
494    async fn lock_with_entry(
495        &mut self,
496        key: &str,
497        entry: crate::config::locking::LockEntry,
498    ) -> Result<()> {
499        use crate::config::locking::LockedConfig;
500
501        let level = entry.level;
502
503        // Load or create locks at this level
504        let mut locks = LockedConfig::load_from_level(level)
505            .await?
506            .unwrap_or_default();
507        locks.lock(key.to_string(), entry);
508        locks.save_to_level(level).await?;
509
510        Ok(())
511    }
512}
513
514/// Extension trait for `HierarchicalConfig` to save partial config
515pub trait HierarchicalConfigExt {
516    /// Save a partial config at a specific level
517    #[allow(async_fn_in_trait)]
518    async fn save_partial_at_level(partial: &PartialConfig, level: ConfigLevel) -> Result<()>;
519}
520
521impl HierarchicalConfigExt for HierarchicalConfig {
522    async fn save_partial_at_level(partial: &PartialConfig, level: ConfigLevel) -> Result<()> {
523        use tokio::fs;
524
525        let path = level.path()?;
526
527        // Ensure parent directory exists
528        if let Some(parent) = path.parent() {
529            fs::create_dir_all(parent).await?;
530        }
531
532        // Convert partial to full config for serialization
533        let full_config = partial.clone().to_full_config();
534        let contents = toml::to_string_pretty(&full_config)
535            .map_err(|e| Error::config(format!("Failed to serialize config: {}", e)))?;
536
537        fs::write(&path, contents).await.map_err(|e| {
538            Error::config(format!(
539                "Failed to write {} config: {}",
540                level.display_name(),
541                e
542            ))
543        })?;
544
545        info!(
546            "Saved {} configuration to {}",
547            level.display_name(),
548            path.display()
549        );
550        Ok(())
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn test_shared_config_serialization() {
560        let config = PartialConfig {
561            required_edition: Some("2024".to_string()),
562            max_file_lines: Some(300),
563            ..Default::default()
564        };
565
566        let shared = SharedConfig {
567            version: "1.0.0".to_string(),
568            exported_from: ConfigLevel::Project,
569            exported_by: "test_user".to_string(),
570            exported_at: "2024-01-01T00:00:00Z".to_string(),
571            description: Some("Test config".to_string()),
572            config,
573            locks: HashMap::new(),
574        };
575
576        let toml = shared.to_toml().unwrap();
577        assert!(toml.contains("version"));
578        assert!(toml.contains("2024"));
579    }
580
581    #[test]
582    fn test_partial_config_ext() {
583        let partial = PartialConfig {
584            required_edition: Some("2024".to_string()),
585            max_file_lines: Some(300),
586            ..Default::default()
587        };
588
589        let keys = partial.list_keys();
590        assert!(keys.contains(&"required_edition".to_string()));
591        assert!(keys.contains(&"max_file_lines".to_string()));
592        assert_eq!(partial.count_set_fields(), 2);
593    }
594}