Skip to main content

grapsus_config/multi_file/
loader.rs

1//! Multi-file configuration loader.
2//!
3//! This module provides `MultiFileLoader` for loading and merging
4//! configurations from multiple KDL files.
5
6use anyhow::{anyhow, Context, Result};
7use glob::glob;
8use kdl::KdlDocument;
9use std::collections::HashSet;
10use std::fs;
11use std::path::{Path, PathBuf};
12use tracing::{debug, info, warn};
13
14use crate::Config;
15
16use super::builder::{ConfigBuilder, PartialConfig};
17
18/// Multi-file configuration loader.
19///
20/// Provides a builder-style API for configuring how configuration files
21/// are discovered and loaded from a directory.
22///
23/// # Example
24///
25/// ```ignore
26/// let mut loader = MultiFileLoader::new("/etc/grapsus/conf.d")
27///     .with_include("*.kdl")
28///     .with_exclude("*.example.kdl")
29///     .recursive(true);
30///
31/// let config = loader.load()?;
32/// ```
33pub struct MultiFileLoader {
34    /// Base directory for configuration files
35    base_dir: PathBuf,
36    /// File patterns to include
37    include_patterns: Vec<String>,
38    /// File patterns to exclude
39    exclude_patterns: Vec<String>,
40    /// Enable recursive directory scanning
41    recursive: bool,
42    /// Allow duplicate definitions (last wins)
43    #[allow(dead_code)]
44    allow_duplicates: bool,
45    /// Strict mode - fail on warnings
46    strict: bool,
47    /// Loaded files tracking
48    loaded_files: HashSet<PathBuf>,
49}
50
51impl MultiFileLoader {
52    /// Create a new multi-file loader.
53    pub fn new(base_dir: impl AsRef<Path>) -> Self {
54        Self {
55            base_dir: base_dir.as_ref().to_path_buf(),
56            include_patterns: vec!["*.kdl".to_string()],
57            exclude_patterns: vec![],
58            recursive: true,
59            allow_duplicates: false,
60            strict: false,
61            loaded_files: HashSet::new(),
62        }
63    }
64
65    /// Add include pattern.
66    pub fn with_include(mut self, pattern: impl Into<String>) -> Self {
67        self.include_patterns.push(pattern.into());
68        self
69    }
70
71    /// Add exclude pattern.
72    pub fn with_exclude(mut self, pattern: impl Into<String>) -> Self {
73        self.exclude_patterns.push(pattern.into());
74        self
75    }
76
77    /// Set recursive scanning.
78    pub fn recursive(mut self, recursive: bool) -> Self {
79        self.recursive = recursive;
80        self
81    }
82
83    /// Allow duplicate definitions.
84    pub fn allow_duplicates(mut self, allow: bool) -> Self {
85        self.allow_duplicates = allow;
86        self
87    }
88
89    /// Enable strict mode.
90    pub fn strict(mut self, strict: bool) -> Self {
91        self.strict = strict;
92        self
93    }
94
95    /// Load configuration from multiple files.
96    pub fn load(&mut self) -> Result<Config> {
97        info!("Loading configuration from directory: {:?}", self.base_dir);
98
99        // Find all configuration files
100        let files = self.find_config_files()?;
101
102        if files.is_empty() {
103            return Err(anyhow!(
104                "No configuration files found in {:?}",
105                self.base_dir
106            ));
107        }
108
109        info!("Found {} configuration files", files.len());
110
111        // Load and merge configurations
112        let mut merged = ConfigBuilder::new();
113
114        for file in files {
115            self.load_file_recursive(&file, &mut merged)?;
116        }
117
118        // Build final configuration
119        let config = merged.build()?;
120
121        // Validate if in strict mode
122        if self.strict {
123            config
124                .validate()
125                .map_err(|e| anyhow!("Validation failed: {}", e))?;
126        }
127
128        Ok(config)
129    }
130
131    /// Load a file and recursively process its includes.
132    fn load_file_recursive(&mut self, path: &Path, merged: &mut ConfigBuilder) -> Result<()> {
133        // Canonicalize path to detect circular includes
134        let canonical = path
135            .canonicalize()
136            .with_context(|| format!("Failed to resolve path: {:?}", path))?;
137
138        // Check for circular includes
139        if self.loaded_files.contains(&canonical) {
140            debug!("Skipping already loaded file: {:?}", canonical);
141            return Ok(());
142        }
143
144        debug!("Loading configuration from: {:?}", path);
145
146        // Mark as loaded before processing to prevent circular includes
147        self.loaded_files.insert(canonical.clone());
148
149        // Load and parse the file
150        let config = self.load_file(path)?;
151
152        // Process includes first (depth-first)
153        for include_path in &config.includes {
154            let resolved = self.resolve_include_path(path, include_path)?;
155
156            if !resolved.exists() {
157                warn!(
158                    "Include file not found: {:?} (referenced from {:?})",
159                    resolved, path
160                );
161                continue;
162            }
163
164            self.load_file_recursive(&resolved, merged)?;
165        }
166
167        // Merge this file's config
168        merged.merge(config)?;
169
170        Ok(())
171    }
172
173    /// Resolve an include path relative to the including file.
174    fn resolve_include_path(&self, from_file: &Path, include: &Path) -> Result<PathBuf> {
175        if include.is_absolute() {
176            Ok(include.to_path_buf())
177        } else {
178            // Relative to the directory containing the including file
179            let base_dir = from_file
180                .parent()
181                .ok_or_else(|| anyhow!("Cannot determine parent directory of {:?}", from_file))?;
182            Ok(base_dir.join(include))
183        }
184    }
185
186    /// Find all configuration files matching patterns.
187    fn find_config_files(&self) -> Result<Vec<PathBuf>> {
188        let mut files = Vec::new();
189        let mut seen = HashSet::new();
190
191        for pattern in &self.include_patterns {
192            let full_pattern = if self.recursive {
193                self.base_dir.join("**").join(pattern)
194            } else {
195                self.base_dir.join(pattern)
196            };
197
198            let pattern_str = full_pattern
199                .to_str()
200                .ok_or_else(|| anyhow!("Invalid path pattern"))?;
201
202            for entry in glob(pattern_str).context("Failed to read glob pattern")? {
203                match entry {
204                    Ok(path) => {
205                        if path.is_file() {
206                            // Check exclusions
207                            if self.should_exclude(&path) {
208                                debug!("Excluding file: {:?}", path);
209                                continue;
210                            }
211
212                            if seen.insert(path.clone()) {
213                                files.push(path);
214                            }
215                        }
216                        // Skip directories
217                    }
218                    Err(e) => {
219                        warn!("Error accessing path: {}", e);
220                    }
221                }
222            }
223        }
224
225        // Sort files for consistent loading order
226        files.sort();
227
228        Ok(files)
229    }
230
231    /// Check if a file should be excluded.
232    fn should_exclude(&self, path: &Path) -> bool {
233        let path_str = path.to_string_lossy();
234
235        for pattern in &self.exclude_patterns {
236            if path_str.contains(pattern) {
237                return true;
238            }
239        }
240
241        // Common exclusions
242        if path_str.contains(".example.") || path_str.contains(".bak") || path_str.ends_with("~") {
243            return true;
244        }
245
246        false
247    }
248
249    /// Load a single configuration file.
250    fn load_file(&self, path: &Path) -> Result<PartialConfig> {
251        let content =
252            fs::read_to_string(path).with_context(|| format!("Failed to read file: {:?}", path))?;
253
254        let doc: KdlDocument = content
255            .parse()
256            .with_context(|| format!("Failed to parse KDL file: {:?}", path))?;
257
258        PartialConfig::from_kdl(doc, path)
259    }
260}