use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::{fs, io};
use glob::glob;
use crate::config::{Config, ConfigError, Meta, parse_str};
const DEFAULT_MAX_DEPTH: usize = 8;
#[derive(Debug, thiserror::Error)]
pub enum IncludeError {
#[error("{0}")]
Config(#[from] ConfigError),
#[error("read {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("include cycle detected: {chain}")]
Cycle {
chain: String,
},
#[error("include depth limit exceeded (max {max}) at {last}")]
DepthExceeded {
max: usize,
last: PathBuf,
},
#[error("glob pattern {pattern:?} from {base}: {reason}")]
Glob {
pattern: String,
base: PathBuf,
reason: String,
},
}
pub fn load_with_includes(path: impl AsRef<Path>) -> Result<Config, IncludeError> {
let path = path.as_ref();
let raw = read_file(path)?;
let cfg = parse_str(&raw, path)?;
let base_dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let canon = canonicalize_for_cycle(path)?;
let mut visited = vec![canon];
expand_includes_inner(cfg, &base_dir, &mut visited, 0)
}
pub fn expand_includes(cfg: Config, base_dir: &Path) -> Result<Config, IncludeError> {
let mut visited: Vec<PathBuf> = Vec::new();
expand_includes_inner(cfg, base_dir, &mut visited, 0)
}
fn expand_includes_inner(
cfg: Config,
base_dir: &Path,
visited: &mut Vec<PathBuf>,
depth: usize,
) -> Result<Config, IncludeError> {
if cfg.include.is_empty() {
let mut out = cfg;
out.include.clear();
return Ok(out);
}
let mut matched_paths: Vec<PathBuf> = Vec::new();
for pattern in &cfg.include {
let full_pattern = base_dir.join(pattern);
let pattern_str = full_pattern.to_string_lossy().into_owned();
let entries = glob(&pattern_str).map_err(|e| IncludeError::Glob {
pattern: pattern.clone(),
base: base_dir.to_path_buf(),
reason: e.to_string(),
})?;
let mut local: Vec<PathBuf> = entries.filter_map(|res| res.ok()).collect();
local.sort();
for p in local {
if !matched_paths.contains(&p) {
matched_paths.push(p);
}
}
}
let mut merged = cfg;
merged.include.clear();
for inc_path in matched_paths {
if depth + 1 > DEFAULT_MAX_DEPTH {
return Err(IncludeError::DepthExceeded {
max: DEFAULT_MAX_DEPTH,
last: inc_path,
});
}
let canon = canonicalize_for_cycle(&inc_path)?;
if visited.contains(&canon) {
let chain = build_cycle_chain(visited, &canon);
return Err(IncludeError::Cycle { chain });
}
let raw = read_file(&inc_path)?;
let inc_cfg = parse_str(&raw, &inc_path)?;
let inc_base = inc_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
visited.push(canon.clone());
let inc_cfg = expand_includes_inner(inc_cfg, &inc_base, visited, depth + 1)?;
visited.pop();
merged = merge(merged, inc_cfg);
}
Ok(merged)
}
fn merge(base: Config, later: Config) -> Config {
Config {
meta: merge_meta(base.meta, later.meta),
include: Vec::new(),
paths: merge_map(base.paths, later.paths),
links: concat(base.links, later.links),
templates: concat(base.templates, later.templates),
prompts: merge_map(base.prompts, later.prompts),
deps: concat(base.deps, later.deps),
hooks: concat(base.hooks, later.hooks),
commands: concat(base.commands, later.commands),
}
}
fn merge_meta(base: Meta, later: Meta) -> Meta {
Meta {
name: nonempty_or(later.name, base.name),
description: nonempty_or(later.description, base.description),
krypt_min: later.krypt_min.or(base.krypt_min),
notify_backend: later.notify_backend.or(base.notify_backend),
}
}
fn nonempty_or(preferred: String, fallback: String) -> String {
if preferred.is_empty() {
fallback
} else {
preferred
}
}
fn concat<T>(mut a: Vec<T>, b: Vec<T>) -> Vec<T> {
a.extend(b);
a
}
fn merge_map<V>(mut base: BTreeMap<String, V>, later: BTreeMap<String, V>) -> BTreeMap<String, V> {
base.extend(later);
base
}
fn read_file(path: &Path) -> Result<String, IncludeError> {
fs::read_to_string(path).map_err(|source| IncludeError::Io {
path: path.to_path_buf(),
source,
})
}
fn canonicalize_for_cycle(path: &Path) -> Result<PathBuf, IncludeError> {
std::fs::canonicalize(path).map_err(|source| IncludeError::Io {
path: path.to_path_buf(),
source,
})
}
fn build_cycle_chain(visited: &[PathBuf], repeated: &Path) -> String {
let mut parts: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
parts.push(repeated.display().to_string());
parts.join(" -> ")
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_config() -> Config {
Config::default()
}
fn config_with_name(name: &str) -> Config {
let mut c = Config::default();
c.meta.name = name.to_string();
c
}
#[test]
fn merge_meta_later_name_wins() {
let base = config_with_name("base");
let later = config_with_name("later");
let merged = merge(base, later);
assert_eq!(merged.meta.name, "later");
}
#[test]
fn merge_meta_empty_later_keeps_base() {
let base = config_with_name("base");
let later = empty_config();
let merged = merge(base, later);
assert_eq!(merged.meta.name, "base");
}
#[test]
fn merge_clears_include() {
let mut base = empty_config();
base.include = vec!["a.toml".into()];
let mut later = empty_config();
later.include = vec!["b.toml".into()];
let merged = merge(base, later);
assert!(merged.include.is_empty());
}
#[test]
fn merge_map_later_wins() {
let mut base = BTreeMap::new();
base.insert("KEY".to_string(), "a".to_string());
let mut later = BTreeMap::new();
later.insert("KEY".to_string(), "b".to_string());
let merged = merge_map(base, later);
assert_eq!(merged["KEY"], "b");
}
#[test]
fn nonempty_or_picks_preferred() {
assert_eq!(nonempty_or("x".into(), "y".into()), "x");
assert_eq!(nonempty_or(String::new(), "y".into()), "y");
}
}