Skip to main content

agent_first_mail/
templates.rs

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}