Skip to main content

ai_agent/tools/powershell/
command_semantics.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/tools/PowerShellTool/commandSemantics.ts
2//! Command semantics configuration for interpreting exit codes in PowerShell.
3//!
4//! PowerShell-native cmdlets do NOT need exit-code semantics:
5//!   - Select-String (grep equivalent) exits 0 on no-match (returns $null)
6//!   - Compare-Object (diff equivalent) exits 0 regardless
7//!   - Test-Path exits 0 regardless (returns bool via pipeline)
8//! Native cmdlets signal failure via terminating errors ($?), not exit codes.
9//!
10//! However, EXTERNAL executables invoked from PowerShell DO set $LASTEXITCODE,
11//! and many use non-zero codes to convey information rather than failure:
12//!   - grep.exe / rg.exe (Git for Windows, scoop, etc.): 1 = no match
13//!   - findstr.exe (Windows native): 1 = no match
14//!   - robocopy.exe (Windows native): 0-7 = success, 8+ = error (notorious!)
15
16use once_cell::sync::Lazy;
17use std::collections::HashMap;
18
19/// Result of command interpretation
20#[derive(Debug, Clone)]
21pub struct CommandResult {
22    pub is_error: bool,
23    pub message: Option<String>,
24}
25
26/// Command type identifier for lookup
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum CommandType {
29    Default,
30    Grep,
31    Robocopy,
32}
33
34/// Get command type from string
35fn get_command_type(base_command: &str) -> CommandType {
36    match base_command {
37        "grep" | "rg" | "findstr" => CommandType::Grep,
38        "robocopy" => CommandType::Robocopy,
39        _ => CommandType::Default,
40    }
41}
42
43/// Interpret command result based on semantic rules
44pub fn interpret_command_result(
45    command: &str,
46    exit_code: i32,
47    stdout: &str,
48    stderr: &str,
49) -> CommandResult {
50    let base_command = extract_base_command(command);
51    let cmd_type = get_command_type(&base_command);
52
53    match cmd_type {
54        CommandType::Grep => {
55            if exit_code >= 2 {
56                CommandResult {
57                    is_error: true,
58                    message: Some(format!("Command failed with exit code {}", exit_code)),
59                }
60            } else if exit_code == 1 {
61                CommandResult {
62                    is_error: false,
63                    message: Some("No matches found".to_string()),
64                }
65            } else {
66                CommandResult {
67                    is_error: false,
68                    message: None,
69                }
70            }
71        }
72        CommandType::Robocopy => {
73            if exit_code >= 8 {
74                CommandResult {
75                    is_error: true,
76                    message: Some(format!("Robocopy failed with exit code {}", exit_code)),
77                }
78            } else if exit_code == 0 {
79                CommandResult {
80                    is_error: false,
81                    message: Some("No files copied (already in sync)".to_string()),
82                }
83            } else {
84                // 1-7 are success codes
85                let message = if exit_code & 1 != 0 {
86                    "Files copied successfully".to_string()
87                } else {
88                    "Robocopy completed (no errors)".to_string()
89                };
90                CommandResult {
91                    is_error: false,
92                    message: Some(message),
93                }
94            }
95        }
96        CommandType::Default => {
97            if exit_code == 0 {
98                CommandResult {
99                    is_error: false,
100                    message: None,
101                }
102            } else {
103                CommandResult {
104                    is_error: true,
105                    message: Some(format!("Command failed with exit code {}", exit_code)),
106                }
107            }
108        }
109    }
110}
111
112/// Extract the command name from a single pipeline segment.
113/// Strips leading `&` / `.` call operators and `.exe` suffix, lowercases.
114fn extract_base_command(command: &str) -> String {
115    let segments: Vec<&str> = command
116        .split(|c| c == ';' || c == '|')
117        .filter(|s| !s.trim().is_empty())
118        .collect();
119    let last = segments.last().unwrap_or(&command);
120
121    // Strip PowerShell call operators: & "cmd", . "cmd"
122    let stripped = last.trim().trim_start_matches(&['&', '.'][..]).trim();
123    let first_token = stripped.split_whitespace().next().unwrap_or("");
124    // Strip surrounding quotes if command was invoked as & "grep.exe"
125    let unquoted = first_token.trim_matches('"').trim_matches('\'');
126    // Strip path: C:\bin\grep.exe → grep.exe, .\rg.exe → rg.exe
127    let basename = unquoted
128        .rsplit(|c| c == '\\' || c == '/')
129        .next()
130        .unwrap_or(unquoted);
131    // Strip .exe suffix (Windows is case-insensitive)
132    basename.to_lowercase().trim_end_matches(".exe").to_string()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_grep_success() {
141        let result = interpret_command_result("grep pattern file.txt", 0, "match\n", "");
142        assert!(!result.is_error);
143    }
144
145    #[test]
146    fn test_grep_no_match() {
147        let result = interpret_command_result("grep pattern file.txt", 1, "", "");
148        assert!(!result.is_error);
149        assert_eq!(result.message, Some("No matches found".to_string()));
150    }
151
152    #[test]
153    fn test_robocopy_success() {
154        let result = interpret_command_result("robocopy src dst", 1, "", "");
155        assert!(!result.is_error);
156        assert_eq!(
157            result.message,
158            Some("Files copied successfully".to_string())
159        );
160    }
161
162    #[test]
163    fn test_robocopy_failure() {
164        let result = interpret_command_result("robocopy src dst", 16, "", "");
165        assert!(result.is_error);
166    }
167}