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    }
203}
204
205/// Return `preferred` if it is non-empty, otherwise `fallback`.
206fn nonempty_or(preferred: String, fallback: String) -> String {
207    if preferred.is_empty() {
208        fallback
209    } else {
210        preferred
211    }
212}
213
214fn concat<T>(mut a: Vec<T>, b: Vec<T>) -> Vec<T> {
215    a.extend(b);
216    a
217}
218
219fn merge_map<V>(mut base: BTreeMap<String, V>, later: BTreeMap<String, V>) -> BTreeMap<String, V> {
220    base.extend(later);
221    base
222}
223
224// ─── Helpers ────────────────────────────────────────────────────────────────
225
226fn read_file(path: &Path) -> Result<String, IncludeError> {
227    fs::read_to_string(path).map_err(|source| IncludeError::Io {
228        path: path.to_path_buf(),
229        source,
230    })
231}
232
233/// Canonicalize a path for cycle detection.  If the path doesn't exist yet
234/// (unlikely in practice), fall back to the absolute path so the error is
235/// still useful.
236fn canonicalize_for_cycle(path: &Path) -> Result<PathBuf, IncludeError> {
237    std::fs::canonicalize(path).map_err(|source| IncludeError::Io {
238        path: path.to_path_buf(),
239        source,
240    })
241}
242
243/// Build a `a -> b -> c` chain string for a cycle error message.
244fn build_cycle_chain(visited: &[PathBuf], repeated: &Path) -> String {
245    let mut parts: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
246    parts.push(repeated.display().to_string());
247    parts.join(" -> ")
248}
249
250// ─── Unit tests ─────────────────────────────────────────────────────────────
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn empty_config() -> Config {
257        Config::default()
258    }
259
260    fn config_with_name(name: &str) -> Config {
261        let mut c = Config::default();
262        c.meta.name = name.to_string();
263        c
264    }
265
266    // merge_meta: later non-empty name wins
267    #[test]
268    fn merge_meta_later_name_wins() {
269        let base = config_with_name("base");
270        let later = config_with_name("later");
271        let merged = merge(base, later);
272        assert_eq!(merged.meta.name, "later");
273    }
274
275    // merge_meta: later empty name keeps base
276    #[test]
277    fn merge_meta_empty_later_keeps_base() {
278        let base = config_with_name("base");
279        let later = empty_config();
280        let merged = merge(base, later);
281        assert_eq!(merged.meta.name, "base");
282    }
283
284    // merge clears include on result
285    #[test]
286    fn merge_clears_include() {
287        let mut base = empty_config();
288        base.include = vec!["a.toml".into()];
289        let mut later = empty_config();
290        later.include = vec!["b.toml".into()];
291        let merged = merge(base, later);
292        assert!(merged.include.is_empty());
293    }
294
295    // merge_map: later key wins
296    #[test]
297    fn merge_map_later_wins() {
298        let mut base = BTreeMap::new();
299        base.insert("KEY".to_string(), "a".to_string());
300        let mut later = BTreeMap::new();
301        later.insert("KEY".to_string(), "b".to_string());
302        let merged = merge_map(base, later);
303        assert_eq!(merged["KEY"], "b");
304    }
305
306    // nonempty_or picks preferred when non-empty
307    #[test]
308    fn nonempty_or_picks_preferred() {
309        assert_eq!(nonempty_or("x".into(), "y".into()), "x");
310        assert_eq!(nonempty_or(String::new(), "y".into()), "y");
311    }
312}