1use crate::config::TemplateLanguage;
2use crate::error::{AppError, Result};
3use minijinja::Environment;
4use serde::Serialize;
5use serde_json::{json, Value};
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
11pub enum TemplateKey {
12 WorkspaceAgents,
13 WorkspaceDoNotEdit,
14 NotesDefault,
15 NotesMergeSection,
16 DraftNew,
17 DraftReply,
18 MessageSection,
19 TriageView,
20 CaseDocument,
21 CaseMessage,
22 ArchiveMessageIndex,
23 ArchiveMessage,
24 StatusIndex,
25 StatusMessage,
26}
27
28impl TemplateKey {
29 pub const ALL: [Self; 14] = [
30 Self::WorkspaceAgents,
31 Self::WorkspaceDoNotEdit,
32 Self::NotesDefault,
33 Self::NotesMergeSection,
34 Self::DraftNew,
35 Self::DraftReply,
36 Self::MessageSection,
37 Self::TriageView,
38 Self::CaseDocument,
39 Self::CaseMessage,
40 Self::ArchiveMessageIndex,
41 Self::ArchiveMessage,
42 Self::StatusIndex,
43 Self::StatusMessage,
44 ];
45
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Self::WorkspaceAgents => "workspace/AGENTS.md.j2",
49 Self::WorkspaceDoNotEdit => "workspace/DO_NOT_EDIT.txt.j2",
50 Self::NotesDefault => "notes/default.md.j2",
51 Self::NotesMergeSection => "notes/merge-section.md.j2",
52 Self::DraftNew => "draft/new.md.j2",
53 Self::DraftReply => "draft/reply.md.j2",
54 Self::MessageSection => "message/section.md.j2",
55 Self::TriageView => "triage/view.md.j2",
56 Self::CaseDocument => "case/case.md.j2",
57 Self::CaseMessage => "case/message.md.j2",
58 Self::ArchiveMessageIndex => "archive-message/archive.md.j2",
59 Self::ArchiveMessage => "archive-message/message.md.j2",
60 Self::StatusIndex => "status/index.md.j2",
61 Self::StatusMessage => "status/message.md.j2",
62 }
63 }
64
65 pub fn builtin_text(self, language: TemplateLanguage) -> &'static str {
66 match (language, self) {
67 (TemplateLanguage::EnUs, Self::WorkspaceAgents) => {
68 include_str!("../templates/en-US/workspace/AGENTS.md.j2")
69 }
70 (TemplateLanguage::EnUs, Self::WorkspaceDoNotEdit) => {
71 include_str!("../templates/en-US/workspace/DO_NOT_EDIT.txt.j2")
72 }
73 (TemplateLanguage::EnUs, Self::NotesDefault) => {
74 include_str!("../templates/en-US/notes/default.md.j2")
75 }
76 (TemplateLanguage::EnUs, Self::NotesMergeSection) => {
77 include_str!("../templates/en-US/notes/merge-section.md.j2")
78 }
79 (TemplateLanguage::EnUs, Self::DraftNew) => {
80 include_str!("../templates/en-US/draft/new.md.j2")
81 }
82 (TemplateLanguage::EnUs, Self::DraftReply) => {
83 include_str!("../templates/en-US/draft/reply.md.j2")
84 }
85 (TemplateLanguage::EnUs, Self::MessageSection) => {
86 include_str!("../templates/en-US/message/section.md.j2")
87 }
88 (TemplateLanguage::EnUs, Self::TriageView) => {
89 include_str!("../templates/en-US/triage/view.md.j2")
90 }
91 (TemplateLanguage::EnUs, Self::CaseDocument) => {
92 include_str!("../templates/en-US/case/case.md.j2")
93 }
94 (TemplateLanguage::EnUs, Self::CaseMessage) => {
95 include_str!("../templates/en-US/case/message.md.j2")
96 }
97 (TemplateLanguage::EnUs, Self::ArchiveMessageIndex) => {
98 include_str!("../templates/en-US/archive-message/archive.md.j2")
99 }
100 (TemplateLanguage::EnUs, Self::ArchiveMessage) => {
101 include_str!("../templates/en-US/archive-message/message.md.j2")
102 }
103 (TemplateLanguage::EnUs, Self::StatusIndex) => {
104 include_str!("../templates/en-US/status/index.md.j2")
105 }
106 (TemplateLanguage::EnUs, Self::StatusMessage) => {
107 include_str!("../templates/en-US/status/message.md.j2")
108 }
109 (TemplateLanguage::ZhCn, Self::WorkspaceAgents) => {
110 include_str!("../templates/zh-CN/workspace/AGENTS.md.j2")
111 }
112 (TemplateLanguage::ZhCn, Self::WorkspaceDoNotEdit) => {
113 include_str!("../templates/zh-CN/workspace/DO_NOT_EDIT.txt.j2")
114 }
115 (TemplateLanguage::ZhCn, Self::NotesDefault) => {
116 include_str!("../templates/zh-CN/notes/default.md.j2")
117 }
118 (TemplateLanguage::ZhCn, Self::NotesMergeSection) => {
119 include_str!("../templates/zh-CN/notes/merge-section.md.j2")
120 }
121 (TemplateLanguage::ZhCn, Self::DraftNew) => {
122 include_str!("../templates/zh-CN/draft/new.md.j2")
123 }
124 (TemplateLanguage::ZhCn, Self::DraftReply) => {
125 include_str!("../templates/zh-CN/draft/reply.md.j2")
126 }
127 (TemplateLanguage::ZhCn, Self::MessageSection) => {
128 include_str!("../templates/zh-CN/message/section.md.j2")
129 }
130 (TemplateLanguage::ZhCn, Self::TriageView) => {
131 include_str!("../templates/zh-CN/triage/view.md.j2")
132 }
133 (TemplateLanguage::ZhCn, Self::CaseDocument) => {
134 include_str!("../templates/zh-CN/case/case.md.j2")
135 }
136 (TemplateLanguage::ZhCn, Self::CaseMessage) => {
137 include_str!("../templates/zh-CN/case/message.md.j2")
138 }
139 (TemplateLanguage::ZhCn, Self::ArchiveMessageIndex) => {
140 include_str!("../templates/zh-CN/archive-message/archive.md.j2")
141 }
142 (TemplateLanguage::ZhCn, Self::ArchiveMessage) => {
143 include_str!("../templates/zh-CN/archive-message/message.md.j2")
144 }
145 (TemplateLanguage::ZhCn, Self::StatusIndex) => {
146 include_str!("../templates/zh-CN/status/index.md.j2")
147 }
148 (TemplateLanguage::ZhCn, Self::StatusMessage) => {
149 include_str!("../templates/zh-CN/status/message.md.j2")
150 }
151 }
152 }
153}
154
155pub fn language_template_path(language: TemplateLanguage, key: TemplateKey) -> PathBuf {
156 PathBuf::from(language.as_str()).join(key.as_str())
157}
158
159#[derive(Clone, Debug, Default)]
160pub struct TemplateRenderStats {
161 counts: BTreeMap<&'static str, TemplateSourceCounts>,
162}
163
164impl TemplateRenderStats {
165 fn record(&mut self, key: TemplateKey, source: TemplateSourceKind) {
166 let counts = self.counts.entry(key.as_str()).or_default();
167 match source {
168 TemplateSourceKind::Builtin => counts.builtin += 1,
169 TemplateSourceKind::Workspace => counts.workspace += 1,
170 }
171 }
172
173 pub fn to_value(&self) -> Value {
174 let mut out = serde_json::Map::new();
175 for key in TemplateKey::ALL {
176 let counts = self.counts.get(key.as_str()).cloned().unwrap_or_default();
177 out.insert(
178 key.as_str().to_string(),
179 json!({
180 "builtin": counts.builtin,
181 "workspace": counts.workspace,
182 }),
183 );
184 }
185 Value::Object(out)
186 }
187}
188
189#[derive(Clone, Debug, Default)]
190struct TemplateSourceCounts {
191 builtin: usize,
192 workspace: usize,
193}
194
195#[derive(Clone, Copy, Debug)]
196enum TemplateSourceKind {
197 Builtin,
198 Workspace,
199}
200
201struct TemplateSource {
202 text: String,
203 kind: TemplateSourceKind,
204 label: String,
205}
206
207pub struct MarkdownTemplateRenderer<'a> {
208 root: Option<&'a Path>,
209 language: TemplateLanguage,
210 stats: TemplateRenderStats,
211}
212
213impl<'a> MarkdownTemplateRenderer<'a> {
214 pub fn new(root: &'a Path, language: TemplateLanguage) -> Self {
215 Self {
216 root: Some(root),
217 language,
218 stats: TemplateRenderStats::default(),
219 }
220 }
221
222 pub fn builtin(language: TemplateLanguage) -> Self {
223 Self {
224 root: None,
225 language,
226 stats: TemplateRenderStats::default(),
227 }
228 }
229
230 pub fn render<T: Serialize>(&mut self, key: TemplateKey, context: &T) -> Result<String> {
231 let source = self.template_source(key)?;
232 let mut env = Environment::new();
233 env.add_template(key.as_str(), &source.text)
234 .map_err(|e| template_error(key, self.language, &source.label, e))?;
235 let template = env
236 .get_template(key.as_str())
237 .map_err(|e| template_error(key, self.language, &source.label, e))?;
238 let rendered = template
239 .render(context)
240 .map_err(|e| template_error(key, self.language, &source.label, e))?;
241 self.stats.record(key, source.kind);
242 Ok(rendered)
243 }
244
245 pub fn stats(&self) -> &TemplateRenderStats {
246 &self.stats
247 }
248
249 fn template_source(&self, key: TemplateKey) -> Result<TemplateSource> {
250 if let Some(root) = self.root {
251 let rel = PathBuf::from("templates").join(language_template_path(self.language, key));
252 let path = root.join(&rel);
253 if path.exists() {
254 let text =
255 fs::read_to_string(&path).map_err(|e| AppError::io("read template", &e))?;
256 return Ok(TemplateSource {
257 text,
258 kind: TemplateSourceKind::Workspace,
259 label: path_to_string(&rel),
260 });
261 }
262 }
263 Ok(TemplateSource {
264 text: key.builtin_text(self.language).to_string(),
265 kind: TemplateSourceKind::Builtin,
266 label: "builtin".to_string(),
267 })
268 }
269}
270
271fn template_error(
272 key: TemplateKey,
273 language: TemplateLanguage,
274 source: &str,
275 error: minijinja::Error,
276) -> AppError {
277 AppError::new(
278 "template_render_failed",
279 format!(
280 "failed to render template {}/{} from {}: {}",
281 language.as_str(),
282 key.as_str(),
283 source,
284 error
285 ),
286 )
287}
288
289fn path_to_string(path: &Path) -> String {
290 path.to_string_lossy().replace('\\', "/")
291}