bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Configuration source selection, overlay merging, and initialization.

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};

/// Load the built-in catalog, selected primary file, includes, and ordered overlays.
///
/// When `explicit_path` is absent, selection checks the working directory, repository template,
/// and user configuration directory in that order. The returned document has source and version
/// references resolved, and [`LoadedConfig::origins`] records field provenance.
///
/// # Errors
///
/// Returns [`ForgeError`] for inaccessible files, invalid TOML, rejected catalog selection,
/// include boundary violations, merge conflicts, or failed reference resolution.
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(),
    })
}

/// Write the embedded default configuration into the current directory.
///
/// # Errors
///
/// Returns [`ForgeError`] when the working directory is unavailable, the target already exists
/// without `force`, or the file cannot be written.
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()
        )));
    }
    // Keep the generated configuration stable across checkout and host line-ending policies.
    // `include_str!` preserves the bytes present in the source tree, while config files are a
    // cross-platform user-facing contract and should always use LF.
    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(())
}