Skip to main content

bot_forge/config/
load.rs

1//! Configuration source selection, overlay merging, and initialization.
2
3use 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
16/// Load the built-in catalog, selected primary file, includes, and ordered overlays.
17///
18/// When `explicit_path` is absent, selection checks the working directory, repository template,
19/// and user configuration directory in that order. The returned document has source and version
20/// references resolved, and [`LoadedConfig::origins`] records field provenance.
21///
22/// # Errors
23///
24/// Returns [`ForgeError`] for inaccessible files, invalid TOML, rejected catalog selection,
25/// include boundary violations, merge conflicts, or failed reference resolution.
26pub 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
68/// Write the embedded default configuration into the current directory.
69///
70/// # Errors
71///
72/// Returns [`ForgeError`] when the working directory is unavailable, the target already exists
73/// without `force`, or the file cannot be written.
74pub 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    // Keep the generated configuration stable across checkout and host line-ending policies.
91    // `include_str!` preserves the bytes present in the source tree, while config files are a
92    // cross-platform user-facing contract and should always use LF.
93    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}