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