main-spex 0.25.0

AI-first development framework that turns specifications into deterministic, verifiable code
use std::fs;
use std::path::Path;

use serde_yaml_ng::Value;

use crate::commands::init::scaffold::{copy_docs_and_schemas, write_rule};
use crate::error::Result;
use crate::rule::RULES_DIR;
use crate::support::fs::{create_dir_all, write_embedded_dir, write_file};
use crate::support::managed_section::replace_managed_section;
use crate::support::yaml_dir::yaml_files;
use crate::templates::{AGENTS_DIRNAME, AGENTS_FILENAME, BUNDLED_TEMPLATES_DIR, SKILLS_DIRNAME};

#[cfg(test)]
#[path = "sync_templates_test.rs"]
mod sync_templates_test;

const SKILL_FILE: &str = "SKILL.md";

pub fn sync_templates(spex_dir: &Path, agent_root: &Path, removed_rules: &[String]) -> Result<()> {
    copy_docs_and_schemas(spex_dir)?;
    sync_rules(spex_dir, removed_rules)?;
    sync_skills(agent_root)?;
    sync_agents_md(agent_root)
}

fn sync_agents_md(agent_root: &Path) -> Result<()> {
    let Some(template) = BUNDLED_TEMPLATES_DIR.get_file(AGENTS_FILENAME) else {
        return Ok(());
    };

    let agents = agent_root.join(AGENTS_FILENAME);
    let Ok(local_contents) = fs::read_to_string(&agents) else {
        return Ok(());
    };

    let updated = replace_managed_section(template.contents(), &local_contents);
    if updated != local_contents.as_bytes() {
        write_file(&agents, updated)?;
    }
    Ok(())
}

fn is_synced_with_upstream(spec_path: &Path) -> bool {
    fs::read_to_string(spec_path)
        .ok()
        .and_then(|raw| serde_yaml_ng::from_str::<Value>(&raw).ok())
        .and_then(|spec| spec.get("sync_with_upstream")?.as_bool())
        .unwrap_or(true)
}

fn frontmatter(contents: &str) -> Option<&str> {
    let rest = contents.strip_prefix("---")?;
    let end = rest.find("\n---")?;
    Some(&rest[..end])
}

fn skill_synced_with_upstream(skill_md_path: &Path) -> bool {
    fs::read_to_string(skill_md_path)
        .ok()
        .and_then(|raw| serde_yaml_ng::from_str::<Value>(frontmatter(&raw)?).ok())
        .and_then(|frontmatter| {
            frontmatter
                .get("metadata")?
                .get("sync-with-upstream")?
                .as_bool()
        })
        .unwrap_or(true)
}

fn sync_rules(spex_dir: &Path, removed_rules: &[String]) -> Result<()> {
    let Some(dir) = BUNDLED_TEMPLATES_DIR.get_dir(RULES_DIR) else {
        return Ok(());
    };

    let rules_dest = spex_dir.join(RULES_DIR);
    create_dir_all(&rules_dest)?;

    let existing_rules_in_any_folder = yaml_files(&rules_dest)?;

    for file in dir.files() {
        let Some(name) = file.path().file_name() else {
            continue;
        };
        let dest = existing_rules_in_any_folder
            .iter()
            .find(|path| path.file_name() == Some(name))
            .cloned()
            .unwrap_or_else(|| rules_dest.join(name));
        let rule_id = dest.file_stem().unwrap_or_default().to_string_lossy();

        if dest.exists() {
            if !is_synced_with_upstream(&dest) {
                continue;
            }
        } else if removed_rules
            .iter()
            .any(|id| id.eq_ignore_ascii_case(rule_id.as_ref()))
        {
            continue;
        }

        write_rule(file, &dest)?;
    }

    Ok(())
}

fn sync_skills(agent_root: &Path) -> Result<()> {
    let Some(skills) = BUNDLED_TEMPLATES_DIR.get_dir(SKILLS_DIRNAME) else {
        return Ok(());
    };

    let agents_dir = agent_root.join(AGENTS_DIRNAME);
    let dest_root = agents_dir.join(SKILLS_DIRNAME);
    create_dir_all(&dest_root)?;

    for skill in skills.dirs() {
        let name = skill.path().file_name().unwrap_or_default();
        let dest = dest_root.join(name);
        let skill_md = dest.join(SKILL_FILE);

        if dest.exists() && !skill_synced_with_upstream(&skill_md) {
            continue;
        }

        let local_skill_md = fs::read_to_string(&skill_md).ok();
        write_embedded_dir(skill, &agents_dir)?;

        if let Some(local) = local_skill_md
            && let Some(template) = skill.get_file(skill.path().join(SKILL_FILE))
        {
            write_file(
                &skill_md,
                replace_managed_section(template.contents(), &local),
            )?;
        }
    }

    Ok(())
}