1use chrono::Local;
18use regex::Regex;
19use rig::completion::ToolDefinition;
20use rig::tool::Tool;
21use serde::Deserialize;
22use serde_json::json;
23use std::fs;
24use std::path::PathBuf;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum TaskStatus {
33 Pending, InProgress, Done, Failed, }
38
39impl TaskStatus {
40 fn marker(&self) -> &'static str {
41 match self {
42 TaskStatus::Pending => "[ ]",
43 TaskStatus::InProgress => "[~]",
44 TaskStatus::Done => "[x]",
45 TaskStatus::Failed => "[!]",
46 }
47 }
48
49 #[allow(dead_code)]
50 fn from_marker(s: &str) -> Option<Self> {
51 match s {
52 "[ ]" => Some(TaskStatus::Pending),
53 "[~]" => Some(TaskStatus::InProgress),
54 "[x]" => Some(TaskStatus::Done),
55 "[!]" => Some(TaskStatus::Failed),
56 _ => None,
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
63pub struct PlanTask {
64 pub index: usize, pub status: TaskStatus,
66 pub description: String,
67 #[allow(dead_code)]
68 pub line_number: usize, }
70
71fn parse_plan_tasks(content: &str) -> Vec<PlanTask> {
77 let task_regex = Regex::new(r"^(\s*)-\s*\[([ x~!])\]\s*(.+)$").unwrap();
78 let mut tasks = Vec::new();
79 let mut task_index = 0;
80
81 for (line_idx, line) in content.lines().enumerate() {
82 if let Some(caps) = task_regex.captures(line) {
83 task_index += 1;
84 let marker_char = caps.get(2).map(|m| m.as_str()).unwrap_or(" ");
85 let description = caps.get(3).map(|m| m.as_str()).unwrap_or("").to_string();
86
87 let status = match marker_char {
88 " " => TaskStatus::Pending,
89 "~" => TaskStatus::InProgress,
90 "x" => TaskStatus::Done,
91 "!" => TaskStatus::Failed,
92 _ => TaskStatus::Pending,
93 };
94
95 tasks.push(PlanTask {
96 index: task_index,
97 status,
98 description,
99 line_number: line_idx + 1,
100 });
101 }
102 }
103
104 tasks
105}
106
107fn update_task_status(
109 content: &str,
110 task_index: usize,
111 new_status: TaskStatus,
112 note: Option<&str>,
113) -> Option<String> {
114 let task_regex = Regex::new(r"^(\s*)-\s*\[[ x~!]\]\s*(.+)$").unwrap();
115 let mut current_index = 0;
116 let mut lines: Vec<String> = content.lines().map(String::from).collect();
117
118 for (line_idx, line) in content.lines().enumerate() {
119 if task_regex.is_match(line) {
120 current_index += 1;
121 if current_index == task_index {
122 let caps = task_regex.captures(line)?;
124 let indent = caps.get(1).map(|m| m.as_str()).unwrap_or("");
125 let desc = caps.get(2).map(|m| m.as_str()).unwrap_or("");
126
127 let new_line = if new_status == TaskStatus::Failed {
129 let fail_note = note.unwrap_or("unknown reason");
130 format!(
131 "{}- {} {} (FAILED: {})",
132 indent,
133 new_status.marker(),
134 desc,
135 fail_note
136 )
137 } else {
138 format!("{}- {} {}", indent, new_status.marker(), desc)
139 };
140
141 lines[line_idx] = new_line;
142 return Some(lines.join("\n"));
143 }
144 }
145 }
146
147 None }
149
150#[derive(Debug, Deserialize)]
155pub struct PlanCreateArgs {
156 pub plan_name: String,
158 pub version: Option<String>,
160 pub content: String,
162}
163
164#[derive(Debug, thiserror::Error)]
165#[error("Plan create error: {0}")]
166pub struct PlanCreateError(String);
167
168#[derive(Debug, Clone)]
169pub struct PlanCreateTool {
170 project_path: PathBuf,
171}
172
173impl PlanCreateTool {
174 pub fn new(project_path: PathBuf) -> Self {
175 Self { project_path }
176 }
177}
178
179impl Tool for PlanCreateTool {
180 const NAME: &'static str = "plan_create";
181
182 type Error = PlanCreateError;
183 type Args = PlanCreateArgs;
184 type Output = String;
185
186 async fn definition(&self, _prompt: String) -> ToolDefinition {
187 ToolDefinition {
188 name: Self::NAME.to_string(),
189 description: r#"Create a structured plan file with task checkboxes. Use this in plan mode to document implementation steps.
190
191The plan file will be created in the `plans/` directory with format: {date}-{plan_name}-{version}.md
192
193IMPORTANT: Each task MUST use the checkbox format: `- [ ] Task description`
194
195Example content:
196```markdown
197# Authentication Feature Plan
198
199## Overview
200Add user authentication to the application.
201
202## Tasks
203
204- [ ] Create User model in src/models/user.rs
205- [ ] Add password hashing with bcrypt
206- [ ] Create login endpoint at POST /api/login
207- [ ] Add JWT token generation
208- [ ] Create authentication middleware
209- [ ] Write tests for auth flow
210```
211
212The task status markers are:
213- `[ ]` - PENDING (not started)
214- `[~]` - IN_PROGRESS (currently being worked on)
215- `[x]` - DONE (completed)
216- `[!]` - FAILED (failed with reason)"#.to_string(),
217 parameters: json!({
218 "type": "object",
219 "properties": {
220 "plan_name": {
221 "type": "string",
222 "description": "Short kebab-case name for the plan (e.g., 'auth-feature', 'refactor-db')"
223 },
224 "version": {
225 "type": "string",
226 "description": "Optional version identifier (e.g., 'v1', 'draft'). Defaults to 'v1'"
227 },
228 "content": {
229 "type": "string",
230 "description": "Markdown content with task checkboxes. Each task must be: '- [ ] Task description'"
231 }
232 },
233 "required": ["plan_name", "content"]
234 }),
235 }
236 }
237
238 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
239 let plan_name = args.plan_name.trim().to_lowercase().replace(' ', "-");
241 if plan_name.is_empty() {
242 return Err(PlanCreateError("Plan name cannot be empty".to_string()));
243 }
244
245 let tasks = parse_plan_tasks(&args.content);
247 if tasks.is_empty() {
248 return Err(PlanCreateError(
249 "Plan must contain at least one task with format: '- [ ] Task description'"
250 .to_string(),
251 ));
252 }
253
254 let version = args.version.unwrap_or_else(|| "v1".to_string());
256 let date = Local::now().format("%Y-%m-%d");
257 let filename = format!("{}-{}-{}.md", date, plan_name, version);
258
259 let plans_dir = self.project_path.join("plans");
261 if !plans_dir.exists() {
262 fs::create_dir_all(&plans_dir)
263 .map_err(|e| PlanCreateError(format!("Failed to create plans directory: {}", e)))?;
264 }
265
266 let file_path = plans_dir.join(&filename);
268 if file_path.exists() {
269 return Err(PlanCreateError(format!(
270 "Plan file already exists: {}. Use a different name or version.",
271 filename
272 )));
273 }
274
275 fs::write(&file_path, &args.content)
277 .map_err(|e| PlanCreateError(format!("Failed to write plan file: {}", e)))?;
278
279 let rel_path = file_path
281 .strip_prefix(&self.project_path)
282 .map(|p| p.display().to_string())
283 .unwrap_or_else(|_| file_path.display().to_string());
284
285 let result = json!({
286 "success": true,
287 "plan_path": rel_path,
288 "filename": filename,
289 "task_count": tasks.len(),
290 "tasks": tasks.iter().map(|t| json!({
291 "index": t.index,
292 "description": t.description,
293 "status": "pending"
294 })).collect::<Vec<_>>(),
295 "next_steps": "Plan created successfully. Choose an execution option from the menu."
296 });
297
298 serde_json::to_string_pretty(&result)
299 .map_err(|e| PlanCreateError(format!("Failed to serialize: {}", e)))
300 }
301}
302
303#[derive(Debug, Deserialize)]
308pub struct PlanNextArgs {
309 pub plan_path: String,
311}
312
313#[derive(Debug, thiserror::Error)]
314#[error("Plan next error: {0}")]
315pub struct PlanNextError(String);
316
317#[derive(Debug, Clone)]
318pub struct PlanNextTool {
319 project_path: PathBuf,
320}
321
322impl PlanNextTool {
323 pub fn new(project_path: PathBuf) -> Self {
324 Self { project_path }
325 }
326
327 fn resolve_path(&self, path: &str) -> PathBuf {
328 let p = PathBuf::from(path);
329 if p.is_absolute() {
330 p
331 } else {
332 self.project_path.join(p)
333 }
334 }
335}
336
337impl Tool for PlanNextTool {
338 const NAME: &'static str = "plan_next";
339
340 type Error = PlanNextError;
341 type Args = PlanNextArgs;
342 type Output = String;
343
344 async fn definition(&self, _prompt: String) -> ToolDefinition {
345 ToolDefinition {
346 name: Self::NAME.to_string(),
347 description: r#"Get the next pending task from a plan file and mark it as in-progress.
348
349This tool:
3501. Reads the plan file
3512. Finds the first `[ ]` (PENDING) task
3523. Updates it to `[~]` (IN_PROGRESS) in the file
3534. Returns the task description for you to execute
354
355After executing the task, use `plan_update` to mark it as done or failed.
356
357Returns null task if all tasks are complete."#
358 .to_string(),
359 parameters: json!({
360 "type": "object",
361 "properties": {
362 "plan_path": {
363 "type": "string",
364 "description": "Path to the plan file (e.g., 'plans/2025-01-15-auth-feature-v1.md')"
365 }
366 },
367 "required": ["plan_path"]
368 }),
369 }
370 }
371
372 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
373 let file_path = self.resolve_path(&args.plan_path);
374
375 let content = fs::read_to_string(&file_path)
377 .map_err(|e| PlanNextError(format!("Failed to read plan file: {}", e)))?;
378
379 let tasks = parse_plan_tasks(&content);
381 if tasks.is_empty() {
382 return Err(PlanNextError("No tasks found in plan file".to_string()));
383 }
384
385 let pending_task = tasks.iter().find(|t| t.status == TaskStatus::Pending);
387
388 match pending_task {
389 Some(task) => {
390 let updated_content =
392 update_task_status(&content, task.index, TaskStatus::InProgress, None)
393 .ok_or_else(|| PlanNextError("Failed to update task status".to_string()))?;
394
395 fs::write(&file_path, &updated_content)
397 .map_err(|e| PlanNextError(format!("Failed to write plan file: {}", e)))?;
398
399 let done_count = tasks
401 .iter()
402 .filter(|t| t.status == TaskStatus::Done)
403 .count();
404 let pending_count = tasks
405 .iter()
406 .filter(|t| t.status == TaskStatus::Pending)
407 .count()
408 - 1; let failed_count = tasks
410 .iter()
411 .filter(|t| t.status == TaskStatus::Failed)
412 .count();
413
414 let result = json!({
415 "has_task": true,
416 "task_index": task.index,
417 "task_description": task.description,
418 "total_tasks": tasks.len(),
419 "completed": done_count,
420 "pending": pending_count,
421 "failed": failed_count,
422 "progress": format!("{}/{}", done_count, tasks.len()),
423 "instructions": "Execute this task using appropriate tools, then call plan_update to mark it done."
424 });
425
426 serde_json::to_string_pretty(&result)
427 .map_err(|e| PlanNextError(format!("Failed to serialize: {}", e)))
428 }
429 None => {
430 let done_count = tasks
432 .iter()
433 .filter(|t| t.status == TaskStatus::Done)
434 .count();
435 let failed_count = tasks
436 .iter()
437 .filter(|t| t.status == TaskStatus::Failed)
438 .count();
439 let in_progress = tasks
440 .iter()
441 .filter(|t| t.status == TaskStatus::InProgress)
442 .count();
443
444 let result = json!({
445 "has_task": false,
446 "total_tasks": tasks.len(),
447 "completed": done_count,
448 "failed": failed_count,
449 "in_progress": in_progress,
450 "status": if in_progress > 0 {
451 "Task in progress - complete it before getting next"
452 } else if failed_count > 0 {
453 "Plan completed with failures"
454 } else {
455 "All tasks completed successfully!"
456 }
457 });
458
459 serde_json::to_string_pretty(&result)
460 .map_err(|e| PlanNextError(format!("Failed to serialize: {}", e)))
461 }
462 }
463 }
464}
465
466#[derive(Debug, Deserialize)]
471pub struct PlanUpdateArgs {
472 pub plan_path: String,
474 pub task_index: usize,
476 pub status: String,
478 pub note: Option<String>,
480}
481
482#[derive(Debug, thiserror::Error)]
483#[error("Plan update error: {0}")]
484pub struct PlanUpdateError(String);
485
486#[derive(Debug, Clone)]
487pub struct PlanUpdateTool {
488 project_path: PathBuf,
489}
490
491impl PlanUpdateTool {
492 pub fn new(project_path: PathBuf) -> Self {
493 Self { project_path }
494 }
495
496 fn resolve_path(&self, path: &str) -> PathBuf {
497 let p = PathBuf::from(path);
498 if p.is_absolute() {
499 p
500 } else {
501 self.project_path.join(p)
502 }
503 }
504}
505
506impl Tool for PlanUpdateTool {
507 const NAME: &'static str = "plan_update";
508
509 type Error = PlanUpdateError;
510 type Args = PlanUpdateArgs;
511 type Output = String;
512
513 async fn definition(&self, _prompt: String) -> ToolDefinition {
514 ToolDefinition {
515 name: Self::NAME.to_string(),
516 description: r#"Update the status of a task in a plan file.
517
518Use this after completing or failing a task to update its status:
519- "done" - Mark task as completed `[x]`
520- "failed" - Mark task as failed `[!]` (include a note explaining why)
521- "pending" - Reset task to pending `[ ]`
522
523After marking a task done, call `plan_next` to get the next task."#
524 .to_string(),
525 parameters: json!({
526 "type": "object",
527 "properties": {
528 "plan_path": {
529 "type": "string",
530 "description": "Path to the plan file"
531 },
532 "task_index": {
533 "type": "integer",
534 "description": "1-based index of the task to update"
535 },
536 "status": {
537 "type": "string",
538 "enum": ["done", "failed", "pending"],
539 "description": "New status for the task"
540 },
541 "note": {
542 "type": "string",
543 "description": "Optional note explaining failure (required for 'failed' status)"
544 }
545 },
546 "required": ["plan_path", "task_index", "status"]
547 }),
548 }
549 }
550
551 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
552 let file_path = self.resolve_path(&args.plan_path);
553
554 let content = fs::read_to_string(&file_path)
556 .map_err(|e| PlanUpdateError(format!("Failed to read plan file: {}", e)))?;
557
558 let new_status = match args.status.to_lowercase().as_str() {
560 "done" => TaskStatus::Done,
561 "failed" => TaskStatus::Failed,
562 "pending" => TaskStatus::Pending,
563 _ => {
564 return Err(PlanUpdateError(format!(
565 "Invalid status '{}'. Use: done, failed, or pending",
566 args.status
567 )));
568 }
569 };
570
571 if new_status == TaskStatus::Failed && args.note.is_none() {
573 return Err(PlanUpdateError(
574 "A note is required when marking a task as failed".to_string(),
575 ));
576 }
577
578 let updated_content =
580 update_task_status(&content, args.task_index, new_status, args.note.as_deref())
581 .ok_or_else(|| {
582 PlanUpdateError(format!("Task {} not found in plan", args.task_index))
583 })?;
584
585 fs::write(&file_path, &updated_content)
587 .map_err(|e| PlanUpdateError(format!("Failed to write plan file: {}", e)))?;
588
589 let tasks = parse_plan_tasks(&updated_content);
591 let done_count = tasks
592 .iter()
593 .filter(|t| t.status == TaskStatus::Done)
594 .count();
595 let pending_count = tasks
596 .iter()
597 .filter(|t| t.status == TaskStatus::Pending)
598 .count();
599 let failed_count = tasks
600 .iter()
601 .filter(|t| t.status == TaskStatus::Failed)
602 .count();
603
604 let result = json!({
605 "success": true,
606 "task_index": args.task_index,
607 "new_status": args.status,
608 "progress": format!("{}/{}", done_count, tasks.len()),
609 "summary": {
610 "total": tasks.len(),
611 "done": done_count,
612 "pending": pending_count,
613 "failed": failed_count
614 },
615 "next_action": if pending_count > 0 {
616 "Call plan_next to get the next pending task"
617 } else if failed_count > 0 {
618 "Plan complete with failures. Review failed tasks."
619 } else {
620 "All tasks completed! Plan execution finished."
621 }
622 });
623
624 serde_json::to_string_pretty(&result)
625 .map_err(|e| PlanUpdateError(format!("Failed to serialize: {}", e)))
626 }
627}
628
629#[derive(Debug, Deserialize)]
634pub struct PlanListArgs {
635 pub filter: Option<String>,
637}
638
639#[derive(Debug, thiserror::Error)]
640#[error("Plan list error: {0}")]
641pub struct PlanListError(String);
642
643#[derive(Debug, Clone)]
644pub struct PlanListTool {
645 project_path: PathBuf,
646}
647
648impl PlanListTool {
649 pub fn new(project_path: PathBuf) -> Self {
650 Self { project_path }
651 }
652}
653
654impl Tool for PlanListTool {
655 const NAME: &'static str = "plan_list";
656
657 type Error = PlanListError;
658 type Args = PlanListArgs;
659 type Output = String;
660
661 async fn definition(&self, _prompt: String) -> ToolDefinition {
662 ToolDefinition {
663 name: Self::NAME.to_string(),
664 description: r#"List all plan files in the plans/ directory with their status summary.
665
666Shows each plan with:
667- Filename and path
668- Task counts (done/pending/failed)
669- Overall status"#
670 .to_string(),
671 parameters: json!({
672 "type": "object",
673 "properties": {
674 "filter": {
675 "type": "string",
676 "enum": ["all", "incomplete", "complete"],
677 "description": "Filter plans: 'all' (default), 'incomplete' (has pending), 'complete' (no pending)"
678 }
679 }
680 }),
681 }
682 }
683
684 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
685 let plans_dir = self.project_path.join("plans");
686
687 if !plans_dir.exists() {
688 let result = json!({
689 "plans": [],
690 "message": "No plans directory found. Create a plan first with plan_create."
691 });
692 return serde_json::to_string_pretty(&result)
693 .map_err(|e| PlanListError(format!("Failed to serialize: {}", e)));
694 }
695
696 let filter = args.filter.as_deref().unwrap_or("all");
697 let mut plans = Vec::new();
698
699 let entries = fs::read_dir(&plans_dir)
700 .map_err(|e| PlanListError(format!("Failed to read plans directory: {}", e)))?;
701
702 for entry in entries.flatten() {
703 let path = entry.path();
704 if path.extension().map(|e| e == "md").unwrap_or(false)
705 && let Ok(content) = fs::read_to_string(&path)
706 {
707 let tasks = parse_plan_tasks(&content);
708 let done = tasks
709 .iter()
710 .filter(|t| t.status == TaskStatus::Done)
711 .count();
712 let pending = tasks
713 .iter()
714 .filter(|t| t.status == TaskStatus::Pending)
715 .count();
716 let in_progress = tasks
717 .iter()
718 .filter(|t| t.status == TaskStatus::InProgress)
719 .count();
720 let failed = tasks
721 .iter()
722 .filter(|t| t.status == TaskStatus::Failed)
723 .count();
724
725 let include = match filter {
727 "incomplete" => pending > 0 || in_progress > 0,
728 "complete" => pending == 0 && in_progress == 0,
729 _ => true,
730 };
731
732 if include {
733 let rel_path = path
734 .strip_prefix(&self.project_path)
735 .map(|p| p.display().to_string())
736 .unwrap_or_else(|_| path.display().to_string());
737
738 plans.push(json!({
739 "path": rel_path,
740 "filename": path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
741 "tasks": {
742 "total": tasks.len(),
743 "done": done,
744 "pending": pending,
745 "in_progress": in_progress,
746 "failed": failed
747 },
748 "progress": format!("{}/{}", done, tasks.len()),
749 "status": if pending == 0 && in_progress == 0 {
750 if failed > 0 { "completed_with_failures" } else { "complete" }
751 } else if in_progress > 0 {
752 "in_progress"
753 } else {
754 "pending"
755 }
756 }));
757 }
758 }
759 }
760
761 plans.sort_by(|a, b| {
763 let a_name = a.get("filename").and_then(|v| v.as_str()).unwrap_or("");
764 let b_name = b.get("filename").and_then(|v| v.as_str()).unwrap_or("");
765 b_name.cmp(a_name)
766 });
767
768 let result = json!({
769 "plans": plans,
770 "total": plans.len(),
771 "filter": filter
772 });
773
774 serde_json::to_string_pretty(&result)
775 .map_err(|e| PlanListError(format!("Failed to serialize: {}", e)))
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use tempfile::tempdir;
783
784 #[tokio::test]
785 async fn test_list_plans_empty_directory() {
786 let dir = tempdir().unwrap();
787 let tool = PlanListTool::new(dir.path().to_path_buf());
788 let args = PlanListArgs { filter: None };
789
790 let result = tool.call(args).await.unwrap();
791 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
793 assert!(parsed.is_object());
794 if let Some(total) = parsed.get("total") {
796 assert!(total.as_u64().unwrap_or(0) == 0);
797 }
798 }
799
800 #[tokio::test]
801 async fn test_list_plans_with_plans() {
802 let dir = tempdir().unwrap();
803 let plans_dir = dir.path().join(".plans");
804 std::fs::create_dir(&plans_dir).unwrap();
805 std::fs::write(
806 plans_dir.join("2026-01-15-test.md"),
807 "# Test Plan\n\nSome content",
808 )
809 .unwrap();
810
811 let tool = PlanListTool::new(dir.path().to_path_buf());
812 let args = PlanListArgs { filter: None };
813
814 let result = tool.call(args).await.unwrap();
815 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
817 assert!(parsed.is_object());
818 }
819}