oxframe-cli 0.17.0

The `oxframe` command: create, preview with hot reload, render and check oxframe projects
//! Instructions for AI coding agents, written into projects by `oxframe init`
//! and refreshed by `oxframe agents`.

use std::path::Path;

use crate::exit;

pub(crate) const AGENTS_MD: &str = include_str!("../templates/agents/AGENTS.md");
const CLAUDE_MD: &str = include_str!("../templates/agents/CLAUDE.md");
pub(crate) const SKILL_MD: &str = include_str!("../templates/agents/SKILL.md");
pub(crate) const REFERENCE_MD: &str = include_str!("../templates/agents/reference.md");

/// Version marker inside the generated files.
const MARKER: &str = "oxframe-agents ";

/// The files for a project on oxframe `sdk` (links point at that version's
/// docs, so agents never write code against another version's API).
fn files(sdk: &str) -> [(&'static str, String); 4] {
    let v = env!("CARGO_PKG_VERSION");
    let series = sdk.split('.').take(2).collect::<Vec<_>>().join(".");
    let fill = |t: &str| {
        t.replace("{{VERSION}}", v)
            .replace("{{SDK_VERSION}}", sdk)
            .replace("{{DOCS_SERIES}}", &series)
    };
    [
        ("AGENTS.md", fill(AGENTS_MD)),
        ("CLAUDE.md", fill(CLAUDE_MD)),
        (".claude/skills/oxframe/SKILL.md", fill(SKILL_MD)),
        (".claude/skills/oxframe/reference.md", fill(REFERENCE_MD)),
    ]
}

/// Whether `existing` was generated by us (and may be replaced).
fn is_ours(existing: &str) -> bool {
    existing.contains(MARKER) || existing.trim() == "@AGENTS.md"
}

#[derive(Debug, Default, PartialEq, Eq)]
pub struct Report {
    pub written: Vec<String>,
    pub skipped: Vec<String>,
}

/// Writes the agent files into `dir`. Files written by the user (without our
/// marker) are kept unless `force`.
pub fn write(dir: &Path, force: bool) -> std::io::Result<Report> {
    let mut report = Report::default();
    // The project's pinned SDK, else the one this CLI creates projects with.
    let sdk = std::fs::read_to_string(dir.join("Cargo.toml"))
        .ok()
        .and_then(|t| crate::upgrade::project_sdk_version(&t))
        .unwrap_or_else(|| crate::init::SDK_VERSION.to_owned());
    for (rel, content) in files(&sdk) {
        let path = dir.join(rel);
        if let Ok(existing) = std::fs::read_to_string(&path) {
            if existing == content {
                continue;
            }
            if !force && !is_ours(&existing) {
                report.skipped.push(rel.to_owned());
                continue;
            }
        }
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(&path, content)?;
        report.written.push(rel.to_owned());
    }
    Ok(report)
}

/// `oxframe agents [--force]`.
pub fn run(force: bool) -> u8 {
    let root = match crate::config::project_root() {
        Ok(r) => r,
        Err(e) => {
            crate::error(e);
            return exit::ENV;
        }
    };
    match write(&root, force) {
        Ok(r) => {
            for f in &r.written {
                crate::note(format!("wrote {f}"));
            }
            for f in &r.skipped {
                crate::note(format!("kept {f} (edited by you; use --force to replace)"));
            }
            if r.written.is_empty() && r.skipped.is_empty() {
                crate::note("agent instructions are up to date");
            }
            exit::OK
        }
        Err(e) => {
            crate::error(format!("cannot write agent files: {e}"));
            exit::ENV
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn writes_updates_and_respects_user_files() {
        let tmp = tempfile::tempdir().unwrap();
        let r = write(tmp.path(), false).unwrap();
        assert_eq!(r.written.len(), 4);
        let skill =
            std::fs::read_to_string(tmp.path().join(".claude/skills/oxframe/SKILL.md")).unwrap();
        assert!(skill.starts_with("---\nname: oxframe\ndescription: "));
        assert!(!skill.contains("{{VERSION}}"));
        // Idempotent.
        assert_eq!(write(tmp.path(), false).unwrap(), Report::default());
        // An older generated file is refreshed; a user's own file is kept.
        std::fs::write(
            tmp.path().join("AGENTS.md"),
            "<!-- oxframe-agents 0.0.1 -->\nold",
        )
        .unwrap();
        std::fs::write(tmp.path().join("CLAUDE.md"), "my own notes").unwrap();
        let r = write(tmp.path(), false).unwrap();
        assert_eq!(r.written, vec!["AGENTS.md"]);
        assert_eq!(r.skipped, vec!["CLAUDE.md"]);
        assert_eq!(write(tmp.path(), true).unwrap().written, vec!["CLAUDE.md"]);
    }

    #[test]
    fn links_the_docs_of_the_projects_version() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(
            tmp.path().join("Cargo.toml"),
            "[package]\nname = \"a\"\n\n[dependencies]\noxframe = { version = \"=0.3.2\" }\n",
        )
        .unwrap();
        write(tmp.path(), false).unwrap();
        let agents = std::fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap();
        assert!(agents.contains("oxframe 0.3.2"));
        assert!(agents.contains("https://oxframe.dev/docs/0.3\n"));
        assert!(agents.contains("https://oxframe.dev/docs/0.3/llms.txt"));
        assert!(agents.contains("https://oxframe.dev/docs/0.3/llms-full.txt"));
        assert!(agents.contains("https://docs.rs/oxframe/0.3.2/oxframe/"));
        assert!(!agents.contains("{{"));
    }
}