Skip to main content

opencode_ralph_loop_cli/templates/
mod.rs

1use rust_embed::RustEmbed;
2
3pub const TEMPLATE_VERSION: &str = "1.0.0";
4
5#[derive(RustEmbed)]
6#[folder = "templates/"]
7pub struct Assets;
8
9pub const DEFAULT_PLUGIN_VERSION: &str = "1.4.7";
10
11pub struct RenderContext {
12    pub plugin_version: String,
13}
14
15impl Default for RenderContext {
16    fn default() -> Self {
17        Self {
18            plugin_version: DEFAULT_PLUGIN_VERSION.to_string(),
19        }
20    }
21}
22
23impl RenderContext {
24    pub fn with_version(version: impl Into<String>) -> Self {
25        Self {
26            plugin_version: version.into(),
27        }
28    }
29}
30
31/// Renders a template by replacing placeholders according to the context.
32/// Returns None if the template does not exist.
33pub fn render_template(path: &str, ctx: &RenderContext) -> Option<Vec<u8>> {
34    let file = Assets::get(path)?;
35    if path == "package.json.template" {
36        let content = std::str::from_utf8(&file.data).ok()?;
37        let rendered = content.replace("{{PLUGIN_VERSION}}", &ctx.plugin_version);
38        Some(rendered.into_bytes())
39    } else {
40        Some(file.data.into_owned())
41    }
42}
43
44/// Converts a template path to a relative output path under .opencode/
45pub fn template_to_output_path(template_path: &str) -> &'static str {
46    match template_path {
47        "plugins/ralph.ts" => "plugins/ralph.ts",
48        "commands/ralph-loop.md" => "commands/ralph-loop.md",
49        "commands/cancel-ralph.md" => "commands/cancel-ralph.md",
50        "commands/ralph-help.md" => "commands/ralph-help.md",
51        "package.json.template" => "package.json",
52        ".gitignore.template" => ".gitignore",
53        _ => "",
54    }
55}
56
57/// Lists the 6 canonical templates in deterministic order.
58pub fn canonical_templates() -> &'static [&'static str] {
59    &[
60        "plugins/ralph.ts",
61        "commands/ralph-loop.md",
62        "commands/cancel-ralph.md",
63        "commands/ralph-help.md",
64        "package.json.template",
65        ".gitignore.template",
66    ]
67}
68
69/// Returns the SHA-256 hex of an embedded template's content (post-render for package.json).
70pub fn template_canonical_hash(path: &str, ctx: &RenderContext) -> Option<String> {
71    let bytes = render_template(path, ctx)?;
72    Some(crate::hash::sha256_hex(&bytes))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn all_canonical_templates_exist() {
81        let ctx = RenderContext::default();
82        for path in canonical_templates() {
83            let result = render_template(path, &ctx);
84            assert!(result.is_some(), "template not found: {path}");
85        }
86    }
87
88    #[test]
89    fn package_json_substitutes_version() {
90        let ctx = RenderContext::with_version("9.9.9");
91        let bytes = render_template("package.json.template", &ctx).unwrap();
92        let content = String::from_utf8(bytes).unwrap();
93        assert!(
94            content.contains("9.9.9"),
95            "version not substituted: {content}"
96        );
97        assert!(!content.contains("{{PLUGIN_VERSION}}"));
98    }
99
100    #[test]
101    fn default_version_is_correct() {
102        assert_eq!(DEFAULT_PLUGIN_VERSION, "1.4.7");
103    }
104
105    #[test]
106    fn template_to_output_path_mappings() {
107        assert_eq!(
108            template_to_output_path("plugins/ralph.ts"),
109            "plugins/ralph.ts"
110        );
111        assert_eq!(
112            template_to_output_path("package.json.template"),
113            "package.json"
114        );
115        assert_eq!(template_to_output_path(".gitignore.template"), ".gitignore");
116    }
117}