Skip to main content

ctx/harness/
templates.rs

1//! Embedded templates for `ctx harness init`.
2//!
3//! All generated files come from raw template files embedded into the binary
4//! with `include_str!`. Templates use `{{TOKEN}}` placeholders:
5//!
6//! - `{{CTX_VERSION}}` -- this binary's crate version
7//! - `{{AUTHOR_NAME}}` -- crate authors with `<email>` stripped
8//! - `{{DEFAULT_BRANCH}}` -- `origin/HEAD` short name, falling back to `main`
9//!
10//! The checksum header (see [`super::checksum`]) is applied *after*
11//! rendering, when the final content is known.
12
13use std::path::Path;
14use std::process::Command;
15
16use super::checksum::{finalize, style_for_path};
17
18pub const SESSION_START_SH: &str = include_str!("templates/session-start.sh");
19pub const POST_TOOL_USE_SH: &str = include_str!("templates/post-tool-use.sh");
20pub const STOP_SH: &str = include_str!("templates/stop.sh");
21pub const RULES_TOML: &str = include_str!("templates/rules.toml");
22pub const SETTINGS_SNIPPET_JSON: &str = include_str!("templates/settings-snippet.json");
23pub const CLAUDE_MD_BLOCK_MD: &str = include_str!("templates/claude-md-block.md");
24pub const PLUGIN_JSON: &str = include_str!("templates/plugin.json");
25pub const HOOKS_JSON: &str = include_str!("templates/hooks.json");
26pub const MCP_JSON: &str = include_str!("templates/mcp.json");
27pub const MARKETPLACE_JSON: &str = include_str!("templates/marketplace.json");
28pub const PLUGIN_SETTINGS_JSON: &str = include_str!("templates/plugin-settings.json");
29pub const SKILL_MD: &str = include_str!("templates/SKILL.md");
30pub const PLUGIN_README_MD: &str = include_str!("templates/plugin-README.md");
31pub const CODEX_SESSION_START_SH: &str = include_str!("templates/codex-session-start.sh");
32pub const CODEX_POST_TOOL_USE_SH: &str = include_str!("templates/codex-post-tool-use.sh");
33pub const CODEX_STOP_SH: &str = include_str!("templates/codex-stop.sh");
34pub const CODEX_HOOKS_JSON: &str = include_str!("templates/codex-hooks.json");
35pub const CODEX_PLUGIN_HOOKS_JSON: &str = include_str!("templates/codex-plugin-hooks.json");
36pub const CODEX_PLUGIN_JSON: &str = include_str!("templates/codex-plugin.json");
37pub const CODEX_PLUGIN_MCP_JSON: &str = include_str!("templates/codex-plugin-mcp.json");
38pub const CODEX_MARKETPLACE_JSON: &str = include_str!("templates/codex-marketplace.json");
39pub const AGENTS_MD_BLOCK_MD: &str = include_str!("templates/agents-md-block.md");
40pub const CODEX_PLUGIN_README_MD: &str = include_str!("templates/codex-plugin-README.md");
41
42/// This binary's version (the value baked into `{{CTX_VERSION}}`).
43pub const CTX_VERSION: &str = env!("CARGO_PKG_VERSION");
44
45/// Replace every `{{KEY}}` in `tmpl` with its value.
46pub fn render(tmpl: &str, vars: &[(&str, &str)]) -> String {
47    let mut out = tmpl.to_string();
48    for (key, value) in vars {
49        out = out.replace(&format!("{{{{{key}}}}}"), value);
50    }
51    out
52}
53
54/// Crate authors with `<email>` parts stripped (`a <x@y>:b` -> `a, b`).
55pub fn author_name() -> String {
56    env!("CARGO_PKG_AUTHORS")
57        .split(':')
58        .map(|author| match author.split_once('<') {
59            Some((name, _)) => name.trim(),
60            None => author.trim(),
61        })
62        .filter(|name| !name.is_empty())
63        .collect::<Vec<_>>()
64        .join(", ")
65}
66
67/// The repository's default branch: `git symbolic-ref --short
68/// refs/remotes/origin/HEAD` with the `origin/` prefix stripped, falling
69/// back to `main` when git or the remote ref is unavailable.
70pub fn default_branch(root: &Path) -> String {
71    let output = Command::new("git")
72        .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
73        .current_dir(root)
74        .output();
75    if let Ok(output) = output {
76        if output.status.success() {
77            let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
78            let name = name.strip_prefix("origin/").unwrap_or(&name).to_string();
79            if !name.is_empty() {
80                return name;
81            }
82        }
83    }
84    "main".to_string()
85}
86
87/// The standard token set for rendering, with this binary's version.
88pub fn standard_vars<'a>(default_branch: &'a str, author: &'a str) -> Vec<(&'static str, &'a str)> {
89    vec![
90        ("CTX_VERSION", CTX_VERSION),
91        ("AUTHOR_NAME", author),
92        ("DEFAULT_BRANCH", default_branch),
93    ]
94}
95
96/// Render a hook script template with an explicit `{{CTX_VERSION}}` and
97/// return the finalized (headered + checksummed) content.
98///
99/// `name` is one of `session-start`, `post-tool-use`, `stop`. Used by tests
100/// to bake an arbitrary version into the compat guard (fail-open behavior)
101/// without depending on the crate version.
102pub fn render_hook_with_version(name: &str, version: &str, default_branch: &str) -> Option<String> {
103    let tmpl = match name {
104        "session-start" => SESSION_START_SH,
105        "post-tool-use" => POST_TOOL_USE_SH,
106        "stop" => STOP_SH,
107        _ => return None,
108    };
109    let rendered = render(
110        tmpl,
111        &[("CTX_VERSION", version), ("DEFAULT_BRANCH", default_branch)],
112    );
113    let rel = format!("{name}.sh");
114    Some(finalize(&rendered, style_for_path(&rel), version))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_render_replaces_all_tokens() {
123        let out = render(
124            "v{{CTX_VERSION}} by {{AUTHOR_NAME}} on {{DEFAULT_BRANCH}}",
125            &[
126                ("CTX_VERSION", "1.0.0"),
127                ("AUTHOR_NAME", "Jane"),
128                ("DEFAULT_BRANCH", "main"),
129            ],
130        );
131        assert_eq!(out, "v1.0.0 by Jane on main");
132    }
133
134    #[test]
135    fn test_author_name_strips_email() {
136        // The crate's own authors field must render to a non-empty name
137        // without angle brackets.
138        let name = author_name();
139        assert!(!name.is_empty());
140        assert!(!name.contains('<'), "author: {}", name);
141        assert!(!name.contains('@'), "author: {}", name);
142    }
143
144    #[test]
145    fn test_default_branch_falls_back_to_main() {
146        // A temp dir with no git repo (and no origin) falls back to main.
147        let temp = tempfile::tempdir().unwrap();
148        assert_eq!(default_branch(temp.path()), "main");
149    }
150
151    #[test]
152    fn test_render_hook_with_version_bakes_guard() {
153        let content = render_hook_with_version("stop", "9.9.9", "main").unwrap();
154        assert!(content.contains("ctx harness compat --require \"9.9.9\""));
155        assert!(content.contains("generated by ctx v9.9.9"));
156        assert!(!content.contains("{{"), "unrendered token in: {}", content);
157        assert!(render_hook_with_version("nope", "1.0.0", "main").is_none());
158    }
159}