ccd-cli 1.0.0-alpha.2

Bootstrap and validate Continuous Context Development repositories
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

pub(crate) const DEFAULT_GEMINI_HEADER: &str =
    "Gemini-specific refresh behavior belongs in tooling, not in a second policy file.";
pub(crate) const SKILL_CANONICAL_DIR: &str = "skills/canonical";

#[derive(Clone, Copy)]
pub(crate) struct HostSkillHeaders<'a> {
    pub(crate) claude: &'a str,
    pub(crate) gemini: &'a str,
}

pub(crate) struct EmbeddedSkillFile {
    pub(crate) skill: &'static str,
    pub(crate) relative_path: &'static str,
    pub(crate) contents: &'static str,
}

pub(crate) struct RepoCanonicalSkillFile {
    pub(crate) relative_path: PathBuf,
    pub(crate) contents: String,
}

include!(concat!(env!("OUT_DIR"), "/embedded_skills.rs"));

pub(crate) fn embedded_skill_files() -> &'static [EmbeddedSkillFile] {
    EMBEDDED_SKILL_FILES
}

pub(crate) fn load_repo_canonical_skill_files(
    repo_root: &Path,
) -> Result<Vec<RepoCanonicalSkillFile>> {
    let canonical_root = repo_root.join(SKILL_CANONICAL_DIR);
    if !canonical_root.is_dir() {
        return Ok(Vec::new());
    }

    let relative_paths = collect_relative_files(&canonical_root, &canonical_root)?;
    let mut files = Vec::with_capacity(relative_paths.len());

    for relative_path in relative_paths {
        let source = canonical_root.join(&relative_path);
        let contents = fs::read_to_string(&source)
            .with_context(|| format!("failed to read {}", source.display()))?;
        files.push(RepoCanonicalSkillFile {
            relative_path,
            contents,
        });
    }

    Ok(files)
}

pub(crate) fn render_skill_for_runtime(
    relative_path: &Path,
    contents: &str,
    runtime_name: &str,
    headers: HostSkillHeaders<'_>,
) -> String {
    if !should_render_runtime_header(relative_path) {
        return contents.to_owned();
    }

    match runtime_name {
        "gemini" if !headers.gemini.is_empty() => {
            format!("> {}\n\n{}", headers.gemini, contents)
        }
        "claude" if !headers.claude.is_empty() => {
            format!("> {}\n\n{}", headers.claude, contents)
        }
        _ => contents.to_owned(),
    }
}

fn should_render_runtime_header(relative_path: &Path) -> bool {
    relative_path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md")
}

pub(crate) fn collect_relative_files(root: &Path, dir: &Path) -> Result<Vec<PathBuf>> {
    let mut entries: Vec<_> = fs::read_dir(dir)
        .with_context(|| format!("failed to read directory {}", dir.display()))?
        .collect::<std::result::Result<Vec<_>, _>>()
        .with_context(|| format!("failed to list directory {}", dir.display()))?;
    entries.sort_by_key(|entry| entry.file_name());

    let mut files = Vec::new();

    for entry in entries {
        let path = entry.path();
        let file_type = entry
            .file_type()
            .with_context(|| format!("failed to inspect {}", path.display()))?;

        if file_type.is_dir() {
            files.extend(collect_relative_files(root, &path)?);
            continue;
        }

        if file_type.is_file() {
            let relative = path
                .strip_prefix(root)
                .with_context(|| format!("failed to relativize {}", path.display()))?;
            files.push(relative.to_path_buf());
        }
    }

    Ok(files)
}