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