1use crate::tools::{
15 base::{PermissionCheckResult, Tool},
16 context::{ToolContext, ToolOptions, ToolResult},
17 error::ToolError,
18};
19use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Value};
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26use uuid::Uuid;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PlanModeState {
35 pub active: bool,
36 pub plan_file: String,
37 pub plan_id: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ToolPermissionContext {
43 pub mode: String, }
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct AppState {
49 pub tool_permission_context: ToolPermissionContext,
50 pub plan_mode: Option<PlanModeState>,
51}
52
53impl Default for AppState {
54 fn default() -> Self {
55 Self {
56 tool_permission_context: ToolPermissionContext {
57 mode: "normal".to_string(),
58 },
59 plan_mode: None,
60 }
61 }
62}
63
64pub struct GlobalStateManager {
66 state: Arc<Mutex<AppState>>,
67}
68
69impl GlobalStateManager {
70 pub fn new() -> Self {
71 Self {
72 state: Arc::new(Mutex::new(AppState::default())),
73 }
74 }
75}
76
77impl Default for GlobalStateManager {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl GlobalStateManager {
84 pub fn get_state(&self) -> AppState {
85 self.state.lock().unwrap().clone()
86 }
87
88 pub fn update_state<F>(&self, updater: F)
89 where
90 F: FnOnce(&mut AppState),
91 {
92 let mut state = self.state.lock().unwrap();
93 updater(&mut state);
94 }
95
96 pub fn is_plan_mode_active(&self) -> bool {
97 let state = self.state.lock().unwrap();
98 state.tool_permission_context.mode == "plan"
99 }
100
101 pub fn get_plan_file(&self) -> Option<String> {
102 let state = self.state.lock().unwrap();
103 state.plan_mode.as_ref().map(|pm| pm.plan_file.clone())
104 }
105
106 pub fn get_current_plan_id(&self) -> Option<String> {
107 let state = self.state.lock().unwrap();
108 state.plan_mode.as_ref().map(|pm| pm.plan_id.clone())
109 }
110
111 pub fn set_plan_mode(&self, active: bool, plan_file: Option<String>, plan_id: Option<String>) {
112 self.update_state(|state| {
113 if active {
114 let plan_file = plan_file.unwrap_or_else(|| {
115 std::env::current_dir()
116 .unwrap_or_else(|_| PathBuf::from("."))
117 .join("PLAN.md")
118 .to_string_lossy()
119 .to_string()
120 });
121 let plan_id = plan_id.unwrap_or_else(|| Uuid::new_v4().to_string());
122
123 state.tool_permission_context.mode = "plan".to_string();
124 state.plan_mode = Some(PlanModeState {
125 active: true,
126 plan_file,
127 plan_id,
128 });
129 } else {
130 state.tool_permission_context.mode = "normal".to_string();
131 state.plan_mode = None;
132 }
133 });
134 }
135}
136
137lazy_static::lazy_static! {
139 static ref GLOBAL_STATE: GlobalStateManager = GlobalStateManager::new();
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct SavedPlan {
149 pub metadata: PlanMetadata,
150 pub summary: String,
151 pub requirements_analysis: RequirementsAnalysis,
152 pub architectural_decisions: Vec<ArchitecturalDecision>,
153 pub steps: Vec<PlanStep>,
154 pub critical_files: Vec<CriticalFile>,
155 pub risks: Vec<Risk>,
156 pub alternatives: Vec<Alternative>,
157 pub estimated_complexity: String,
158 pub content: String,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct PlanMetadata {
163 pub id: String,
164 pub title: String,
165 pub description: String,
166 pub status: String, pub created_at: u64,
168 pub updated_at: u64,
169 pub working_directory: String,
170 pub version: u32,
171 pub priority: String, }
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct RequirementsAnalysis {
176 pub functional_requirements: Vec<String>,
177 pub non_functional_requirements: Vec<String>,
178 pub technical_constraints: Vec<String>,
179 pub success_criteria: Vec<String>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ArchitecturalDecision {
184 pub title: String,
185 pub description: String,
186 pub rationale: String,
187 pub alternatives: Vec<String>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct PlanStep {
192 pub step: u32,
193 pub description: String,
194 pub files: Vec<String>,
195 pub complexity: String, pub dependencies: Vec<u32>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct CriticalFile {
201 pub path: String,
202 pub reason: String,
203 pub importance: u32, }
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct Risk {
208 pub category: String, pub level: String, pub description: String,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct Alternative {
215 pub title: String,
216 pub description: String,
217 pub pros: Vec<String>,
218 pub cons: Vec<String>,
219}
220
221pub struct PlanPersistenceManager;
223
224impl PlanPersistenceManager {
225 pub fn generate_plan_id() -> String {
227 Uuid::new_v4().to_string()
228 }
229
230 pub fn get_plans_dir() -> PathBuf {
232 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
233 PathBuf::from(home).join(".aster").join("plans")
234 }
235
236 pub fn save_plan(plan: &SavedPlan) -> Result<bool, ToolError> {
238 let plans_dir = Self::get_plans_dir();
239 if let Err(e) = fs::create_dir_all(&plans_dir) {
240 return Err(ToolError::ExecutionFailed(format!(
241 "Failed to create plans directory: {}",
242 e
243 )));
244 }
245
246 let plan_file = plans_dir.join(format!("{}.json", plan.metadata.id));
247 let plan_json = serde_json::to_string_pretty(plan)
248 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to serialize plan: {}", e)))?;
249
250 fs::write(&plan_file, plan_json)
251 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to write plan file: {}", e)))?;
252
253 Ok(true)
254 }
255
256 pub fn load_plan(plan_id: &str) -> Result<SavedPlan, ToolError> {
258 let plans_dir = Self::get_plans_dir();
259 let plan_file = plans_dir.join(format!("{}.json", plan_id));
260
261 if !plan_file.exists() {
262 return Err(ToolError::NotFound(format!("Plan not found: {}", plan_id)));
263 }
264
265 let plan_json = fs::read_to_string(&plan_file)
266 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to read plan file: {}", e)))?;
267
268 let plan: SavedPlan = serde_json::from_str(&plan_json).map_err(|e| {
269 ToolError::ExecutionFailed(format!("Failed to deserialize plan: {}", e))
270 })?;
271
272 Ok(plan)
273 }
274}
275
276pub struct EnterPlanModeTool;
285
286impl EnterPlanModeTool {
287 pub fn new() -> Self {
288 Self
289 }
290}
291
292impl Default for EnterPlanModeTool {
293 fn default() -> Self {
294 Self::new()
295 }
296}
297
298#[async_trait]
299impl Tool for EnterPlanModeTool {
300 fn name(&self) -> &str {
301 "EnterPlanMode"
302 }
303
304 fn description(&self) -> &str {
305 r#"Use this tool when you encounter a complex task that requires careful planning and exploration before implementation.
306
307## When to Use This Tool
308
309**Prefer using EnterPlanMode** for implementation tasks unless they're simple. Use it when ANY of these conditions apply:
310
3111. **New Feature Implementation**: Adding meaningful new functionality
312 - Example: "Add a logout button" - where should it go? What should happen on click?
313 - Example: "Add form validation" - what rules? What error messages?
314
3152. **Multiple Valid Approaches**: The task can be solved in several different ways
316 - Example: "Add caching to the API" - could use Redis, in-memory, file-based, etc.
317 - Example: "Improve performance" - many optimization strategies possible
318
3193. **Code Modifications**: Changes that affect existing behavior or structure
320 - Example: "Update the login flow" - what exactly should change?
321 - Example: "Refactor this component" - what's the target architecture?
322
3234. **Architectural Decisions**: The task requires choosing between patterns or technologies
324 - Example: "Add real-time updates" - WebSockets vs SSE vs polling
325 - Example: "Implement state management" - Redux vs Context vs custom solution
326
3275. **Multi-File Changes**: The task will likely touch more than 2-3 files
328 - Example: "Refactor the authentication system"
329 - Example: "Add a new API endpoint with tests"
330
3316. **Unclear Requirements**: You need to explore before understanding the full scope
332 - Example: "Make the app faster" - need to profile and identify bottlenecks
333 - Example: "Fix the bug in checkout" - need to investigate root cause
334
3357. **User Preferences Matter**: The implementation could reasonably go multiple ways
336 - If you would use AskUserQuestion to clarify the approach, use EnterPlanMode instead
337 - Plan mode lets you explore first, then present options with context
338
339## When NOT to Use This Tool
340
341Only skip EnterPlanMode for simple tasks:
342- Single-line or few-line fixes (typos, obvious bugs, small tweaks)
343- Adding a single function with clear requirements
344- Tasks where the user has given very specific, detailed instructions
345- Pure research/exploration tasks (use the Task tool with explore agent instead)
346
347## What Happens in Plan Mode
348
349In plan mode, you'll:
3501. Thoroughly explore the codebase using Glob, Grep, and Read tools
3512. Understand existing patterns and architecture
3523. Design an implementation approach
3534. Present your plan to the user for approval
3545. Use AskUserQuestion if you need to clarify approaches
3556. Exit plan mode with ExitPlanMode when ready to implement
356
357## Examples
358
359### GOOD - Use EnterPlanMode:
360User: "Add user authentication to the app"
361- Requires architectural decisions (session vs JWT, where to store tokens, middleware structure)
362
363User: "Optimize the database queries"
364- Multiple approaches possible, need to profile first, significant impact
365
366User: "Implement dark mode"
367- Architectural decision on theme system, affects many components
368
369User: "Add a delete button to the user profile"
370- Seems simple but involves: where to place it, confirmation dialog, API call, error handling, state updates
371
372User: "Update the error handling in the API"
373- Affects multiple files, user should approve the approach
374
375### BAD - Don't use EnterPlanMode:
376User: "Fix the typo in the README"
377- Straightforward, no planning needed
378
379User: "Add a console.log to debug this function"
380- Simple, obvious implementation
381
382User: "What files handle routing?"
383- Research task, not implementation planning
384
385## Important Notes
386
387- This tool REQUIRES user approval - they must consent to entering plan mode
388- If unsure whether to use it, err on the side of planning - it's better to get alignment upfront than to redo work
389- Users appreciate being consulted before significant changes are made to their codebase"#
390 }
391
392 fn input_schema(&self) -> Value {
393 json!({
394 "type": "object",
395 "properties": {},
396 "required": []
397 })
398 }
399
400 fn options(&self) -> ToolOptions {
401 ToolOptions::new()
402 .with_base_timeout(Duration::from_secs(30))
403 .with_max_retries(0)
404 }
405
406 async fn check_permissions(
407 &self,
408 _params: &Value,
409 _context: &ToolContext,
410 ) -> PermissionCheckResult {
411 PermissionCheckResult::ask("Enter plan mode?")
412 }
413
414 async fn execute(
415 &self,
416 _params: Value,
417 _context: &ToolContext,
418 ) -> Result<ToolResult, ToolError> {
419 if GLOBAL_STATE.is_plan_mode_active() {
421 return Ok(ToolResult::error(
422 "Already in plan mode. Use ExitPlanMode to exit first.",
423 ));
424 }
425
426 let plan_id = PlanPersistenceManager::generate_plan_id();
428 let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
429 let plan_path = current_dir.join("PLAN.md");
430
431 let now = SystemTime::now()
433 .duration_since(UNIX_EPOCH)
434 .unwrap()
435 .as_millis() as u64;
436
437 let initial_plan = SavedPlan {
438 metadata: PlanMetadata {
439 id: plan_id.clone(),
440 title: "Untitled Plan".to_string(),
441 description: "Plan created in plan mode".to_string(),
442 status: "draft".to_string(),
443 created_at: now,
444 updated_at: now,
445 working_directory: current_dir.to_string_lossy().to_string(),
446 version: 1,
447 priority: "medium".to_string(),
448 },
449 summary: "Plan in progress".to_string(),
450 requirements_analysis: RequirementsAnalysis {
451 functional_requirements: vec![],
452 non_functional_requirements: vec![],
453 technical_constraints: vec![],
454 success_criteria: vec![],
455 },
456 architectural_decisions: vec![],
457 steps: vec![],
458 critical_files: vec![],
459 risks: vec![],
460 alternatives: vec![],
461 estimated_complexity: "moderate".to_string(),
462 content: "# Implementation Plan\n\n(Building plan...)".to_string(),
463 };
464
465 PlanPersistenceManager::save_plan(&initial_plan)?;
467
468 GLOBAL_STATE.set_plan_mode(
470 true,
471 Some(plan_path.to_string_lossy().to_string()),
472 Some(plan_id.clone()),
473 );
474
475 let output = format!(
476 r#"Entered plan mode.
477
478Plan ID: {}
479
480=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
481This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
482- Creating new files (no Write, touch, or file creation of any kind) EXCEPT the plan file
483- Modifying existing files (no Edit operations) EXCEPT the plan file
484- Deleting files (no rm or deletion)
485- Moving or copying files (no mv or cp)
486- Creating temporary files anywhere, including /tmp
487- Using redirect operators (>, >>, |) or heredocs to write to files
488- Running ANY commands that change system state
489
490Your role is EXCLUSIVELY to explore the codebase and design implementation plans. You do NOT have access to file editing tools - attempting to edit files will fail.
491
492## Plan File Info:
493No plan file exists yet. You should create your plan at {} using the Write tool.
494You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
495
496The plan will be automatically saved to the persistent storage (~/.aster/plans/{}.json) when you exit plan mode.
497
498In plan mode, you should:
4991. Thoroughly explore the codebase to understand existing patterns
5002. Identify similar features and architectural approaches
5013. Consider multiple approaches and their trade-offs
5024. Use AskUserQuestion if you need to clarify the approach
5035. Design a concrete implementation strategy
5046. When ready, use ExitPlanMode to present your plan for approval
505
506Focus on understanding the problem before proposing solutions."#,
507 plan_id,
508 plan_path.display(),
509 plan_id
510 );
511
512 Ok(ToolResult::success(output)
513 .with_metadata("plan_id", json!(plan_id))
514 .with_metadata("plan_file", json!(plan_path.to_string_lossy()))
515 .with_metadata("mode", json!("plan")))
516 }
517}
518
519#[derive(Debug, Serialize, Deserialize)]
525pub struct ExitPlanModeInput {}
526
527pub struct ExitPlanModeTool;
532
533impl ExitPlanModeTool {
534 pub fn new() -> Self {
535 Self
536 }
537
538 fn parse_plan_content(&self, plan_id: &str, content: &str) -> SavedPlan {
540 let now = SystemTime::now()
541 .duration_since(UNIX_EPOCH)
542 .unwrap()
543 .as_millis() as u64;
544
545 let title = content
547 .lines()
548 .find(|line| line.starts_with("# "))
549 .map(|line| line.trim_start_matches("# ").trim())
550 .unwrap_or("Untitled Plan")
551 .to_string();
552
553 SavedPlan {
554 metadata: PlanMetadata {
555 id: plan_id.to_string(),
556 title: title.clone(),
557 description: title,
558 status: "pending".to_string(),
559 created_at: now,
560 updated_at: now,
561 working_directory: std::env::current_dir()
562 .unwrap_or_else(|_| PathBuf::from("."))
563 .to_string_lossy()
564 .to_string(),
565 version: 1,
566 priority: "medium".to_string(),
567 },
568 summary: self.extract_summary(content),
569 requirements_analysis: RequirementsAnalysis {
570 functional_requirements: self
571 .extract_requirements(content, "Functional Requirements"),
572 non_functional_requirements: self
573 .extract_requirements(content, "Non-Functional Requirements"),
574 technical_constraints: self.extract_requirements(content, "Technical Constraints"),
575 success_criteria: self.extract_requirements(content, "Success Criteria"),
576 },
577 architectural_decisions: vec![],
578 steps: self.extract_steps(content),
579 critical_files: self.extract_critical_files(content),
580 risks: self.extract_risks(content),
581 alternatives: vec![],
582 estimated_complexity: "moderate".to_string(),
583 content: content.to_string(),
584 }
585 }
586
587 fn extract_summary(&self, content: &str) -> String {
588 if let Some(start) = content.find("## Summary") {
590 let after_header = content.get(start..).unwrap_or("");
591 if let Some(content_start) = after_header.find('\n') {
592 let content_part = after_header.get(content_start + 1..).unwrap_or("");
593 if let Some(end) = content_part.find("\n##") {
594 return content_part.get(..end).unwrap_or("").trim().to_string();
595 } else {
596 return content_part.trim().to_string();
597 }
598 }
599 }
600 "No summary provided".to_string()
601 }
602
603 fn extract_requirements(&self, content: &str, section: &str) -> Vec<String> {
604 let section_header = format!("### {}", section);
605 if let Some(start) = content.find(§ion_header) {
606 let after_header = content.get(start..).unwrap_or("");
607 if let Some(content_start) = after_header.find('\n') {
608 let content_part = after_header.get(content_start + 1..).unwrap_or("");
609 let end = content_part
610 .find("\n###")
611 .or_else(|| content_part.find("\n##"))
612 .unwrap_or(content_part.len());
613 let section_content = content_part.get(..end).unwrap_or("");
614
615 return section_content
616 .lines()
617 .filter_map(|line| {
618 let trimmed = line.trim();
619 trimmed
620 .strip_prefix("- ")
621 .map(|stripped| stripped.trim().to_string())
622 })
623 .filter(|line| !line.is_empty())
624 .collect();
625 }
626 }
627 vec![]
628 }
629
630 fn extract_steps(&self, content: &str) -> Vec<PlanStep> {
631 let mut steps = vec![];
632
633 for line in content.lines() {
635 if line.starts_with("### Step ") {
636 if let Some(colon_pos) = line.find(": ") {
637 let step_part = line.get(9..colon_pos).unwrap_or(""); if let Ok(step_number) = step_part.parse::<u32>() {
639 let description = line.get(colon_pos + 2..).unwrap_or("");
640 steps.push(PlanStep {
641 step: step_number,
642 description: description.to_string(),
643 files: vec![],
644 complexity: "medium".to_string(),
645 dependencies: vec![],
646 });
647 }
648 }
649 }
650 }
651
652 steps
653 }
654
655 fn extract_critical_files(&self, content: &str) -> Vec<CriticalFile> {
656 let mut files = vec![];
657
658 if let Some(start) = content.find("### Critical Files") {
659 let after_header = content.get(start..).unwrap_or("");
660 if let Some(content_start) = after_header.find('\n') {
661 let content_part = after_header.get(content_start + 1..).unwrap_or("");
662 let end = content_part.find("\n##").unwrap_or(content_part.len());
663 let section_content = content_part.get(..end).unwrap_or("");
664
665 for line in section_content.lines() {
666 let trimmed = line.trim();
667 if let Some(content_line) = trimmed.strip_prefix("- ") {
668 if let Some(dash_pos) = content_line.find(" - ") {
669 let path = content_line.get(..dash_pos).unwrap_or("").trim();
670 let reason = content_line.get(dash_pos + 3..).unwrap_or("").trim();
671 files.push(CriticalFile {
672 path: path.to_string(),
673 reason: reason.to_string(),
674 importance: 3,
675 });
676 }
677 }
678 }
679 }
680 }
681
682 files
683 }
684
685 fn extract_risks(&self, content: &str) -> Vec<Risk> {
686 let mut risks = vec![];
687
688 if let Some(start) = content.find("## Risks") {
689 let after_header = content.get(start..).unwrap_or("");
690 if let Some(content_start) = after_header.find('\n') {
691 let content_part = after_header.get(content_start + 1..).unwrap_or("");
692 let end = content_part.find("\n## ").unwrap_or(content_part.len());
694 let section_content = content_part.get(..end).unwrap_or("");
695
696 let risk_blocks: Vec<&str> = section_content.split("### ").collect();
698 for block in risk_blocks {
699 if block.trim().is_empty() {
700 continue;
701 }
702
703 let lines: Vec<&str> = block.lines().collect();
704 if let Some(first_line) = lines.first() {
705 let description = first_line.trim();
706 if !description.is_empty() {
707 let clean_description = if let Some(dot_pos) = description.find(". ") {
709 description.get(dot_pos + 2..).unwrap_or(description)
710 } else {
711 description
712 };
713
714 risks.push(Risk {
715 category: "technical".to_string(),
716 level: "medium".to_string(),
717 description: clean_description.to_string(),
718 });
719 }
720 }
721 }
722 }
723 }
724
725 risks
726 }
727}
728
729impl Default for ExitPlanModeTool {
730 fn default() -> Self {
731 Self::new()
732 }
733}
734
735#[async_trait]
736impl Tool for ExitPlanModeTool {
737 fn name(&self) -> &str {
738 "ExitPlanMode"
739 }
740
741 fn description(&self) -> &str {
742 r#"Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval.
743
744## How This Tool Works
745- You should have already written your plan to the plan file specified in the plan mode system message
746- This tool does NOT take the plan content as a parameter - it will read the plan from the file you wrote
747- This tool simply signals that you're done planning and ready for the user to review and approve
748- The user will see the contents of your plan file when they review it
749
750## When to Use This Tool
751IMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool.
752
753## Handling Ambiguity in Plans
754Before using this tool, ensure your plan is clear and unambiguous. If there are multiple valid approaches or unclear requirements:
7551. Use the AskUserQuestion tool to clarify with the user
7562. Ask about specific implementation choices (e.g., architectural patterns, which library to use)
7573. Clarify any assumptions that could affect the implementation
7584. Edit your plan file to incorporate user feedback
7595. Only proceed with ExitPlanMode after resolving ambiguities and updating the plan file
760
761## Examples
762
7631. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task.
7642. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task.
7653. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use AskUserQuestion first, then use exit plan mode tool after clarifying the approach."#
766 }
767
768 fn input_schema(&self) -> Value {
769 json!({
770 "type": "object",
771 "properties": {},
772 "required": []
773 })
774 }
775
776 fn options(&self) -> ToolOptions {
777 ToolOptions::new()
778 .with_base_timeout(Duration::from_secs(30))
779 .with_max_retries(0)
780 }
781
782 async fn check_permissions(
783 &self,
784 _params: &Value,
785 _context: &ToolContext,
786 ) -> PermissionCheckResult {
787 PermissionCheckResult::ask("Exit plan mode?")
788 }
789
790 async fn execute(
791 &self,
792 _params: Value,
793 _context: &ToolContext,
794 ) -> Result<ToolResult, ToolError> {
795 if !GLOBAL_STATE.is_plan_mode_active() {
797 return Ok(ToolResult::error(
798 "Not in plan mode. Use EnterPlanMode first.",
799 ));
800 }
801
802 let plan_file = GLOBAL_STATE.get_plan_file();
804 let plan_id = GLOBAL_STATE.get_current_plan_id();
805
806 let mut plan_content = String::new();
807 if let Some(ref plan_file_path) = plan_file {
808 if Path::new(plan_file_path).exists() {
809 plan_content = fs::read_to_string(plan_file_path).unwrap_or_default();
810 }
811 }
812
813 let mut saved_plan_path: Option<String> = None;
815 if let (Some(plan_id), false) = (&plan_id, plan_content.is_empty()) {
816 let plan = self.parse_plan_content(plan_id, &plan_content);
817 match PlanPersistenceManager::save_plan(&plan) {
818 Ok(_) => {
819 saved_plan_path = Some(format!("~/.aster/plans/{}.json", plan_id));
820 }
821 Err(e) => {
822 eprintln!("Failed to save plan to persistence: {}", e);
823 }
824 }
825 }
826
827 GLOBAL_STATE.set_plan_mode(false, None, None);
829
830 let output = if let Some(ref plan_file_path) = plan_file {
831 format!(
832 r#"Exited plan mode.
833
834Your plan has been saved to:
835- Working file: {}{}
836{}
837
838Awaiting user approval to proceed with implementation.
839
840## Approved Plan:
841{}"#,
842 plan_file_path,
843 saved_plan_path
844 .as_ref()
845 .map(|p| format!("\n- Persistent storage: {}", p))
846 .unwrap_or_default(),
847 plan_id
848 .as_ref()
849 .map(|id| format!("\nPlan ID: {}", id))
850 .unwrap_or_default(),
851 plan_content
852 )
853 } else {
854 "Exited plan mode. Awaiting user approval to proceed with implementation.".to_string()
855 };
856
857 Ok(ToolResult::success(output)
858 .with_metadata("plan_id", json!(plan_id))
859 .with_metadata("plan_file", json!(plan_file))
860 .with_metadata("saved_plan_path", json!(saved_plan_path))
861 .with_metadata("mode", json!("normal")))
862 }
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868 use crate::tools::{context::ToolContext, PermissionBehavior};
869 use serde_json::json;
870 use std::collections::HashMap;
871 use tempfile::TempDir;
872 use tokio;
873
874 fn create_test_context() -> ToolContext {
875 ToolContext {
876 working_directory: std::env::current_dir().unwrap(),
877 session_id: "test-session".to_string(),
878 user: Some("test-user".to_string()),
879 environment: HashMap::new(),
880 cancellation_token: None,
881 }
882 }
883
884 #[test]
885 fn test_enter_plan_mode_tool_creation() {
886 let tool = EnterPlanModeTool::new();
887 assert_eq!(tool.name(), "EnterPlanMode");
888 assert!(tool.description().contains("complex task"));
889 }
890
891 #[test]
892 fn test_exit_plan_mode_tool_creation() {
893 let tool = ExitPlanModeTool::new();
894 assert_eq!(tool.name(), "ExitPlanMode");
895 assert!(tool.description().contains("finished writing your plan"));
896 }
897
898 #[test]
899 fn test_global_state_manager() {
900 let manager = GlobalStateManager::new();
901
902 assert!(!manager.is_plan_mode_active());
904 assert!(manager.get_plan_file().is_none());
905 assert!(manager.get_current_plan_id().is_none());
906
907 manager.set_plan_mode(
909 true,
910 Some("test.md".to_string()),
911 Some("test-id".to_string()),
912 );
913 assert!(manager.is_plan_mode_active());
914 assert_eq!(manager.get_plan_file(), Some("test.md".to_string()));
915 assert_eq!(manager.get_current_plan_id(), Some("test-id".to_string()));
916
917 manager.set_plan_mode(false, None, None);
919 assert!(!manager.is_plan_mode_active());
920 assert!(manager.get_plan_file().is_none());
921 assert!(manager.get_current_plan_id().is_none());
922 }
923
924 #[test]
925 fn test_plan_persistence_manager() {
926 let _temp_dir = TempDir::new().unwrap();
927 let _plans_dir = _temp_dir.path().join("plans");
928
929 let plan_id = "test-plan-id";
931 let now = SystemTime::now()
932 .duration_since(UNIX_EPOCH)
933 .unwrap()
934 .as_millis() as u64;
935
936 let _plan = SavedPlan {
937 metadata: PlanMetadata {
938 id: plan_id.to_string(),
939 title: "Test Plan".to_string(),
940 description: "A test plan".to_string(),
941 status: "draft".to_string(),
942 created_at: now,
943 updated_at: now,
944 working_directory: "/tmp".to_string(),
945 version: 1,
946 priority: "medium".to_string(),
947 },
948 summary: "Test summary".to_string(),
949 requirements_analysis: RequirementsAnalysis {
950 functional_requirements: vec!["Req 1".to_string()],
951 non_functional_requirements: vec![],
952 technical_constraints: vec![],
953 success_criteria: vec![],
954 },
955 architectural_decisions: vec![],
956 steps: vec![],
957 critical_files: vec![],
958 risks: vec![],
959 alternatives: vec![],
960 estimated_complexity: "low".to_string(),
961 content: "# Test Plan\n\nThis is a test plan.".to_string(),
962 };
963
964 let generated_id = PlanPersistenceManager::generate_plan_id();
967 assert!(!generated_id.is_empty());
968 assert!(generated_id.len() > 10); }
970
971 #[tokio::test]
972 async fn test_enter_plan_mode_permissions() {
973 let tool = EnterPlanModeTool::new();
974 let context = create_test_context();
975 let input = json!({});
976
977 let result = tool.check_permissions(&input, &context).await;
978 assert!(matches!(result.behavior, PermissionBehavior::Ask));
979 assert_eq!(result.message, Some("Enter plan mode?".to_string()));
980 }
981
982 #[tokio::test]
983 async fn test_exit_plan_mode_permissions() {
984 let tool = ExitPlanModeTool::new();
985 let context = create_test_context();
986 let input = json!({});
987
988 let result = tool.check_permissions(&input, &context).await;
989 assert!(matches!(result.behavior, PermissionBehavior::Ask));
990 assert_eq!(result.message, Some("Exit plan mode?".to_string()));
991 }
992
993 #[tokio::test]
994 async fn test_enter_plan_mode_execution() {
995 let tool = EnterPlanModeTool::new();
996 let context = create_test_context();
997 let input = json!({});
998
999 GLOBAL_STATE.set_plan_mode(false, None, None);
1001
1002 let result = tool.execute(input, &context).await.unwrap();
1003 assert!(result.success);
1004 assert!(result.output.is_some());
1005 assert!(result
1006 .output
1007 .as_ref()
1008 .unwrap()
1009 .contains("Entered plan mode"));
1010 assert!(!result.metadata.is_empty());
1011
1012 assert!(GLOBAL_STATE.is_plan_mode_active());
1014 }
1015
1016 #[tokio::test]
1017 async fn test_exit_plan_mode_execution_not_in_plan_mode() {
1018 let tool = ExitPlanModeTool::new();
1019 let context = create_test_context();
1020 let input = json!({});
1021
1022 GLOBAL_STATE.set_plan_mode(false, None, None);
1024
1025 let result = tool.execute(input, &context).await.unwrap();
1026 assert!(!result.success);
1027 assert!(result.error.is_some());
1028 assert!(result.error.as_ref().unwrap().contains("Not in plan mode"));
1029 }
1030
1031 #[tokio::test]
1032 async fn test_exit_plan_mode_execution_in_plan_mode() {
1033 let tool = ExitPlanModeTool::new();
1034 let context = create_test_context();
1035 let input = json!({});
1036
1037 GLOBAL_STATE.set_plan_mode(
1039 true,
1040 Some("test.md".to_string()),
1041 Some("test-id".to_string()),
1042 );
1043
1044 let result = tool.execute(input, &context).await.unwrap();
1045 assert!(result.success);
1046 assert!(result.output.is_some());
1047 assert!(result.output.as_ref().unwrap().contains("Exited plan mode"));
1048
1049 assert!(!GLOBAL_STATE.is_plan_mode_active());
1051 }
1052
1053 #[test]
1054 fn test_plan_content_parsing() {
1055 let tool = ExitPlanModeTool::new();
1056 let content = r#"# Test Implementation Plan
1057
1058## Summary
1059
1060This is a test plan for implementing a new feature.
1061
1062### Functional Requirements
1063
1064- Requirement 1
1065- Requirement 2
1066
1067### Technical Constraints
1068
1069- Constraint 1
1070
1071### Step 1: Initial Setup
1072
1073Set up the basic structure.
1074
1075### Step 2: Implementation
1076
1077Implement the core functionality.
1078
1079### Critical Files
1080
1081- src/main.rs - Main entry point
1082- src/lib.rs - Core library
1083
1084## Risks
1085
1086### 1. Performance Risk
1087
1088The implementation might be slow.
1089
1090### 2. Security Risk
1091
1092Need to validate inputs properly.
1093"#;
1094
1095 let plan = tool.parse_plan_content("test-id", content);
1096
1097 assert_eq!(plan.metadata.title, "Test Implementation Plan");
1098 assert_eq!(
1099 plan.summary,
1100 "This is a test plan for implementing a new feature."
1101 );
1102 assert_eq!(plan.requirements_analysis.functional_requirements.len(), 2);
1103 assert_eq!(plan.requirements_analysis.technical_constraints.len(), 1);
1104 assert_eq!(plan.steps.len(), 2);
1105 assert_eq!(plan.critical_files.len(), 2);
1106 assert_eq!(plan.risks.len(), 2);
1107 assert_eq!(plan.content, content);
1108 }
1109
1110 #[test]
1111 fn test_tool_definitions() {
1112 let enter_tool = EnterPlanModeTool::new();
1113 let exit_tool = ExitPlanModeTool::new();
1114
1115 let enter_def = enter_tool.get_definition();
1116 let exit_def = exit_tool.get_definition();
1117
1118 assert_eq!(enter_def.name, "EnterPlanMode");
1119 assert_eq!(exit_def.name, "ExitPlanMode");
1120
1121 assert!(enter_def.input_schema.get("type").is_some());
1123 assert!(exit_def.input_schema.get("type").is_some());
1124 }
1125
1126 #[test]
1127 fn test_tool_options() {
1128 let enter_tool = EnterPlanModeTool::new();
1129 let exit_tool = ExitPlanModeTool::new();
1130
1131 let enter_options = enter_tool.options();
1132 let exit_options = exit_tool.options();
1133
1134 assert_eq!(enter_options.base_timeout, Duration::from_secs(30));
1135 assert_eq!(enter_options.max_retries, 0);
1136 assert_eq!(exit_options.base_timeout, Duration::from_secs(30));
1137 assert_eq!(exit_options.max_retries, 0);
1138 }
1139}