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");
32pub const PLUGIN_README_MD: &str = include_str!("templates/plugin-README.md");
33pub const CODEX_SESSION_START_SH: &str = include_str!("templates/codex-session-start.sh");
34pub const CODEX_POST_TOOL_USE_SH: &str = include_str!("templates/codex-post-tool-use.sh");
35pub const CODEX_STOP_SH: &str = include_str!("templates/codex-stop.sh");
36pub const CODEX_HOOKS_JSON: &str = include_str!("templates/codex-hooks.json");
37pub const CODEX_PLUGIN_HOOKS_JSON: &str = include_str!("templates/codex-plugin-hooks.json");
38pub const CODEX_PLUGIN_JSON: &str = include_str!("templates/codex-plugin.json");
39pub const CODEX_PLUGIN_MCP_JSON: &str = include_str!("templates/codex-plugin-mcp.json");
40pub const CODEX_MARKETPLACE_JSON: &str = include_str!("templates/codex-marketplace.json");
41pub const AGENTS_MD_BLOCK_MD: &str = include_str!("templates/agents-md-block.md");
42pub const CODEX_PLUGIN_README_MD: &str = include_str!("templates/codex-plugin-README.md");
43
44pub const CTX_VERSION: &str = env!("CARGO_PKG_VERSION");
46
47pub fn render(tmpl: &str, vars: &[(&str, &str)]) -> String {
49 let mut out = tmpl.to_string();
50 for (key, value) in vars {
51 out = out.replace(&format!("{{{{{key}}}}}"), value);
52 }
53 out
54}
55
56pub fn author_name() -> String {
58 env!("CARGO_PKG_AUTHORS")
59 .split(':')
60 .map(|author| match author.split_once('<') {
61 Some((name, _)) => name.trim(),
62 None => author.trim(),
63 })
64 .filter(|name| !name.is_empty())
65 .collect::<Vec<_>>()
66 .join(", ")
67}
68
69pub fn default_branch(root: &Path) -> String {
73 let output = Command::new("git")
74 .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
75 .current_dir(root)
76 .output();
77 if let Ok(output) = output {
78 if output.status.success() {
79 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
80 let name = name.strip_prefix("origin/").unwrap_or(&name).to_string();
81 if !name.is_empty() {
82 return name;
83 }
84 }
85 }
86 "main".to_string()
87}
88
89pub fn standard_vars<'a>(default_branch: &'a str, author: &'a str) -> Vec<(&'static str, &'a str)> {
91 vec![
92 ("CTX_VERSION", CTX_VERSION),
93 ("AUTHOR_NAME", author),
94 ("DEFAULT_BRANCH", default_branch),
95 ]
96}
97
98pub fn render_hook_with_version(name: &str, version: &str, default_branch: &str) -> Option<String> {
105 let tmpl = match name {
106 "session-start" => SESSION_START_SH,
107 "post-tool-use" => POST_TOOL_USE_SH,
108 "stop" => STOP_SH,
109 _ => return None,
110 };
111 let rendered = render(
112 tmpl,
113 &[("CTX_VERSION", version), ("DEFAULT_BRANCH", default_branch)],
114 );
115 let rel = format!("{name}.sh");
116 Some(finalize(&rendered, style_for_path(&rel), version))
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn test_render_replaces_all_tokens() {
125 let out = render(
126 "v{{CTX_VERSION}} by {{AUTHOR_NAME}} on {{DEFAULT_BRANCH}}",
127 &[
128 ("CTX_VERSION", "1.0.0"),
129 ("AUTHOR_NAME", "Jane"),
130 ("DEFAULT_BRANCH", "main"),
131 ],
132 );
133 assert_eq!(out, "v1.0.0 by Jane on main");
134 }
135
136 #[test]
137 fn test_author_name_strips_email() {
138 let name = author_name();
141 assert!(!name.is_empty());
142 assert!(!name.contains('<'), "author: {}", name);
143 assert!(!name.contains('@'), "author: {}", name);
144 }
145
146 #[test]
147 fn test_default_branch_falls_back_to_main() {
148 let temp = tempfile::tempdir().unwrap();
150 assert_eq!(default_branch(temp.path()), "main");
151 }
152
153 #[test]
154 fn test_render_hook_with_version_bakes_guard() {
155 let content = render_hook_with_version("stop", "9.9.9", "main").unwrap();
156 assert!(content.contains("ctx harness compat --require \"9.9.9\""));
157 assert!(content.contains("generated by ctx v9.9.9"));
158 assert!(!content.contains("{{"), "unrendered token in: {}", content);
159 assert!(render_hook_with_version("nope", "1.0.0", "main").is_none());
160 }
161}