mermaid_cli/agents/
plan.rs

1use crate::agents::types::{ActionResult, AgentAction};
2use std::time::Instant;
3
4/// The status of a planned action
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ActionStatus {
7    /// Not executed yet
8    Pending,
9    /// Currently running
10    Executing,
11    /// Successfully finished
12    Completed,
13    /// Failed with error
14    Failed,
15    /// User chose to skip
16    Skipped,
17}
18
19impl ActionStatus {
20    /// Get status indicator for display
21    pub fn indicator(&self) -> &str {
22        match self {
23            ActionStatus::Pending => "•",
24            ActionStatus::Executing => "...",
25            ActionStatus::Completed => "✓",
26            ActionStatus::Failed => "✗",
27            ActionStatus::Skipped => "-",
28        }
29    }
30
31    /// Check if action is in a terminal state
32    pub fn is_terminal(&self) -> bool {
33        matches!(
34            self,
35            ActionStatus::Completed | ActionStatus::Failed | ActionStatus::Skipped
36        )
37    }
38}
39
40/// A single action within a plan
41#[derive(Debug, Clone)]
42pub struct PlannedAction {
43    /// The action to execute
44    pub action: AgentAction,
45    /// Current status of this action
46    pub status: ActionStatus,
47    /// Result of the action (if completed)
48    pub result: Option<ActionResult>,
49    /// Error message (if failed)
50    pub error: Option<String>,
51}
52
53impl PlannedAction {
54    /// Create a new pending action
55    pub fn new(action: AgentAction) -> Self {
56        Self {
57            action,
58            status: ActionStatus::Pending,
59            result: None,
60            error: None,
61        }
62    }
63
64    /// Get a short description of this action for display
65    pub fn description(&self) -> String {
66        match &self.action {
67            AgentAction::ReadFile { path } => format!("Read {}", path),
68            AgentAction::WriteFile { path, .. } => format!("Write {}", path),
69            AgentAction::DeleteFile { path } => format!("Delete {}", path),
70            AgentAction::CreateDirectory { path } => format!("Create dir {}", path),
71            AgentAction::ExecuteCommand { command, .. } => format!("Run: {}", command),
72            AgentAction::GitDiff { path } => {
73                format!("Git diff {}", path.as_deref().unwrap_or("*"))
74            },
75            AgentAction::GitCommit { message, .. } => format!("Git commit: {}", message),
76            AgentAction::GitStatus => "Git status".to_string(),
77            AgentAction::WebSearch { query, .. } => format!("Search: {}", query),
78            AgentAction::ParallelRead { paths } => format!("Read {} files", paths.len()),
79            AgentAction::ParallelWebSearch { queries } => format!("Search {} queries", queries.len()),
80            AgentAction::ParallelGitDiff { paths } => format!("Git diff {} paths", paths.len()),
81        }
82    }
83
84    /// Get action type for display
85    pub fn action_type(&self) -> &str {
86        match &self.action {
87            AgentAction::ReadFile { .. } => "Read",
88            AgentAction::WriteFile { .. } => "Write",
89            AgentAction::DeleteFile { .. } => "Delete",
90            AgentAction::CreateDirectory { .. } => "Create",
91            AgentAction::ExecuteCommand { .. } => "Bash",
92            AgentAction::GitDiff { .. } => "GitDiff",
93            AgentAction::GitCommit { .. } => "GitCommit",
94            AgentAction::GitStatus => "GitStatus",
95            AgentAction::WebSearch { .. } => "WebSearch",
96            AgentAction::ParallelRead { .. } => "ReadFiles",
97            AgentAction::ParallelWebSearch { .. } => "WebSearches",
98            AgentAction::ParallelGitDiff { .. } => "GitDiffs",
99        }
100    }
101}
102
103/// A complete plan of actions to execute
104#[derive(Debug, Clone)]
105pub struct Plan {
106    /// All actions in the plan
107    pub actions: Vec<PlannedAction>,
108    /// When this plan was created
109    pub created_at: Instant,
110    /// LLM's explanation of what it plans to do
111    pub explanation: Option<String>,
112    /// Pre-formatted markdown text for display
113    pub display_text: String,
114}
115
116impl Plan {
117    /// Create a new plan from a list of actions
118    pub fn new(actions: Vec<AgentAction>) -> Self {
119        Self::with_explanation(None, actions)
120    }
121
122    /// Create a new plan with an explanation from the LLM
123    pub fn with_explanation(explanation: Option<String>, actions: Vec<AgentAction>) -> Self {
124        let planned_actions: Vec<PlannedAction> =
125            actions.into_iter().map(PlannedAction::new).collect();
126
127        let display_text = Self::format_display_with_explanation(&explanation, &planned_actions);
128
129        Self {
130            actions: planned_actions,
131            created_at: Instant::now(),
132            explanation,
133            display_text,
134        }
135    }
136
137    /// Format plan with explanation and actions for display
138    fn format_display_with_explanation(
139        explanation: &Option<String>,
140        actions: &[PlannedAction],
141    ) -> String {
142        let mut output = String::new();
143
144        // Add explanation if provided
145        if let Some(exp) = explanation {
146            let trimmed = exp.trim();
147            if !trimmed.is_empty() {
148                output.push_str(trimmed);
149                output.push_str("\n\n");
150            }
151        }
152
153        // Add action summary
154        let actions_text = Self::format_display_actions(actions);
155        output.push_str(&actions_text);
156        output
157    }
158
159    /// Format plan actions for display as markdown
160    #[allow(dead_code)]
161    fn format_display(actions: &[PlannedAction]) -> String {
162        Self::format_display_actions(actions)
163    }
164
165    /// Format only the actions portion of the plan
166    fn format_display_actions(actions: &[PlannedAction]) -> String {
167        if actions.is_empty() {
168            return "No actions in plan".to_string();
169        }
170
171        let mut output = String::new();
172        output.push_str("Plan: Ready to execute\n\n");
173
174        let mut file_actions = Vec::new();
175        let mut command_actions = Vec::new();
176        let mut git_actions = Vec::new();
177
178        for action in actions {
179            match &action.action {
180                AgentAction::ReadFile { .. }
181                | AgentAction::WriteFile { .. }
182                | AgentAction::DeleteFile { .. }
183                | AgentAction::CreateDirectory { .. }
184                | AgentAction::ParallelRead { .. } => file_actions.push(action),
185                AgentAction::ExecuteCommand { .. } => command_actions.push(action),
186                AgentAction::GitDiff { .. }
187                | AgentAction::GitCommit { .. }
188                | AgentAction::GitStatus
189                | AgentAction::ParallelGitDiff { .. } => git_actions.push(action),
190                AgentAction::WebSearch { .. }
191                | AgentAction::ParallelWebSearch { .. } => {
192                    // Web search actions are executed inline
193                },
194            }
195        }
196
197        if !file_actions.is_empty() {
198            output.push_str("File Operations:\n");
199            for (i, action) in file_actions.iter().enumerate() {
200                output.push_str(&format!(
201                    "  {}. {} {}\n",
202                    i + 1,
203                    action.status.indicator(),
204                    action.description()
205                ));
206            }
207            output.push('\n');
208        }
209
210        if !command_actions.is_empty() {
211            output.push_str("Commands:\n");
212            for (i, action) in command_actions.iter().enumerate() {
213                output.push_str(&format!(
214                    "  {}. {} {}\n",
215                    i + 1,
216                    action.status.indicator(),
217                    action.description()
218                ));
219            }
220            output.push('\n');
221        }
222
223        if !git_actions.is_empty() {
224            output.push_str("Git Operations:\n");
225            for (i, action) in git_actions.iter().enumerate() {
226                output.push_str(&format!(
227                    "  {}. {} {}\n",
228                    i + 1,
229                    action.status.indicator(),
230                    action.description()
231                ));
232            }
233            output.push('\n');
234        }
235
236        output.push_str("Approve with Ctrl+Y, Cancel with Ctrl+N");
237        output
238    }
239
240    /// Update an action's status and regenerate display text
241    pub fn update_action_status(
242        &mut self,
243        index: usize,
244        status: ActionStatus,
245        result: Option<ActionResult>,
246        error: Option<String>,
247    ) {
248        if let Some(action) = self.actions.get_mut(index) {
249            action.status = status;
250            action.result = result;
251            action.error = error;
252        }
253        self.regenerate_display();
254    }
255
256    /// Regenerate the display text with current action statuses
257    fn regenerate_display(&mut self) {
258        let mut output = String::new();
259
260        let completed = self
261            .actions
262            .iter()
263            .filter(|a| a.status == ActionStatus::Completed)
264            .count();
265        let failed = self
266            .actions
267            .iter()
268            .filter(|a| a.status == ActionStatus::Failed)
269            .count();
270        let total = self.actions.len();
271
272        if completed == total {
273            output.push_str(&format!("Plan: Completed ({}/{})\n\n", completed, total));
274        } else if failed > 0 {
275            output.push_str(&format!(
276                "Plan: In Progress ({}/{}, {} failed)\n\n",
277                completed, total, failed
278            ));
279        } else {
280            output.push_str(&format!("Plan: In Progress ({}/{})\n\n", completed, total));
281        }
282
283        let mut file_actions = Vec::new();
284        let mut command_actions = Vec::new();
285        let mut git_actions = Vec::new();
286
287        for action in &self.actions {
288            match &action.action {
289                AgentAction::ReadFile { .. }
290                | AgentAction::WriteFile { .. }
291                | AgentAction::DeleteFile { .. }
292                | AgentAction::CreateDirectory { .. }
293                | AgentAction::ParallelRead { .. } => file_actions.push(action),
294                AgentAction::ExecuteCommand { .. } => command_actions.push(action),
295                AgentAction::GitDiff { .. }
296                | AgentAction::GitCommit { .. }
297                | AgentAction::GitStatus
298                | AgentAction::ParallelGitDiff { .. } => git_actions.push(action),
299                AgentAction::WebSearch { .. }
300                | AgentAction::ParallelWebSearch { .. } => {
301                    // Web search actions are executed inline, not categorized
302                },
303            }
304        }
305
306        if !file_actions.is_empty() {
307            output.push_str("File Operations:\n");
308            for action in file_actions {
309                output.push_str(&format!(
310                    "  {} {}\n",
311                    action.status.indicator(),
312                    action.description()
313                ));
314                if let Some(ref err) = action.error {
315                    output.push_str(&format!("    Error: {}\n", err));
316                }
317            }
318            output.push('\n');
319        }
320
321        if !command_actions.is_empty() {
322            output.push_str("Commands:\n");
323            for action in command_actions {
324                output.push_str(&format!(
325                    "  {} {}\n",
326                    action.status.indicator(),
327                    action.description()
328                ));
329                if let Some(ref err) = action.error {
330                    output.push_str(&format!("    Error: {}\n", err));
331                }
332            }
333            output.push('\n');
334        }
335
336        if !git_actions.is_empty() {
337            output.push_str("Git Operations:\n");
338            for action in git_actions {
339                output.push_str(&format!(
340                    "  {} {}\n",
341                    action.status.indicator(),
342                    action.description()
343                ));
344                if let Some(ref err) = action.error {
345                    output.push_str(&format!("    Error: {}\n", err));
346                }
347            }
348            output.push('\n');
349        }
350
351        if completed + failed == total {
352            output.push_str("Plan: Complete");
353        } else {
354            output.push_str("Executing plan... Alt+Esc to abort");
355        }
356
357        self.display_text = output;
358    }
359
360    /// Get next pending action
361    pub fn next_pending_action(&self) -> Option<(usize, &PlannedAction)> {
362        self.actions
363            .iter()
364            .enumerate()
365            .find(|(_, a)| a.status == ActionStatus::Pending)
366    }
367
368    /// Get completion statistics
369    pub fn stats(&self) -> PlanStats {
370        PlanStats {
371            total: self.actions.len(),
372            completed: self
373                .actions
374                .iter()
375                .filter(|a| a.status == ActionStatus::Completed)
376                .count(),
377            failed: self
378                .actions
379                .iter()
380                .filter(|a| a.status == ActionStatus::Failed)
381                .count(),
382            skipped: self
383                .actions
384                .iter()
385                .filter(|a| a.status == ActionStatus::Skipped)
386                .count(),
387            executing: self
388                .actions
389                .iter()
390                .filter(|a| a.status == ActionStatus::Executing)
391                .count(),
392        }
393    }
394}
395
396/// Statistics about plan execution
397#[derive(Debug, Clone, Copy)]
398pub struct PlanStats {
399    pub total: usize,
400    pub completed: usize,
401    pub failed: usize,
402    pub skipped: usize,
403    pub executing: usize,
404}
405
406impl PlanStats {
407    /// Get completion percentage
408    pub fn completion_percent(&self) -> u8 {
409        if self.total == 0 {
410            100
411        } else {
412            ((self.completed + self.failed + self.skipped) as f64 / self.total as f64 * 100.0) as u8
413        }
414    }
415
416    /// Check if plan is complete
417    pub fn is_complete(&self) -> bool {
418        self.completed + self.failed + self.skipped == self.total
419    }
420
421    /// Check if plan has failures
422    pub fn has_failures(&self) -> bool {
423        self.failed > 0
424    }
425
426    /// Get status message
427    pub fn status_message(&self) -> String {
428        if self.is_complete() {
429            if self.has_failures() {
430                format!(
431                    "Plan completed: {}/{} successful, {} failed",
432                    self.completed, self.total, self.failed
433                )
434            } else {
435                format!("Plan completed: all {} actions successful", self.total)
436            }
437        } else {
438            format!(
439                "Plan: {} executing, {}/{} completed",
440                self.executing, self.completed, self.total
441            )
442        }
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn test_action_status_indicators() {
452        assert_eq!(ActionStatus::Pending.indicator(), "•");
453        assert_eq!(ActionStatus::Executing.indicator(), "...");
454        assert_eq!(ActionStatus::Completed.indicator(), "✓");
455        assert_eq!(ActionStatus::Failed.indicator(), "✗");
456        assert_eq!(ActionStatus::Skipped.indicator(), "-");
457    }
458
459    #[test]
460    fn test_planned_action_new() {
461        let action = AgentAction::ReadFile {
462            path: "test.txt".to_string(),
463        };
464        let planned = PlannedAction::new(action);
465        assert_eq!(planned.status, ActionStatus::Pending);
466        assert!(planned.result.is_none());
467        assert!(planned.error.is_none());
468    }
469
470    #[test]
471    fn test_plan_stats() {
472        let mut plan = Plan::new(vec![
473            AgentAction::ReadFile {
474                path: "a.txt".to_string(),
475            },
476            AgentAction::WriteFile {
477                path: "b.txt".to_string(),
478                content: "content".to_string(),
479            },
480        ]);
481
482        let mut stats = plan.stats();
483        assert_eq!(stats.total, 2);
484        assert_eq!(stats.completed, 0);
485        assert!(!stats.is_complete());
486
487        plan.update_action_status(0, ActionStatus::Completed, None, None);
488        stats = plan.stats();
489        assert_eq!(stats.completed, 1);
490        assert!(!stats.is_complete());
491
492        plan.update_action_status(1, ActionStatus::Completed, None, None);
493        stats = plan.stats();
494        assert_eq!(stats.completed, 2);
495        assert!(stats.is_complete());
496    }
497}