Skip to main content

mermaid_cli/agents/
plan.rs

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