1use std::collections::BTreeSet;
4use std::env;
5use std::path::{Path, PathBuf};
6
7use crate::config::catalog;
8use crate::config::local_paths::resolve_local_skill_sources;
9use crate::config::schema::{ConfigDocument, OriginMap};
10use crate::constants::{CONFIG_FILE, DEFAULT_CONFIG};
11use crate::error::ForgeError;
12use crate::fsutil::{read_to_string, write_file};
13use crate::model::LoadedConfig;
14use crate::paths::{config_dir, manifest_config_path};
15
16pub fn load_config_with_overlays(
27 explicit_path: Option<&Path>,
28 overlay_paths: &[PathBuf],
29) -> Result<LoadedConfig, ForgeError> {
30 let selected = if let Some(path) = explicit_path {
31 Some(path.to_path_buf())
32 } else {
33 let local = env::current_dir()
34 .map_err(|source| ForgeError::Io {
35 path: PathBuf::from("."),
36 source,
37 })?
38 .join(CONFIG_FILE);
39 let manifest = manifest_config_path();
40 let user = config_dir().join(CONFIG_FILE);
41 [local, manifest, user]
42 .into_iter()
43 .find(|path| path.is_file())
44 };
45 if let Some(path) = &selected {
46 catalog::validate_selection(&read_to_string(path)?, &path.display().to_string())?;
47 }
48 let (mut document, mut origins) = catalog::load_builtin()?;
49 if let Some(path) = &selected {
50 let contents = resolve_local_skill_sources(&read_to_string(path)?, path)?;
51 document.merge_primary_text(&contents, &path.display().to_string(), &mut origins)?;
52 }
53 load_includes(&mut document, selected.as_deref(), &mut origins)?;
54 for path in overlay_paths {
55 let contents = resolve_local_skill_sources(&read_to_string(path)?, path)?;
56 document.merge_overlay_text(&contents, &path.display().to_string(), &mut origins)?;
57 load_includes(&mut document, Some(path), &mut origins)?;
58 }
59 document.resolve_source_references(&mut origins)?;
60 document.resolve_version_references()?;
61 Ok(LoadedConfig {
62 document,
63 origins,
64 path: selected.clone(),
65 })
66}
67
68pub fn init_config(output: Option<&Path>, force: bool) -> Result<PathBuf, ForgeError> {
75 let path = match output {
76 Some(path) => path.to_path_buf(),
77 None => env::current_dir()
78 .map_err(|source| ForgeError::Io {
79 path: PathBuf::from("."),
80 source,
81 })?
82 .join(CONFIG_FILE),
83 };
84 if path.exists() && !force {
85 return Err(ForgeError::Config(format!(
86 "{} already exists; pass --force to overwrite it",
87 path.display()
88 )));
89 }
90 let default_config = DEFAULT_CONFIG.replace("\r\n", "\n").replace('\r', "\n");
94 write_file(&path, &default_config)?;
95 Ok(path)
96}
97
98fn load_includes(
99 document: &mut ConfigDocument,
100 selected_path: Option<&Path>,
101 origins: &mut OriginMap,
102) -> Result<(), ForgeError> {
103 let Some(base) = selected_path.and_then(Path::parent) else {
104 if !document.include.is_empty() {
105 return Err(ForgeError::Config(
106 "embedded configuration cannot declare include; use an explicit configuration file"
107 .to_string(),
108 ));
109 }
110 return Ok(());
111 };
112 let includes = std::mem::take(&mut document.include);
113 if includes.len() > 16 {
114 return Err(ForgeError::Config(
115 "include count cannot exceed 16".to_string(),
116 ));
117 }
118 let mut seen = BTreeSet::new();
119 for relative in includes {
120 if relative.is_absolute() {
121 return Err(ForgeError::Config(format!(
122 "include only allows relative local paths: {}",
123 relative.display()
124 )));
125 }
126 let joined = base.join(&relative);
127 let canonical = joined.canonicalize().map_err(|source| ForgeError::Io {
128 path: joined.clone(),
129 source,
130 })?;
131 let canonical_base = base.canonicalize().map_err(|source| ForgeError::Io {
132 path: base.to_path_buf(),
133 source,
134 })?;
135 if !canonical.starts_with(&canonical_base) || !seen.insert(canonical.clone()) {
136 return Err(ForgeError::Config(format!(
137 "include escapes its root or is duplicated: {}",
138 relative.display()
139 )));
140 }
141 let contents = resolve_local_skill_sources(&read_to_string(&canonical)?, &canonical)?;
142 let overlay: toml::Value = toml::from_str(&contents)
143 .map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
144 if overlay.get("include").is_some() {
145 return Err(ForgeError::Config(format!(
146 "an include file cannot include another file: {}",
147 canonical.display()
148 )));
149 }
150 document.merge_overlay_text(&contents, &canonical.display().to_string(), origins)?;
151 }
152 Ok(())
153}