mermaid_cli/agents/
mode_aware_executor.rs

1use super::action_executor::execute_action;
2use super::types::{ActionResult, AgentAction};
3use crate::tui::OperationMode;
4use anyhow::Result;
5
6/// Mode-aware action executor that respects operation modes
7#[derive(Debug, Clone)]
8pub struct ModeAwareExecutor {
9    mode: OperationMode,
10    bypass_confirmed: bool,
11}
12
13impl ModeAwareExecutor {
14    /// Create a new mode-aware executor
15    pub fn new(mode: OperationMode) -> Self {
16        Self {
17            mode,
18            bypass_confirmed: false,
19        }
20    }
21
22    /// Update the operation mode
23    pub fn set_mode(&mut self, mode: OperationMode) {
24        self.mode = mode;
25        self.bypass_confirmed = false; // Reset confirmation when mode changes
26    }
27
28    /// Check if an action needs confirmation based on the current mode
29    pub fn needs_confirmation(&self, action: &AgentAction) -> bool {
30        // In plan mode, nothing needs confirmation as nothing executes
31        if self.mode.is_planning_only() {
32            return false;
33        }
34
35        match action {
36            // File operations
37            AgentAction::WriteFile { .. } | AgentAction::DeleteFile { .. } => {
38                !self.mode.auto_accept_files()
39            },
40
41            // Shell commands
42            AgentAction::ExecuteCommand { .. } => !self.mode.auto_accept_commands(),
43
44            // Git operations
45            AgentAction::GitCommit { .. } => !self.mode.auto_accept_git(),
46
47            // Read operations are generally safe
48            AgentAction::ReadFile { .. } | AgentAction::GitStatus | AgentAction::GitDiff { .. } => {
49                false
50            },
51
52            // Directory creation needs confirmation unless in bypass mode
53            AgentAction::CreateDirectory { .. } => !self.mode.auto_accept_files(),
54
55            // Web search is safe
56            AgentAction::WebSearch { .. } => false,
57
58            // Parallel operations are generally safe (read-only or informational)
59            AgentAction::ParallelRead { .. }
60                | AgentAction::ParallelWebSearch { .. }
61                | AgentAction::ParallelGitDiff { .. } => false,
62        }
63    }
64
65    /// Check if an action is considered destructive
66    pub fn is_destructive(&self, action: &AgentAction) -> bool {
67        match action {
68            AgentAction::DeleteFile { .. } => true,
69            AgentAction::ExecuteCommand { command, .. } => {
70                command.contains("rm")
71                    || command.contains("del")
72                    || command.contains("drop")
73                    || command.contains("truncate")
74            },
75            _ => false,
76        }
77    }
78
79    /// Execute an action with mode awareness
80    pub async fn execute(&mut self, action: AgentAction) -> Result<ActionResult> {
81        // Planning mode: just return what would happen
82        if self.mode.is_planning_only() {
83            return Ok(ActionResult::Success {
84                output: format!("[PLANNED]: {}", self.describe_action(&action)),
85            });
86        }
87
88        // Bypass mode with destructive operation: require double confirmation
89        if self.mode == OperationMode::BypassAll && self.is_destructive(&action) {
90            if !self.bypass_confirmed {
91                self.bypass_confirmed = true;
92                return Ok(ActionResult::Success {
93                    output: format!(
94                        "[WARNING] DESTRUCTIVE OPERATION in Bypass Mode: {}\n\
95                         Press Enter to confirm or Esc to cancel.",
96                        self.describe_action(&action)
97                    ),
98                });
99            }
100        }
101
102        // Execute the action
103        let result = execute_action(&action).await?;
104
105        // Reset bypass confirmation after successful execution
106        if self.bypass_confirmed {
107            self.bypass_confirmed = false;
108        }
109
110        // Add mode indicator to result if not in Normal mode
111        if self.mode != OperationMode::Normal {
112            match result {
113                ActionResult::Success { output } => Ok(ActionResult::Success {
114                    output: format!("[{}] {}", self.mode.short_name(), output),
115                }),
116                other => Ok(other),
117            }
118        } else {
119            Ok(result)
120        }
121    }
122
123    /// Get a human-readable description of an action
124    pub fn describe_action(&self, action: &AgentAction) -> String {
125        match action {
126            AgentAction::ReadFile { path } => {
127                format!("Read file: {}", path)
128            },
129            AgentAction::WriteFile { path, content } => {
130                format!("Write file: {} ({} bytes)", path, content.len())
131            },
132            AgentAction::DeleteFile { path } => {
133                format!("Delete file: {}", path)
134            },
135            AgentAction::CreateDirectory { path } => {
136                format!("Create directory: {}", path)
137            },
138            AgentAction::ExecuteCommand {
139                command,
140                working_dir,
141            } => {
142                if let Some(dir) = working_dir {
143                    format!("Execute command in {}: {}", dir, command)
144                } else {
145                    format!("Execute command: {}", command)
146                }
147            },
148            AgentAction::GitDiff { path } => {
149                if let Some(p) = path {
150                    format!("Git diff for: {}", p)
151                } else {
152                    format!("Git diff (all files)")
153                }
154            },
155            AgentAction::GitStatus => "Git status".to_string(),
156            AgentAction::GitCommit { message, files } => {
157                if !files.is_empty() {
158                    format!("Git commit ({} files): {}", files.len(), message)
159                } else {
160                    format!("Git commit (all): {}", message)
161                }
162            },
163            AgentAction::WebSearch { query, result_count } => {
164                format!("Web search: '{}' ({} results)", query, result_count)
165            },
166            AgentAction::ParallelRead { paths } => {
167                format!("Read {} files in parallel", paths.len())
168            },
169            AgentAction::ParallelWebSearch { queries } => {
170                format!("Search web with {} queries in parallel", queries.len())
171            },
172            AgentAction::ParallelGitDiff { paths } => {
173                format!("Git diff for {} paths in parallel", paths.len())
174            },
175        }
176    }
177
178    /// Get current mode
179    pub fn mode(&self) -> OperationMode {
180        self.mode
181    }
182
183    /// Check if bypass is confirmed
184    pub fn is_bypass_confirmed(&self) -> bool {
185        self.bypass_confirmed
186    }
187
188    /// Reset bypass confirmation
189    pub fn reset_bypass_confirmation(&mut self) {
190        self.bypass_confirmed = false;
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_needs_confirmation() {
200        let executor = ModeAwareExecutor::new(OperationMode::Normal);
201
202        // Normal mode needs confirmation for writes
203        assert!(executor.needs_confirmation(&AgentAction::WriteFile {
204            path: "test.txt".to_string(),
205            content: "test".to_string(),
206        }));
207
208        // Normal mode doesn't need confirmation for reads
209        assert!(!executor.needs_confirmation(&AgentAction::ReadFile {
210            path: "test.txt".to_string(),
211        }));
212    }
213
214    #[test]
215    fn test_accept_edits_mode() {
216        let executor = ModeAwareExecutor::new(OperationMode::AcceptEdits);
217
218        // AcceptEdits auto-accepts file operations
219        assert!(!executor.needs_confirmation(&AgentAction::WriteFile {
220            path: "test.txt".to_string(),
221            content: "test".to_string(),
222        }));
223
224        // But still confirms commands
225        assert!(executor.needs_confirmation(&AgentAction::ExecuteCommand {
226            command: "ls".to_string(),
227            working_dir: None,
228        }));
229    }
230
231    #[test]
232    fn test_bypass_all_mode() {
233        let executor = ModeAwareExecutor::new(OperationMode::BypassAll);
234
235        // BypassAll auto-accepts everything
236        assert!(!executor.needs_confirmation(&AgentAction::WriteFile {
237            path: "test.txt".to_string(),
238            content: "test".to_string(),
239        }));
240
241        assert!(!executor.needs_confirmation(&AgentAction::ExecuteCommand {
242            command: "ls".to_string(),
243            working_dir: None,
244        }));
245
246        assert!(!executor.needs_confirmation(&AgentAction::GitCommit {
247            message: "test".to_string(),
248            files: vec![],
249        }));
250    }
251
252    #[test]
253    fn test_destructive_detection() {
254        let executor = ModeAwareExecutor::new(OperationMode::Normal);
255
256        // Delete file is destructive
257        assert!(executor.is_destructive(&AgentAction::DeleteFile {
258            path: "test.txt".to_string(),
259        }));
260
261        // rm command is destructive
262        assert!(executor.is_destructive(&AgentAction::ExecuteCommand {
263            command: "rm -rf /".to_string(),
264            working_dir: None,
265        }));
266
267        // Regular command is not destructive
268        assert!(!executor.is_destructive(&AgentAction::ExecuteCommand {
269            command: "ls -la".to_string(),
270            working_dir: None,
271        }));
272    }
273}