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