Skip to main content

oven_cli/agents/
planner.rs

1use anyhow::{Context, Result};
2use askama::Template;
3
4use crate::{agents::GraphContextNode, issues::PipelineIssue};
5
6#[derive(Template)]
7#[template(path = "planner.txt")]
8struct PlannerPrompt<'a> {
9    issues: &'a [PipelineIssue],
10    graph_context: &'a [GraphContextNode],
11}
12
13pub fn build_prompt(
14    issues: &[PipelineIssue],
15    graph_context: &[GraphContextNode],
16) -> Result<String> {
17    let tmpl = PlannerPrompt { issues, graph_context };
18    tmpl.render().context("rendering planner template")
19}
20
21#[cfg(test)]
22mod tests {
23    use super::*;
24    use crate::issues::IssueOrigin;
25
26    fn sample_issues() -> Vec<PipelineIssue> {
27        vec![
28            PipelineIssue {
29                number: 1,
30                title: "Add login".to_string(),
31                body: "implement login flow".to_string(),
32                source: IssueOrigin::Github,
33                target_repo: None,
34                author: None,
35            },
36            PipelineIssue {
37                number: 2,
38                title: "Fix bug".to_string(),
39                body: "crash on startup".to_string(),
40                source: IssueOrigin::Github,
41                target_repo: None,
42                author: None,
43            },
44        ]
45    }
46
47    #[test]
48    fn prompt_includes_issue_details() {
49        let prompt = build_prompt(&sample_issues(), &[]).unwrap();
50        assert!(prompt.contains("#1: Add login"));
51        assert!(prompt.contains("#2: Fix bug"));
52        assert!(prompt.contains("<issue_body>implement login flow</issue_body>"));
53        assert!(prompt.contains("<issue_body>crash on startup</issue_body>"));
54    }
55
56    #[test]
57    fn prompt_includes_complexity_classification() {
58        let prompt = build_prompt(&sample_issues(), &[]).unwrap();
59        assert!(prompt.contains("**simple**"));
60        assert!(prompt.contains("**full**"));
61        assert!(prompt.contains("Complexity Classification"));
62    }
63
64    #[test]
65    fn prompt_includes_dependency_analysis() {
66        let prompt = build_prompt(&sample_issues(), &[]).unwrap();
67        assert!(prompt.contains("Dependency Analysis"));
68        assert!(prompt.contains("depends_on"));
69    }
70
71    #[test]
72    fn prompt_structured_json_output_is_valid() {
73        let prompt = build_prompt(&sample_issues(), &[]).unwrap();
74        assert!(prompt.contains("\"complexity\": \"simple\""));
75        assert!(prompt.contains("\"has_migration\""));
76        assert!(prompt.contains("\"predicted_files\""));
77        assert!(prompt.contains("\"area\""));
78        assert!(prompt.contains("\"total_issues\""));
79        assert!(prompt.contains("\"parallel_capacity\""));
80        assert!(prompt.contains("\"depends_on\""));
81        assert!(prompt.contains("\"nodes\""));
82    }
83
84    #[test]
85    fn prompt_omits_graph_context_when_empty() {
86        let prompt = build_prompt(&sample_issues(), &[]).unwrap();
87        assert!(!prompt.contains("<graph_state>"));
88    }
89
90    #[test]
91    fn prompt_includes_graph_context() {
92        let graph_ctx = vec![
93            GraphContextNode {
94                number: 10,
95                title: "Refactor auth".to_string(),
96                state: crate::db::graph::NodeState::InFlight,
97                area: "auth".to_string(),
98                predicted_files: vec!["src/auth.rs".to_string(), "src/middleware.rs".to_string()],
99                has_migration: false,
100                depends_on: vec![],
101                target_repo: None,
102            },
103            GraphContextNode {
104                number: 11,
105                title: "Add migration".to_string(),
106                state: crate::db::graph::NodeState::AwaitingMerge,
107                area: "db".to_string(),
108                predicted_files: vec!["src/db/mod.rs".to_string()],
109                has_migration: true,
110                depends_on: vec![10],
111                target_repo: Some("backend".to_string()),
112            },
113        ];
114
115        let prompt = build_prompt(&sample_issues(), &graph_ctx).unwrap();
116        assert!(prompt.contains("<graph_state>"));
117        assert!(prompt.contains("#10: Refactor auth"));
118        assert!(prompt.contains("state: in_flight"));
119        assert!(prompt.contains("area: auth"));
120        assert!(prompt.contains("src/auth.rs"));
121        assert!(prompt.contains("src/middleware.rs"));
122        assert!(prompt.contains("has_migration: false"));
123        assert!(prompt.contains("#11: Add migration"));
124        assert!(prompt.contains("state: awaiting_merge"));
125        assert!(prompt.contains("has_migration: true"));
126        assert!(prompt.contains("#10"));
127        // target_repo only shown when set
128        assert!(prompt.contains("target_repo: backend"));
129        // node 10 has no target_repo, so it should not appear for that node
130        let auth_section =
131            prompt.split("#10: Refactor auth").nth(1).unwrap().split('#').next().unwrap();
132        assert!(!auth_section.contains("target_repo"));
133    }
134}