opencode-ralph-loop-cli 0.1.0

Scaffolder CLI for OpenCode Ralph Loop plugin — one command setup
Documentation
use rust_embed::RustEmbed;

pub const TEMPLATE_VERSION: &str = "1.0.0";

#[derive(RustEmbed)]
#[folder = "templates/"]
pub struct Assets;

pub const DEFAULT_PLUGIN_VERSION: &str = "1.4.7";

pub struct RenderContext {
    pub plugin_version: String,
}

impl Default for RenderContext {
    fn default() -> Self {
        Self {
            plugin_version: DEFAULT_PLUGIN_VERSION.to_string(),
        }
    }
}

impl RenderContext {
    pub fn with_version(version: impl Into<String>) -> Self {
        Self {
            plugin_version: version.into(),
        }
    }
}

/// Renders a template by replacing placeholders according to the context.
/// Returns None if the template does not exist.
pub fn render_template(path: &str, ctx: &RenderContext) -> Option<Vec<u8>> {
    let file = Assets::get(path)?;
    if path == "package.json.template" {
        let content = std::str::from_utf8(&file.data).ok()?;
        let rendered = content.replace("{{PLUGIN_VERSION}}", &ctx.plugin_version);
        Some(rendered.into_bytes())
    } else {
        Some(file.data.into_owned())
    }
}

/// Converts a template path to a relative output path under .opencode/
pub fn template_to_output_path(template_path: &str) -> &'static str {
    match template_path {
        "plugins/ralph.ts" => "plugins/ralph.ts",
        "commands/ralph-loop.md" => "commands/ralph-loop.md",
        "commands/cancel-ralph.md" => "commands/cancel-ralph.md",
        "commands/ralph-help.md" => "commands/ralph-help.md",
        "package.json.template" => "package.json",
        ".gitignore.template" => ".gitignore",
        _ => "",
    }
}

/// Lists the 6 canonical templates in deterministic order.
pub fn canonical_templates() -> &'static [&'static str] {
    &[
        "plugins/ralph.ts",
        "commands/ralph-loop.md",
        "commands/cancel-ralph.md",
        "commands/ralph-help.md",
        "package.json.template",
        ".gitignore.template",
    ]
}

/// Returns the SHA-256 hex of an embedded template's content (post-render for package.json).
pub fn template_canonical_hash(path: &str, ctx: &RenderContext) -> Option<String> {
    let bytes = render_template(path, ctx)?;
    Some(crate::hash::sha256_hex(&bytes))
}

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

    #[test]
    fn all_canonical_templates_exist() {
        let ctx = RenderContext::default();
        for path in canonical_templates() {
            let result = render_template(path, &ctx);
            assert!(result.is_some(), "template not found: {path}");
        }
    }

    #[test]
    fn package_json_substitutes_version() {
        let ctx = RenderContext::with_version("9.9.9");
        let bytes = render_template("package.json.template", &ctx).unwrap();
        let content = String::from_utf8(bytes).unwrap();
        assert!(
            content.contains("9.9.9"),
            "version not substituted: {content}"
        );
        assert!(!content.contains("{{PLUGIN_VERSION}}"));
    }

    #[test]
    fn default_version_is_correct() {
        assert_eq!(DEFAULT_PLUGIN_VERSION, "1.4.7");
    }

    #[test]
    fn template_to_output_path_mappings() {
        assert_eq!(
            template_to_output_path("plugins/ralph.ts"),
            "plugins/ralph.ts"
        );
        assert_eq!(
            template_to_output_path("package.json.template"),
            "package.json"
        );
        assert_eq!(template_to_output_path(".gitignore.template"), ".gitignore");
    }
}