use std::collections::BTreeSet;
use std::env;
use std::path::{Path, PathBuf};
use crate::config::catalog;
use crate::config::local_paths::resolve_local_skill_sources;
use crate::config::schema::{ConfigDocument, OriginMap};
use crate::constants::{CONFIG_FILE, DEFAULT_CONFIG};
use crate::error::ForgeError;
use crate::fsutil::{read_to_string, write_file};
use crate::model::LoadedConfig;
use crate::paths::{config_dir, manifest_config_path};
pub fn load_config_with_overlays(
explicit_path: Option<&Path>,
overlay_paths: &[PathBuf],
) -> Result<LoadedConfig, ForgeError> {
let selected = if let Some(path) = explicit_path {
Some(path.to_path_buf())
} else {
let local = env::current_dir()
.map_err(|source| ForgeError::Io {
path: PathBuf::from("."),
source,
})?
.join(CONFIG_FILE);
let manifest = manifest_config_path();
let user = config_dir().join(CONFIG_FILE);
[local, manifest, user]
.into_iter()
.find(|path| path.is_file())
};
if let Some(path) = &selected {
catalog::validate_selection(&read_to_string(path)?, &path.display().to_string())?;
}
let (mut document, mut origins) = catalog::load_builtin()?;
if let Some(path) = &selected {
let contents = resolve_local_skill_sources(&read_to_string(path)?, path)?;
document.merge_primary_text(&contents, &path.display().to_string(), &mut origins)?;
}
load_includes(&mut document, selected.as_deref(), &mut origins)?;
for path in overlay_paths {
let contents = resolve_local_skill_sources(&read_to_string(path)?, path)?;
document.merge_overlay_text(&contents, &path.display().to_string(), &mut origins)?;
load_includes(&mut document, Some(path), &mut origins)?;
}
document.resolve_source_references(&mut origins)?;
document.resolve_version_references()?;
Ok(LoadedConfig {
document,
origins,
path: selected.clone(),
})
}
pub fn init_config(output: Option<&Path>, force: bool) -> Result<PathBuf, ForgeError> {
let path = match output {
Some(path) => path.to_path_buf(),
None => env::current_dir()
.map_err(|source| ForgeError::Io {
path: PathBuf::from("."),
source,
})?
.join(CONFIG_FILE),
};
if path.exists() && !force {
return Err(ForgeError::Config(format!(
"{} already exists; pass --force to overwrite it",
path.display()
)));
}
let default_config = DEFAULT_CONFIG.replace("\r\n", "\n").replace('\r', "\n");
write_file(&path, &default_config)?;
Ok(path)
}
fn load_includes(
document: &mut ConfigDocument,
selected_path: Option<&Path>,
origins: &mut OriginMap,
) -> Result<(), ForgeError> {
let Some(base) = selected_path.and_then(Path::parent) else {
if !document.include.is_empty() {
return Err(ForgeError::Config(
"embedded configuration cannot declare include; use an explicit configuration file"
.to_string(),
));
}
return Ok(());
};
let includes = std::mem::take(&mut document.include);
if includes.len() > 16 {
return Err(ForgeError::Config(
"include count cannot exceed 16".to_string(),
));
}
let mut seen = BTreeSet::new();
for relative in includes {
if relative.is_absolute() {
return Err(ForgeError::Config(format!(
"include only allows relative local paths: {}",
relative.display()
)));
}
let joined = base.join(&relative);
let canonical = joined.canonicalize().map_err(|source| ForgeError::Io {
path: joined.clone(),
source,
})?;
let canonical_base = base.canonicalize().map_err(|source| ForgeError::Io {
path: base.to_path_buf(),
source,
})?;
if !canonical.starts_with(&canonical_base) || !seen.insert(canonical.clone()) {
return Err(ForgeError::Config(format!(
"include escapes its root or is duplicated: {}",
relative.display()
)));
}
let contents = resolve_local_skill_sources(&read_to_string(&canonical)?, &canonical)?;
let overlay: toml::Value = toml::from_str(&contents)
.map_err(|error| ForgeError::Parse(format!("invalid TOML: {error}")))?;
if overlay.get("include").is_some() {
return Err(ForgeError::Config(format!(
"an include file cannot include another file: {}",
canonical.display()
)));
}
document.merge_overlay_text(&contents, &canonical.display().to_string(), origins)?;
}
Ok(())
}