Skip to main content

krypt_core/
include.rs

1//! `#include` directive — glob-based config composition.
2//!
3//! The [`include`][`Config::include`] field in a `.krypt.toml` lists glob
4//! patterns (relative to the file's directory) that name additional config
5//! files to merge in.  This module performs that expansion, merges the
6//! resulting configs, and returns a single flattened [`Config`].
7//!
8//! Entry points:
9//! - [`load_with_includes`] — read a root file and fully expand it.
10//! - [`expand_includes`] — expand an already-parsed `Config` given its
11//!   directory.  Useful when the caller has already parsed the root itself.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::{fs, io};
16
17use glob::glob;
18
19use crate::config::{Config, ConfigError, Meta, parse_str};
20
21/// Maximum include-nesting depth before we bail out.
22const DEFAULT_MAX_DEPTH: usize = 8;
23
24// ─── Public error type ──────────────────────────────────────────────────────
25
26/// Errors that can arise during include expansion.
27#[derive(Debug, thiserror::Error)]
28pub enum IncludeError {
29    /// A parse or validation error in one of the included files.
30    #[error("{0}")]
31    Config(#[from] ConfigError),
32
33    /// Could not read a file that matched a glob.
34    #[error("read {path}: {source}")]
35    Io {
36        /// The file we failed to read.
37        path: PathBuf,
38        /// Underlying OS error.
39        #[source]
40        source: io::Error,
41    },
42
43    /// A file was reachable from itself through the include chain.
44    #[error("include cycle detected: {chain}")]
45    Cycle {
46        /// Human-readable `a -> b -> a` chain.
47        chain: String,
48    },
49
50    /// The include chain exceeded [`DEFAULT_MAX_DEPTH`] hops.
51    #[error("include depth limit exceeded (max {max}) at {last}")]
52    DepthExceeded {
53        /// The limit that was hit.
54        max: usize,
55        /// The file that would have been the next hop.
56        last: PathBuf,
57    },
58
59    /// A glob pattern was syntactically invalid.
60    #[error("glob pattern {pattern:?} from {base}: {reason}")]
61    Glob {
62        /// The raw pattern string from the config.
63        pattern: String,
64        /// Directory the pattern was relative to.
65        base: PathBuf,
66        /// Human-readable error from the `glob` crate.
67        reason: String,
68    },
69}
70
71// ─── Public API ─────────────────────────────────────────────────────────────
72
73/// Parse `path` from disk and fully expand its `include` directives.
74///
75/// This is the most convenient entry point: it reads, parses, validates, and
76/// merges everything, returning a single flat [`Config`] with `include`
77/// cleared.
78pub fn load_with_includes(path: impl AsRef<Path>) -> Result<Config, IncludeError> {
79    let path = path.as_ref();
80    let raw = read_file(path)?;
81    let cfg = parse_str(&raw, path)?;
82    let base_dir = path
83        .parent()
84        .unwrap_or_else(|| Path::new("."))
85        .to_path_buf();
86    let canon = canonicalize_for_cycle(path)?;
87    let mut visited = vec![canon];
88    expand_includes_inner(cfg, &base_dir, &mut visited, 0)
89}
90
91/// Expand the `include` list in an already-parsed `cfg`, resolving patterns
92/// relative to `base_dir`.
93///
94/// `base_dir` should be the directory of the file that produced `cfg`.  The
95/// returned `Config` has its `include` field cleared.
96pub fn expand_includes(cfg: Config, base_dir: &Path) -> Result<Config, IncludeError> {
97    let mut visited: Vec<PathBuf> = Vec::new();
98    expand_includes_inner(cfg, base_dir, &mut visited, 0)
99}
100
101// ─── Internal recursion ─────────────────────────────────────────────────────
102
103fn expand_includes_inner(
104    cfg: Config,
105    base_dir: &Path,
106    visited: &mut Vec<PathBuf>,
107    depth: usize,
108) -> Result<Config, IncludeError> {
109    if cfg.include.is_empty() {
110        let mut out = cfg;
111        out.include.clear();
112        return Ok(out);
113    }
114
115    // Collect the expanded paths for every glob pattern, sorted + de-duped.
116    let mut matched_paths: Vec<PathBuf> = Vec::new();
117    for pattern in &cfg.include {
118        let full_pattern = base_dir.join(pattern);
119        let pattern_str = full_pattern.to_string_lossy().into_owned();
120        let entries = glob(&pattern_str).map_err(|e| IncludeError::Glob {
121            pattern: pattern.clone(),
122            base: base_dir.to_path_buf(),
123            reason: e.to_string(),
124        })?;
125        let mut local: Vec<PathBuf> = entries.filter_map(|res| res.ok()).collect();
126        local.sort();
127        for p in local {
128            if !matched_paths.contains(&p) {
129                matched_paths.push(p);
130            }
131        }
132    }
133
134    // Start with the root config (include cleared).
135    let mut merged = cfg;
136    merged.include.clear();
137
138    for inc_path in matched_paths {
139        // Depth check.
140        if depth + 1 > DEFAULT_MAX_DEPTH {
141            return Err(IncludeError::DepthExceeded {
142                max: DEFAULT_MAX_DEPTH,
143                last: inc_path,
144            });
145        }
146
147        // Cycle check via canonical path.
148        let canon = canonicalize_for_cycle(&inc_path)?;
149        if visited.contains(&canon) {
150            let chain = build_cycle_chain(visited, &canon);
151            return Err(IncludeError::Cycle { chain });
152        }
153
154        // Parse the included file.
155        let raw = read_file(&inc_path)?;
156        let inc_cfg = parse_str(&raw, &inc_path)?;
157
158        // Recurse into its own includes.
159        let inc_base = inc_path
160            .parent()
161            .unwrap_or_else(|| Path::new("."))
162            .to_path_buf();
163        visited.push(canon.clone());
164        let inc_cfg = expand_includes_inner(inc_cfg, &inc_base, visited, depth + 1)?;
165        visited.pop();
166
167        // Merge the (fully expanded) included config on top.
168        merged = merge(merged, inc_cfg);
169    }
170
171    Ok(merged)
172}
173
174// ─── Merge logic ────────────────────────────────────────────────────────────
175
176/// Merge `later` on top of `base`.
177///
178/// - **Vec fields** (`links`, `templates`, `deps`, `hooks`, `commands`):
179///   appended in order.
180/// - **BTreeMap fields** (`paths`, `prompts`): later wins on conflicting keys.
181/// - **`meta`**: later non-empty fields win; empty ones keep the base value.
182/// - **`include`**: always cleared (expansion is done).
183fn merge(base: Config, later: Config) -> Config {
184    Config {
185        meta: merge_meta(base.meta, later.meta),
186        include: Vec::new(),
187        paths: merge_map(base.paths, later.paths),
188        links: concat(base.links, later.links),
189        templates: concat(base.templates, later.templates),
190        prompts: merge_map(base.prompts, later.prompts),
191        deps: concat(base.deps, later.deps),
192        hooks: concat(base.hooks, later.hooks),
193        commands: concat(base.commands, later.commands),
194    }
195}
196
197fn merge_meta(base: Meta, later: Meta) -> Meta {
198    Meta {
199        name: nonempty_or(later.name, base.name),
200        description: nonempty_or(later.description, base.description),
201        krypt_min: later.krypt_min.or(base.krypt_min),
202        notify_backend: later.notify_backend.or(base.notify_backend),
203    }
204}
205
206/// Return `preferred` if it is non-empty, otherwise `fallback`.
207fn nonempty_or(preferred: String, fallback: String) -> String {
208    if preferred.is_empty() {
209        fallback
210    } else {
211        preferred
212    }
213}
214
215fn concat<T>(mut a: Vec<T>, b: Vec<T>) -> Vec<T> {
216    a.extend(b);
217    a
218}
219
220fn merge_map<V>(mut base: BTreeMap<String, V>, later: BTreeMap<String, V>) -> BTreeMap<String, V> {
221    base.extend(later);
222    base
223}
224
225// ─── Helpers ────────────────────────────────────────────────────────────────
226
227fn read_file(path: &Path) -> Result<String, IncludeError> {
228    fs::read_to_string(path).map_err(|source| IncludeError::Io {
229        path: path.to_path_buf(),
230        source,
231    })
232}
233
234/// Canonicalize a path for cycle detection.  If the path doesn't exist yet
235/// (unlikely in practice), fall back to the absolute path so the error is
236/// still useful.
237fn canonicalize_for_cycle(path: &Path) -> Result<PathBuf, IncludeError> {
238    std::fs::canonicalize(path).map_err(|source| IncludeError::Io {
239        path: path.to_path_buf(),
240        source,
241    })
242}
243
244/// Build a `a -> b -> c` chain string for a cycle error message.
245fn build_cycle_chain(visited: &[PathBuf], repeated: &Path) -> String {
246    let mut parts: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
247    parts.push(repeated.display().to_string());
248    parts.join(" -> ")
249}
250
251// ─── Unit tests ─────────────────────────────────────────────────────────────
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn empty_config() -> Config {
258        Config::default()
259    }
260
261    fn config_with_name(name: &str) -> Config {
262        let mut c = Config::default();
263        c.meta.name = name.to_string();
264        c
265    }
266
267    // merge_meta: later non-empty name wins
268    #[test]
269    fn merge_meta_later_name_wins() {
270        let base = config_with_name("base");
271        let later = config_with_name("later");
272        let merged = merge(base, later);
273        assert_eq!(merged.meta.name, "later");
274    }
275
276    // merge_meta: later empty name keeps base
277    #[test]
278    fn merge_meta_empty_later_keeps_base() {
279        let base = config_with_name("base");
280        let later = empty_config();
281        let merged = merge(base, later);
282        assert_eq!(merged.meta.name, "base");
283    }
284
285    // merge clears include on result
286    #[test]
287    fn merge_clears_include() {
288        let mut base = empty_config();
289        base.include = vec!["a.toml".into()];
290        let mut later = empty_config();
291        later.include = vec!["b.toml".into()];
292        let merged = merge(base, later);
293        assert!(merged.include.is_empty());
294    }
295
296    // merge_map: later key wins
297    #[test]
298    fn merge_map_later_wins() {
299        let mut base = BTreeMap::new();
300        base.insert("KEY".to_string(), "a".to_string());
301        let mut later = BTreeMap::new();
302        later.insert("KEY".to_string(), "b".to_string());
303        let merged = merge_map(base, later);
304        assert_eq!(merged["KEY"], "b");
305    }
306
307    // nonempty_or picks preferred when non-empty
308    #[test]
309    fn nonempty_or_picks_preferred() {
310        assert_eq!(nonempty_or("x".into(), "y".into()), "x");
311        assert_eq!(nonempty_or(String::new(), "y".into()), "y");
312    }
313}