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