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