agent_air_runtime/controller/tools/
list_plans.rs1use 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
23pub const LIST_PLANS_TOOL_NAME: &str = "list_plans";
25
26pub 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
36pub const LIST_PLANS_TOOL_SCHEMA: &str = r#"{
38 "type": "object",
39 "properties": {},
40 "required": []
41}"#;
42
43pub struct ListPlansTool {
48 plan_store: Arc<PlanStore>,
50}
51
52impl ListPlansTool {
53 pub fn new(plan_store: Arc<PlanStore>) -> Self {
55 Self { plan_store }
56 }
57}
58
59struct 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
77fn 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
122fn 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 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 !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 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 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 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 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 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 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 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}