Skip to main content

agent_orchestrator/
qa_utils.rs

1use anyhow::Result;
2use std::collections::{HashMap, HashSet};
3use std::path::{Component, Path};
4
5/// Unified template engine trait for all template rendering
6/// This provides a consistent interface for rendering templates with various placeholders
7pub trait TemplateEngine {
8    /// Render a template with the given context
9    fn render(&self, template: &str) -> String;
10}
11
12/// Basic template context for simple replacements
13pub struct BasicTemplateContext {
14    /// Relative path bound to `{rel_path}`.
15    pub rel_path: Option<String>,
16    /// Ticket path list bound to `{ticket_paths}`.
17    pub ticket_paths: Option<Vec<String>>,
18    /// Phase name bound to `{phase}`.
19    pub phase: Option<String>,
20    /// Task identifier bound to `{task_id}`.
21    pub task_id: Option<String>,
22    /// Cycle number bound to `{cycle}`.
23    pub cycle: Option<u32>,
24    /// Unresolved item count bound to `{unresolved_items}`.
25    pub unresolved_items: Option<i64>,
26}
27
28impl BasicTemplateContext {
29    /// Returns an empty template context.
30    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    /// Sets the relative path placeholder value.
42    pub fn with_rel_path(mut self, path: impl Into<String>) -> Self {
43        self.rel_path = Some(path.into());
44        self
45    }
46
47    /// Sets the ticket path placeholder value.
48    pub fn with_ticket_paths(mut self, paths: Vec<String>) -> Self {
49        self.ticket_paths = Some(paths);
50        self
51    }
52
53    /// Sets the phase placeholder value.
54    pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
55        self.phase = Some(phase.into());
56        self
57    }
58
59    /// Sets the task identifier placeholder value.
60    pub fn with_task_id(mut self, id: impl Into<String>) -> Self {
61        self.task_id = Some(id.into());
62        self
63    }
64
65    /// Sets the cycle placeholder value.
66    pub fn with_cycle(mut self, cycle: u32) -> Self {
67        self.cycle = Some(cycle);
68        self
69    }
70
71    /// Sets the unresolved item count placeholder value.
72    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
111/// Advanced template context with upstream outputs and shared state
112pub struct AdvancedTemplateContext {
113    basic: BasicTemplateContext,
114    /// Upstream step outputs addressable through `upstream[i].*` placeholders.
115    pub upstream_outputs: Vec<serde_json::Value>,
116    /// Shared workflow state rendered through `{key}` placeholders.
117    pub shared_state: HashMap<String, serde_json::Value>,
118}
119
120impl AdvancedTemplateContext {
121    /// Returns an empty advanced context.
122    pub fn new() -> Self {
123        Self {
124            basic: BasicTemplateContext::new(),
125            upstream_outputs: Vec::new(),
126            shared_state: HashMap::new(),
127        }
128    }
129
130    /// Replaces the embedded basic placeholder set.
131    pub fn with_basic(mut self, basic: BasicTemplateContext) -> Self {
132        self.basic = basic;
133        self
134    }
135
136    /// Sets upstream outputs visible to advanced placeholders.
137    pub fn with_upstream_outputs(mut self, outputs: Vec<serde_json::Value>) -> Self {
138        self.upstream_outputs = outputs;
139        self
140    }
141
142    /// Sets shared workflow state visible to advanced placeholders.
143    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        // First apply basic context replacements
158        let mut result = self.basic.render(template);
159
160        // Upstream outputs - collect all replacements first
161        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        // Sort by length descending to replace longer patterns first
176        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        // Shared state
183        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
196/// Validates that a workspace-relative path stays within the workspace tree.
197pub 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
218/// Returns the ticket paths newly added between two snapshots.
219pub 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
228/// Renders a template using only `rel_path` and `ticket_paths` placeholders.
229pub 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
235/// Renders a template using basic placeholders plus upstream outputs and shared state.
236pub 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    // Basic placeholders
247    result = result.replace("{rel_path}", rel_path);
248    result = result.replace("{ticket_paths}", &ticket_paths.join(" "));
249    result = result.replace("{phase}", phase);
250
251    // Upstream outputs (JSON serialized)
252    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    // Shared state
263    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}