Skip to main content

agent_air_runtime/controller/tools/
list_plans.rs

1//! ListPlans tool implementation.
2//!
3//! Lists all plans in the `.agent-air/plans/` directory with summary
4//! metadata parsed from each plan file's header: title, status, created
5//! date, and step counts by status.
6//!
7//! Plan files are internal agent artifacts so this tool handles its own
8//! permissions and never prompts the user.
9
10use std::collections::HashMap;
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::Arc;
14
15use regex::Regex;
16use tokio::fs;
17
18use super::plan_store::PlanStore;
19use super::types::{
20    DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
21};
22
23/// ListPlans tool name constant.
24pub const LIST_PLANS_TOOL_NAME: &str = "list_plans";
25
26/// ListPlans tool description constant.
27pub const LIST_PLANS_TOOL_DESCRIPTION: &str = r#"Lists all plans in the workspace with summary metadata.
28
29Usage:
30- No parameters required
31- Returns a summary of each plan including ID, title, status, creation date, and step progress
32
33Returns:
34- A formatted list of all plans, or a message if no plans exist"#;
35
36/// ListPlans tool JSON schema constant.
37pub const LIST_PLANS_TOOL_SCHEMA: &str = r#"{
38    "type": "object",
39    "properties": {},
40    "required": []
41}"#;
42
43/// Tool that lists all plans in the workspace with summary metadata.
44///
45/// Plan files live in `.agent-air/plans/` and are internal agent artifacts,
46/// so no user permission is required.
47pub struct ListPlansTool {
48    /// Shared plan store for directory paths.
49    plan_store: Arc<PlanStore>,
50}
51
52impl ListPlansTool {
53    /// Create a new ListPlansTool.
54    pub fn new(plan_store: Arc<PlanStore>) -> Self {
55        Self { plan_store }
56    }
57}
58
59/// Parsed summary of a single plan file.
60struct PlanSummary {
61    plan_id: String,
62    title: String,
63    status: String,
64    created: String,
65    pending: usize,
66    in_progress: usize,
67    completed: usize,
68    skipped: usize,
69}
70
71impl PlanSummary {
72    fn total_steps(&self) -> usize {
73        self.pending + self.in_progress + self.completed + self.skipped
74    }
75}
76
77/// Parses a plan file's content into a `PlanSummary`.
78fn parse_plan_summary(plan_id: &str, content: &str) -> PlanSummary {
79    let title_re = Regex::new(r"^# Plan: (.+)$").unwrap();
80    let status_re = Regex::new(r"^\*\*Status\*\*: (.+)$").unwrap();
81    let created_re = Regex::new(r"^\*\*Created\*\*: (.+)$").unwrap();
82    let step_re = Regex::new(r"^\d+\. \[([ x~-])\] ").unwrap();
83
84    let mut title = String::from("Untitled");
85    let mut status = String::from("unknown");
86    let mut created = String::from("unknown");
87    let mut pending: usize = 0;
88    let mut in_progress: usize = 0;
89    let mut completed: usize = 0;
90    let mut skipped: usize = 0;
91
92    for line in content.lines() {
93        if let Some(caps) = title_re.captures(line) {
94            title = caps[1].to_string();
95        } else if let Some(caps) = status_re.captures(line) {
96            status = caps[1].to_string();
97        } else if let Some(caps) = created_re.captures(line) {
98            created = caps[1].to_string();
99        } else if let Some(caps) = step_re.captures(line) {
100            match &caps[1] {
101                " " => pending += 1,
102                "~" => in_progress += 1,
103                "x" => completed += 1,
104                "-" => skipped += 1,
105                _ => pending += 1,
106            }
107        }
108    }
109
110    PlanSummary {
111        plan_id: plan_id.to_string(),
112        title,
113        status,
114        created,
115        pending,
116        in_progress,
117        completed,
118        skipped,
119    }
120}
121
122/// Formats a list of plan summaries into a readable string.
123fn format_plan_list(summaries: &[PlanSummary]) -> String {
124    if summaries.is_empty() {
125        return "No plans found. Use markdown_plan to create one.".to_string();
126    }
127
128    let mut output = format!("Found {} plan(s):\n\n", summaries.len());
129
130    for s in summaries {
131        let total = s.total_steps();
132        let progress = if total > 0 {
133            format!("{}/{} completed", s.completed, total)
134        } else {
135            "no steps".to_string()
136        };
137
138        output.push_str(&format!(
139            "- **{}**: {} [{}] (created: {}, {})\n",
140            s.plan_id, s.title, s.status, s.created, progress
141        ));
142
143        // Show step breakdown if there are steps in multiple states.
144        let active_states = [
145            s.pending > 0,
146            s.in_progress > 0,
147            s.completed > 0,
148            s.skipped > 0,
149        ]
150        .iter()
151        .filter(|&&b| b)
152        .count();
153        if active_states > 1 {
154            let mut parts = Vec::new();
155            if s.completed > 0 {
156                parts.push(format!("{} completed", s.completed));
157            }
158            if s.in_progress > 0 {
159                parts.push(format!("{} in progress", s.in_progress));
160            }
161            if s.pending > 0 {
162                parts.push(format!("{} pending", s.pending));
163            }
164            if s.skipped > 0 {
165                parts.push(format!("{} skipped", s.skipped));
166            }
167            output.push_str(&format!("  Steps: {}\n", parts.join(", ")));
168        }
169    }
170
171    output
172}
173
174impl Executable for ListPlansTool {
175    fn name(&self) -> &str {
176        LIST_PLANS_TOOL_NAME
177    }
178
179    fn description(&self) -> &str {
180        LIST_PLANS_TOOL_DESCRIPTION
181    }
182
183    fn input_schema(&self) -> &str {
184        LIST_PLANS_TOOL_SCHEMA
185    }
186
187    fn tool_type(&self) -> ToolType {
188        ToolType::Custom
189    }
190
191    fn execute(
192        &self,
193        _context: ToolContext,
194        _input: HashMap<String, serde_json::Value>,
195    ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
196        let plan_store = self.plan_store.clone();
197
198        Box::pin(async move {
199            let plans_dir = plan_store.plans_dir();
200
201            // If the directory doesn't exist, no plans have been created yet.
202            if !plans_dir.exists() {
203                return Ok("No plans found. Use markdown_plan to create one.".to_string());
204            }
205
206            let mut entries = fs::read_dir(plans_dir)
207                .await
208                .map_err(|e| format!("Failed to read plans directory: {}", e))?;
209
210            let mut summaries = Vec::new();
211
212            while let Some(entry) = entries
213                .next_entry()
214                .await
215                .map_err(|e| format!("Failed to read directory entry: {}", e))?
216            {
217                let file_name = entry.file_name();
218                let name = file_name.to_string_lossy();
219
220                // Only process plan-NNN.md files.
221                if let Some(plan_id) = name.strip_suffix(".md")
222                    && plan_id.starts_with("plan-")
223                {
224                    let content = fs::read_to_string(entry.path())
225                        .await
226                        .map_err(|e| format!("Failed to read plan file '{}': {}", name, e))?;
227                    summaries.push(parse_plan_summary(plan_id, &content));
228                }
229            }
230
231            // Sort by plan ID for consistent ordering.
232            summaries.sort_by(|a, b| a.plan_id.cmp(&b.plan_id));
233
234            Ok(format_plan_list(&summaries))
235        })
236    }
237
238    fn handles_own_permissions(&self) -> bool {
239        true
240    }
241
242    fn display_config(&self) -> DisplayConfig {
243        DisplayConfig {
244            display_name: "List Plans".to_string(),
245            display_title: Box::new(|_input| String::new()),
246            display_content: Box::new(|_input, result| DisplayResult {
247                content: result.to_string(),
248                content_type: ResultContentType::Markdown,
249                is_truncated: false,
250                full_length: result.lines().count(),
251            }),
252        }
253    }
254
255    fn compact_summary(&self, _input: &HashMap<String, serde_json::Value>, result: &str) -> String {
256        // Extract plan count from the result.
257        let count = result
258            .lines()
259            .next()
260            .and_then(|line| {
261                line.strip_prefix("Found ")
262                    .and_then(|s| s.split(' ').next())
263                    .and_then(|n| n.parse::<usize>().ok())
264            })
265            .unwrap_or(0);
266        format!("[ListPlans: {} plan(s)]", count)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use tempfile::TempDir;
274
275    fn make_context(tool_use_id: &str) -> ToolContext {
276        ToolContext {
277            session_id: 1,
278            tool_use_id: tool_use_id.to_string(),
279            turn_id: None,
280            permissions_pre_approved: false,
281        }
282    }
283
284    fn sample_plan(title: &str, status: &str, steps: &[&str]) -> String {
285        let mut content = format!(
286            "# Plan: {}\n\n**ID**: plan-001\n**Status**: {}\n**Created**: 2025-06-15\n\n## Steps\n\n",
287            title, status
288        );
289        for (i, marker) in steps.iter().enumerate() {
290            content.push_str(&format!("{}. [{}] Step {}\n", i + 1, marker, i + 1));
291        }
292        content
293    }
294
295    #[test]
296    fn test_parse_plan_summary_basic() {
297        let content = sample_plan("My Plan", "active", &[" ", "x", "~"]);
298        let summary = parse_plan_summary("plan-001", &content);
299
300        assert_eq!(summary.plan_id, "plan-001");
301        assert_eq!(summary.title, "My Plan");
302        assert_eq!(summary.status, "active");
303        assert_eq!(summary.created, "2025-06-15");
304        assert_eq!(summary.pending, 1);
305        assert_eq!(summary.completed, 1);
306        assert_eq!(summary.in_progress, 1);
307        assert_eq!(summary.skipped, 0);
308        assert_eq!(summary.total_steps(), 3);
309    }
310
311    #[test]
312    fn test_parse_plan_summary_all_completed() {
313        let content = sample_plan("Done", "completed", &["x", "x"]);
314        let summary = parse_plan_summary("plan-002", &content);
315
316        assert_eq!(summary.completed, 2);
317        assert_eq!(summary.pending, 0);
318        assert_eq!(summary.total_steps(), 2);
319    }
320
321    #[test]
322    fn test_parse_plan_summary_with_skipped() {
323        let content = sample_plan("Mixed", "active", &[" ", "-", "x", "-"]);
324        let summary = parse_plan_summary("plan-003", &content);
325
326        assert_eq!(summary.pending, 1);
327        assert_eq!(summary.skipped, 2);
328        assert_eq!(summary.completed, 1);
329        assert_eq!(summary.total_steps(), 4);
330    }
331
332    #[test]
333    fn test_format_plan_list_empty() {
334        let result = format_plan_list(&[]);
335        assert!(result.contains("No plans found"));
336    }
337
338    #[test]
339    fn test_format_plan_list_single_plan() {
340        let summaries = vec![PlanSummary {
341            plan_id: "plan-001".to_string(),
342            title: "Test Plan".to_string(),
343            status: "active".to_string(),
344            created: "2025-06-15".to_string(),
345            pending: 2,
346            in_progress: 0,
347            completed: 1,
348            skipped: 0,
349        }];
350
351        let result = format_plan_list(&summaries);
352        assert!(result.contains("Found 1 plan(s)"));
353        assert!(result.contains("plan-001"));
354        assert!(result.contains("Test Plan"));
355        assert!(result.contains("[active]"));
356        assert!(result.contains("1/3 completed"));
357    }
358
359    #[tokio::test]
360    async fn test_list_no_plans_directory() {
361        let temp_dir = TempDir::new().unwrap();
362        let plan_store = Arc::new(PlanStore::new(temp_dir.path().to_path_buf()));
363        let tool = ListPlansTool::new(plan_store);
364
365        let result = tool
366            .execute(make_context("test-empty"), HashMap::new())
367            .await;
368        assert!(result.is_ok());
369        assert!(result.unwrap().contains("No plans found"));
370    }
371
372    #[tokio::test]
373    async fn test_list_empty_plans_directory() {
374        let temp_dir = TempDir::new().unwrap();
375        let plans_dir = temp_dir.path().join(".agent-air/plans");
376        fs::create_dir_all(&plans_dir).await.unwrap();
377
378        let plan_store = Arc::new(PlanStore::new(temp_dir.path().to_path_buf()));
379        let tool = ListPlansTool::new(plan_store);
380
381        let result = tool
382            .execute(make_context("test-empty-dir"), HashMap::new())
383            .await;
384        assert!(result.is_ok());
385        assert!(result.unwrap().contains("No plans found"));
386    }
387
388    #[tokio::test]
389    async fn test_list_multiple_plans() {
390        let temp_dir = TempDir::new().unwrap();
391        let plans_dir = temp_dir.path().join(".agent-air/plans");
392        fs::create_dir_all(&plans_dir).await.unwrap();
393
394        // Create two plan files.
395        let plan1 = sample_plan("First Plan", "active", &[" ", "x"]);
396        let plan2 = "# Plan: Second Plan\n\n**ID**: plan-002\n**Status**: draft\n**Created**: 2025-07-01\n\n## Steps\n\n1. [ ] Only step\n";
397
398        fs::write(plans_dir.join("plan-001.md"), &plan1)
399            .await
400            .unwrap();
401        fs::write(plans_dir.join("plan-002.md"), plan2)
402            .await
403            .unwrap();
404
405        // Also create a non-plan file that should be ignored.
406        fs::write(plans_dir.join("notes.md"), "# Notes")
407            .await
408            .unwrap();
409
410        let plan_store = Arc::new(PlanStore::new(temp_dir.path().to_path_buf()));
411        let tool = ListPlansTool::new(plan_store);
412
413        let result = tool
414            .execute(make_context("test-multi"), HashMap::new())
415            .await;
416        assert!(result.is_ok());
417
418        let output = result.unwrap();
419        assert!(output.contains("Found 2 plan(s)"));
420        assert!(output.contains("plan-001"));
421        assert!(output.contains("First Plan"));
422        assert!(output.contains("plan-002"));
423        assert!(output.contains("Second Plan"));
424        // notes.md should not appear.
425        assert!(!output.contains("Notes"));
426    }
427
428    #[tokio::test]
429    async fn test_list_plans_sorted_by_id() {
430        let temp_dir = TempDir::new().unwrap();
431        let plans_dir = temp_dir.path().join(".agent-air/plans");
432        fs::create_dir_all(&plans_dir).await.unwrap();
433
434        // Create plans out of order.
435        let plan3 = "# Plan: Third\n\n**ID**: plan-003\n**Status**: draft\n**Created**: 2025-07-03\n\n## Steps\n\n1. [ ] Step\n";
436        let plan1 = "# Plan: First\n\n**ID**: plan-001\n**Status**: active\n**Created**: 2025-07-01\n\n## Steps\n\n1. [x] Step\n";
437
438        fs::write(plans_dir.join("plan-003.md"), plan3)
439            .await
440            .unwrap();
441        fs::write(plans_dir.join("plan-001.md"), plan1)
442            .await
443            .unwrap();
444
445        let plan_store = Arc::new(PlanStore::new(temp_dir.path().to_path_buf()));
446        let tool = ListPlansTool::new(plan_store);
447
448        let result = tool
449            .execute(make_context("test-sort"), HashMap::new())
450            .await;
451        assert!(result.is_ok());
452
453        let output = result.unwrap();
454        let pos_001 = output.find("plan-001").unwrap();
455        let pos_003 = output.find("plan-003").unwrap();
456        assert!(pos_001 < pos_003, "plan-001 should appear before plan-003");
457    }
458
459    #[test]
460    fn test_compact_summary() {
461        let plan_store = Arc::new(PlanStore::new(std::path::PathBuf::from("/tmp")));
462        let tool = ListPlansTool::new(plan_store);
463
464        let result_text = "Found 3 plan(s):\n\n- plan-001...";
465        let summary = tool.compact_summary(&HashMap::new(), result_text);
466        assert_eq!(summary, "[ListPlans: 3 plan(s)]");
467    }
468
469    #[test]
470    fn test_compact_summary_no_plans() {
471        let plan_store = Arc::new(PlanStore::new(std::path::PathBuf::from("/tmp")));
472        let tool = ListPlansTool::new(plan_store);
473
474        let result_text = "No plans found. Use markdown_plan to create one.";
475        let summary = tool.compact_summary(&HashMap::new(), result_text);
476        assert_eq!(summary, "[ListPlans: 0 plan(s)]");
477    }
478
479    #[test]
480    fn test_handles_own_permissions() {
481        let plan_store = Arc::new(PlanStore::new(std::path::PathBuf::from("/tmp")));
482        let tool = ListPlansTool::new(plan_store);
483        assert!(tool.handles_own_permissions());
484    }
485}