1use 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
42pub const CTX_VERSION: &str = env!("CARGO_PKG_VERSION");
44
45pub 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
54pub 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
67pub 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
87pub 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
96pub 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 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 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}