1use 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
21const DEFAULT_MAX_DEPTH: usize = 8;
23
24#[derive(Debug, thiserror::Error)]
28pub enum IncludeError {
29 #[error("{0}")]
31 Config(#[from] ConfigError),
32
33 #[error("read {path}: {source}")]
35 Io {
36 path: PathBuf,
38 #[source]
40 source: io::Error,
41 },
42
43 #[error("include cycle detected: {chain}")]
45 Cycle {
46 chain: String,
48 },
49
50 #[error("include depth limit exceeded (max {max}) at {last}")]
52 DepthExceeded {
53 max: usize,
55 last: PathBuf,
57 },
58
59 #[error("glob pattern {pattern:?} from {base}: {reason}")]
61 Glob {
62 pattern: String,
64 base: PathBuf,
66 reason: String,
68 },
69}
70
71pub 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
91pub 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
101fn 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 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 let mut merged = cfg;
136 merged.include.clear();
137
138 for inc_path in matched_paths {
139 if depth + 1 > DEFAULT_MAX_DEPTH {
141 return Err(IncludeError::DepthExceeded {
142 max: DEFAULT_MAX_DEPTH,
143 last: inc_path,
144 });
145 }
146
147 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 let raw = read_file(&inc_path)?;
156 let inc_cfg = parse_str(&raw, &inc_path)?;
157
158 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 merged = merge(merged, inc_cfg);
169 }
170
171 Ok(merged)
172}
173
174fn 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
206fn 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
225fn 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
234fn 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
244fn 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#[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 #[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 #[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 #[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 #[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 #[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}