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 }
203}
204
205fn 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
224fn 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
233fn 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
243fn 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#[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 #[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 #[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 #[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 #[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 #[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}