1use anyhow::Result;
2use std::collections::{HashMap, HashSet};
3use std::path::{Component, Path};
4
5pub trait TemplateEngine {
8 fn render(&self, template: &str) -> String;
10}
11
12pub struct BasicTemplateContext {
14 pub rel_path: Option<String>,
16 pub ticket_paths: Option<Vec<String>>,
18 pub phase: Option<String>,
20 pub task_id: Option<String>,
22 pub cycle: Option<u32>,
24 pub unresolved_items: Option<i64>,
26}
27
28impl BasicTemplateContext {
29 pub fn new() -> Self {
31 Self {
32 rel_path: None,
33 ticket_paths: None,
34 phase: None,
35 task_id: None,
36 cycle: None,
37 unresolved_items: None,
38 }
39 }
40
41 pub fn with_rel_path(mut self, path: impl Into<String>) -> Self {
43 self.rel_path = Some(path.into());
44 self
45 }
46
47 pub fn with_ticket_paths(mut self, paths: Vec<String>) -> Self {
49 self.ticket_paths = Some(paths);
50 self
51 }
52
53 pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
55 self.phase = Some(phase.into());
56 self
57 }
58
59 pub fn with_task_id(mut self, id: impl Into<String>) -> Self {
61 self.task_id = Some(id.into());
62 self
63 }
64
65 pub fn with_cycle(mut self, cycle: u32) -> Self {
67 self.cycle = Some(cycle);
68 self
69 }
70
71 pub fn with_unresolved_items(mut self, count: i64) -> Self {
73 self.unresolved_items = Some(count);
74 self
75 }
76}
77
78impl Default for BasicTemplateContext {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl TemplateEngine for BasicTemplateContext {
85 fn render(&self, template: &str) -> String {
86 let mut result = template.to_string();
87
88 if let Some(ref rel_path) = self.rel_path {
89 result = result.replace("{rel_path}", rel_path);
90 }
91 if let Some(ref ticket_paths) = self.ticket_paths {
92 result = result.replace("{ticket_paths}", &ticket_paths.join(" "));
93 }
94 if let Some(ref phase) = self.phase {
95 result = result.replace("{phase}", phase);
96 }
97 if let Some(ref task_id) = self.task_id {
98 result = result.replace("{task_id}", task_id);
99 }
100 if let Some(cycle) = self.cycle {
101 result = result.replace("{cycle}", &cycle.to_string());
102 }
103 if let Some(unresolved) = self.unresolved_items {
104 result = result.replace("{unresolved_items}", &unresolved.to_string());
105 }
106
107 result
108 }
109}
110
111pub struct AdvancedTemplateContext {
113 basic: BasicTemplateContext,
114 pub upstream_outputs: Vec<serde_json::Value>,
116 pub shared_state: HashMap<String, serde_json::Value>,
118}
119
120impl AdvancedTemplateContext {
121 pub fn new() -> Self {
123 Self {
124 basic: BasicTemplateContext::new(),
125 upstream_outputs: Vec::new(),
126 shared_state: HashMap::new(),
127 }
128 }
129
130 pub fn with_basic(mut self, basic: BasicTemplateContext) -> Self {
132 self.basic = basic;
133 self
134 }
135
136 pub fn with_upstream_outputs(mut self, outputs: Vec<serde_json::Value>) -> Self {
138 self.upstream_outputs = outputs;
139 self
140 }
141
142 pub fn with_shared_state(mut self, state: HashMap<String, serde_json::Value>) -> Self {
144 self.shared_state = state;
145 self
146 }
147}
148
149impl Default for AdvancedTemplateContext {
150 fn default() -> Self {
151 Self::new()
152 }
153}
154
155impl TemplateEngine for AdvancedTemplateContext {
156 fn render(&self, template: &str) -> String {
157 let mut result = self.basic.render(template);
159
160 let mut replacements: Vec<(String, String)> = Vec::new();
162 for (i, output) in self.upstream_outputs.iter().enumerate() {
163 let prefix = format!("upstream[{}]", i);
164 if let Some(v) = output.get("exit_code").and_then(|v| v.as_i64()) {
165 replacements.push((format!("{}.exit_code", prefix), v.to_string()));
166 }
167 if let Some(v) = output.get("confidence").and_then(|v| v.as_f64()) {
168 replacements.push((format!("{}.confidence", prefix), v.to_string()));
169 }
170 if let Some(v) = output.get("quality_score").and_then(|v| v.as_f64()) {
171 replacements.push((format!("{}.quality_score", prefix), v.to_string()));
172 }
173 }
174
175 replacements.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
177
178 for (key, value) in replacements {
179 result = result.replace(&format!("{{{}}}", key), &value);
180 }
181
182 for (key, value) in &self.shared_state {
184 let placeholder = format!("{{{}}}", key);
185 if let Some(s) = value.as_str() {
186 result = result.replace(&placeholder, s);
187 } else if let Ok(s) = serde_json::to_string(value) {
188 result = result.replace(&placeholder, &s);
189 }
190 }
191
192 result
193 }
194}
195
196pub fn validate_workspace_rel_path(raw: &str, field: &str) -> Result<()> {
198 let path = raw.trim();
199 if path.is_empty() {
200 anyhow::bail!("{} cannot be empty", field);
201 }
202
203 let parsed = Path::new(path);
204 if parsed.is_absolute() {
205 anyhow::bail!("{} must be a relative path: {}", field, raw);
206 }
207
208 if parsed
209 .components()
210 .any(|c| matches!(c, Component::ParentDir))
211 {
212 anyhow::bail!("{} cannot include '..': {}", field, raw);
213 }
214
215 Ok(())
216}
217
218pub fn new_ticket_diff(before: &[String], after: &[String]) -> Vec<String> {
220 let before_set: HashSet<&String> = before.iter().collect();
221 after
222 .iter()
223 .filter(|path| !before_set.contains(path))
224 .cloned()
225 .collect()
226}
227
228pub fn render_template(template: &str, rel_path: &str, ticket_paths: &[String]) -> String {
230 template
231 .replace("{rel_path}", rel_path)
232 .replace("{ticket_paths}", &ticket_paths.join(" "))
233}
234
235pub fn render_template_with_context(
237 template: &str,
238 rel_path: &str,
239 ticket_paths: &[String],
240 phase: &str,
241 upstream_outputs: &[serde_json::Value],
242 shared_state: &HashMap<String, serde_json::Value>,
243) -> String {
244 let mut result = template.to_string();
245
246 result = result.replace("{rel_path}", rel_path);
248 result = result.replace("{ticket_paths}", &ticket_paths.join(" "));
249 result = result.replace("{phase}", phase);
250
251 for (i, output) in upstream_outputs.iter().enumerate() {
253 let prefix = format!("upstream[{}]", i);
254 if let Some(v) = output.get("exit_code").and_then(|v| v.as_i64()) {
255 result = result.replace(&format!("{}.exit_code", prefix), &v.to_string());
256 }
257 if let Some(v) = output.get("confidence").and_then(|v| v.as_f64()) {
258 result = result.replace(&format!("{}.confidence", prefix), &v.to_string());
259 }
260 }
261
262 for (key, value) in shared_state {
264 let placeholder = format!("{{{}}}", key);
265 if let Some(s) = value.as_str() {
266 result = result.replace(&placeholder, s);
267 } else if let Ok(s) = serde_json::to_string(value) {
268 result = result.replace(&placeholder, &s);
269 }
270 }
271
272 result
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn validate_workspace_rel_path_accepts_normal_relative_paths() {
281 assert!(validate_workspace_rel_path("docs/qa", "field").is_ok());
282 assert!(validate_workspace_rel_path("config/default.yaml", "field").is_ok());
283 assert!(validate_workspace_rel_path("a-b_c/1", "field").is_ok());
284 }
285
286 #[test]
287 fn validate_workspace_rel_path_rejects_empty_input() {
288 assert!(validate_workspace_rel_path("", "f").is_err());
289 assert!(validate_workspace_rel_path(" ", "f").is_err());
290 }
291
292 #[test]
293 fn validate_workspace_rel_path_rejects_absolute_path() {
294 assert!(validate_workspace_rel_path("/tmp/data", "f").is_err());
295 }
296
297 #[test]
298 fn validate_workspace_rel_path_rejects_parent_segments() {
299 assert!(validate_workspace_rel_path("../docs", "f").is_err());
300 assert!(validate_workspace_rel_path("docs/../../x", "f").is_err());
301 }
302
303 #[test]
304 fn render_template_replaces_placeholders() {
305 let template = "run {rel_path} --tickets {ticket_paths}";
306 let tickets = vec!["a.md".to_string(), "b.md".to_string()];
307 let rendered = render_template(template, "docs/qa/1.md", &tickets);
308 assert_eq!(rendered, "run docs/qa/1.md --tickets a.md b.md");
309 }
310
311 #[test]
312 fn render_template_handles_empty_ticket_paths() {
313 let rendered = render_template("{rel_path}:{ticket_paths}", "x.md", &[]);
314 assert_eq!(rendered, "x.md:");
315 }
316
317 #[test]
318 fn new_ticket_diff_returns_only_new_items_with_original_order() {
319 let before = vec!["a".to_string(), "b".to_string()];
320 let after = vec!["b".to_string(), "c".to_string(), "d".to_string()];
321 let diff = new_ticket_diff(&before, &after);
322 assert_eq!(diff, vec!["c".to_string(), "d".to_string()]);
323 }
324
325 #[test]
326 fn new_ticket_diff_returns_empty_when_no_new_items() {
327 let before = vec!["a".to_string(), "b".to_string()];
328 let after = vec!["a".to_string(), "b".to_string()];
329 let diff = new_ticket_diff(&before, &after);
330 assert!(diff.is_empty());
331 }
332
333 #[test]
334 fn new_ticket_diff_keeps_duplicates_if_after_has_duplicates() {
335 let before = vec!["a".to_string()];
336 let after = vec!["b".to_string(), "b".to_string()];
337 let diff = new_ticket_diff(&before, &after);
338 assert_eq!(diff, vec!["b".to_string(), "b".to_string()]);
339 }
340
341 #[test]
342 fn basic_template_context_render() {
343 let ctx = BasicTemplateContext::new()
344 .with_rel_path("docs/qa/test.md")
345 .with_ticket_paths(vec!["ticket1.md".to_string()]);
346
347 let result = ctx.render("qa {rel_path} --tickets {ticket_paths}");
348 assert_eq!(result, "qa docs/qa/test.md --tickets ticket1.md");
349 }
350
351 #[test]
352 fn basic_template_context_all_fields() {
353 let ctx = BasicTemplateContext::new()
354 .with_rel_path("test.md")
355 .with_phase("qa")
356 .with_task_id("task-123")
357 .with_cycle(5)
358 .with_unresolved_items(3);
359
360 let result = ctx.render("{rel_path} {phase} {task_id} c{cycle} u{unresolved_items}");
361 assert_eq!(result, "test.md qa task-123 c5 u3");
362 }
363
364 #[test]
365 fn advanced_template_context_with_upstream() {
366 let mut shared = HashMap::new();
367 shared.insert("key".to_string(), serde_json::json!("value"));
368
369 let upstream = vec![serde_json::json!({"exit_code": 0, "confidence": 0.9})];
370
371 let ctx = AdvancedTemplateContext::new()
372 .with_basic(BasicTemplateContext::new().with_rel_path("test.md"))
373 .with_upstream_outputs(upstream)
374 .with_shared_state(shared);
375
376 let result = ctx.render(
377 "{rel_path} exit:{upstream[0].exit_code} conf:{upstream[0].confidence} key:{key}",
378 );
379 assert_eq!(result, "test.md exit:0 conf:0.9 key:value");
380 }
381
382 #[test]
383 fn advanced_template_context_with_json_value() {
384 let mut shared = HashMap::new();
385 shared.insert("data".to_string(), serde_json::json!({"foo": "bar"}));
386
387 let ctx = AdvancedTemplateContext::new().with_shared_state(shared);
388
389 let result = ctx.render("data: {data}");
390 assert!(result.contains("foo"));
391 assert!(result.contains("bar"));
392 }
393
394 #[test]
395 fn advanced_template_context_supports_quality_score_replacement() {
396 let upstream = vec![serde_json::json!({
397 "exit_code": 0,
398 "confidence": 0.9,
399 "quality_score": 0.75
400 })];
401
402 let ctx = AdvancedTemplateContext::new().with_upstream_outputs(upstream);
403 let result = ctx.render(
404 "exit:{upstream[0].exit_code} conf:{upstream[0].confidence} quality:{upstream[0].quality_score}",
405 );
406
407 assert_eq!(result, "exit:0 conf:0.9 quality:0.75");
408 }
409
410 #[test]
411 fn render_template_with_context_replaces_phase_upstream_and_shared_state() {
412 let upstream = vec![serde_json::json!({
413 "exit_code": 7,
414 "confidence": 0.42
415 })];
416 let mut shared = HashMap::new();
417 shared.insert("status".to_string(), serde_json::json!("open"));
418 shared.insert("meta".to_string(), serde_json::json!({"owner": "qa"}));
419
420 let rendered = render_template_with_context(
421 "{phase} {rel_path} {ticket_paths} {upstream[0].exit_code} {upstream[0].confidence} {status} {meta}",
422 "docs/qa/test.md",
423 &["ticket-1.md".to_string(), "ticket-2.md".to_string()],
424 "qa_testing",
425 &upstream,
426 &shared,
427 );
428
429 assert!(rendered.contains("qa_testing"));
430 assert!(rendered.contains("docs/qa/test.md"));
431 assert!(rendered.contains("ticket-1.md ticket-2.md"));
432 assert!(rendered.contains("7"));
433 assert!(rendered.contains("0.42"));
434 assert!(rendered.contains("open"));
435 assert!(rendered.contains("owner"));
436 }
437}