Skip to main content

a3s_code_core/skills/
preprocessor.rs

1//! Shell command preprocessor for skill prompts
2//!
3//! Supports two syntaxes in skill markdown content:
4//! - Code blocks: ```! command ```
5//! - Inline: !`command`
6//!
7//! ## Usage
8//!
9//! ```rust
10//! use a3s_code_core::skills::ShellPreprocessor;
11//!
12//! let preprocessor = ShellPreprocessor::new();
13//! let result = preprocessor.process("Run !`echo hello`", &context).await?;
14//! ```
15
16use crate::error::Result;
17use crate::tools::{ToolExecutor, ToolResult};
18use async_trait::async_trait;
19use std::path::Path;
20use std::sync::Arc;
21
22/// Shell command output extractor
23#[async_trait]
24pub trait ShellPreprocessor: Send + Sync {
25    /// Process skill content, executing any embedded shell commands
26    async fn process(
27        &self,
28        content: &str,
29        workspace: &Path,
30        executor: &Arc<ToolExecutor>,
31    ) -> Result<String>;
32}
33
34/// Default shell preprocessor implementation
35pub struct DefaultShellPreprocessor {
36    /// Maximum shell output length to substitute (bytes)
37    max_output_length: usize,
38}
39
40impl DefaultShellPreprocessor {
41    pub fn new() -> Self {
42        Self {
43            max_output_length: 10 * 1024, // 10KB
44        }
45    }
46
47    /// Process inline shell commands: !`command`
48    fn process_inline(&self, content: &str) -> Vec<(String, String)> {
49        let mut results = Vec::new();
50        // Pattern: !`command` - must be at start of line or after whitespace
51        // Cannot use lookbehind, so match the preceding char too and strip it
52        let re = regex::Regex::new(r"(?:^|\s)(!`([^`]+)`)").unwrap();
53
54        for cap in re.captures_iter(content) {
55            let full_match = cap.get(1).unwrap().as_str();
56            let command = cap.get(2).unwrap().as_str().trim();
57            results.push((full_match.to_string(), command.to_string()));
58        }
59
60        results
61    }
62
63    /// Process code block shell commands: ```! command ```
64    fn process_blocks(&self, content: &str) -> Vec<(String, String)> {
65        let mut results = Vec::new();
66        // Pattern: ```! command ```
67        let re = regex::Regex::new(r"```!\s*\n?([\s\S]*?)\n?```").unwrap();
68
69        for cap in re.captures_iter(content) {
70            let full_match = cap.get(0).unwrap().as_str();
71            let command = cap.get(1).unwrap().as_str().trim();
72            if !command.is_empty() {
73                results.push((full_match.to_string(), command.to_string()));
74            }
75        }
76
77        results
78    }
79}
80
81impl Default for DefaultShellPreprocessor {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87#[async_trait]
88impl ShellPreprocessor for DefaultShellPreprocessor {
89    async fn process(
90        &self,
91        content: &str,
92        workspace: &Path,
93        executor: &Arc<ToolExecutor>,
94    ) -> Result<String> {
95        let mut result = content.to_string();
96
97        // Process code blocks first: ```! command ```
98        for (pattern, command) in self.process_blocks(content) {
99            let output = self.execute_command(&command, workspace, executor).await?;
100            result = result.replace(&pattern, &output);
101        }
102
103        // Process inline commands: !`command`
104        // Gate on presence of !` to avoid expensive regex on large content
105        if result.contains("!`") {
106            for (pattern, command) in self.process_inline(&result) {
107                let output = self.execute_command(&command, workspace, executor).await?;
108                result = result.replace(&pattern, &output);
109            }
110        }
111
112        Ok(result)
113    }
114}
115
116impl DefaultShellPreprocessor {
117    async fn execute_command(
118        &self,
119        command: &str,
120        workspace: &Path,
121        executor: &Arc<ToolExecutor>,
122    ) -> Result<String> {
123        let args = serde_json::json!({ "command": command });
124        let result: ToolResult = executor
125            .execute_with_context(
126                "bash",
127                &args,
128                &crate::tools::ToolContext {
129                    workspace: workspace.to_path_buf(),
130                    session_id: None,
131                    event_tx: None,
132                    agent_event_tx: None,
133                    search_config: None,
134                    sandbox: None,
135                    command_env: None,
136                },
137            )
138            .await?;
139
140        if result.exit_code != 0 {
141            return Ok(format!("[Error: {}]", result.output));
142        }
143
144        let output = result.output.trim().to_string();
145        if output.len() > self.max_output_length {
146            Ok(format!(
147                "{}... [truncated {} bytes]",
148                &output[..self.max_output_length],
149                output.len() - self.max_output_length
150            ))
151        } else {
152            Ok(output)
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_process_inline_pattern() {
163        let preprocessor = DefaultShellPreprocessor::new();
164        let matches = preprocessor.process_inline("Run !`echo hello` now");
165        assert_eq!(matches.len(), 1);
166        assert_eq!(matches[0].0, "!`echo hello`");
167        assert_eq!(matches[0].1, "echo hello");
168    }
169
170    #[test]
171    fn test_process_block_pattern() {
172        let preprocessor = DefaultShellPreprocessor::new();
173        let content = r#"```!
174ls -la
175```"#;
176        let matches = preprocessor.process_blocks(content);
177        assert_eq!(matches.len(), 1);
178        assert_eq!(matches[0].1, "ls -la");
179    }
180
181    #[test]
182    fn test_process_block_pattern_with_newlines() {
183        let preprocessor = DefaultShellPreprocessor::new();
184        // Block pattern requires ```! (not just ``` followed by !)
185        let content = r#"```!
186ls -la
187```"#;
188        let matches = preprocessor.process_blocks(content);
189        assert_eq!(matches.len(), 1);
190        assert_eq!(matches[0].1, "ls -la");
191    }
192
193    #[test]
194    fn test_no_false_positives() {
195        let preprocessor = DefaultShellPreprocessor::new();
196        // $! (shell variable ending in !) should not match since it's not !`
197        let matches = preprocessor.process_inline("echo $! and !`echo hi`");
198        assert_eq!(matches.len(), 1); // only the actual !`...` should match
199        assert_eq!(matches[0].1, "echo hi");
200    }
201
202    #[test]
203    fn test_process_inline_at_start_of_line() {
204        let preprocessor = DefaultShellPreprocessor::new();
205        let matches = preprocessor.process_inline("!`pwd`");
206        assert_eq!(matches.len(), 1);
207        assert_eq!(matches[0].1, "pwd");
208    }
209
210    #[test]
211    fn test_process_inline_multiple() {
212        let preprocessor = DefaultShellPreprocessor::new();
213        let matches = preprocessor.process_inline("First !`cmd1` then !`cmd2`");
214        assert_eq!(matches.len(), 2);
215        assert_eq!(matches[0].1, "cmd1");
216        assert_eq!(matches[1].1, "cmd2");
217    }
218
219    #[test]
220    fn test_process_inline_no_matches() {
221        let preprocessor = DefaultShellPreprocessor::new();
222        let matches = preprocessor.process_inline("No shell commands here");
223        assert!(matches.is_empty());
224    }
225
226    #[test]
227    fn test_process_inline_backtick_in_string() {
228        let preprocessor = DefaultShellPreprocessor::new();
229        // Should not match backticks without !
230        let matches = preprocessor.process_inline("`echo hello`");
231        assert!(matches.is_empty());
232    }
233
234    #[test]
235    fn test_process_blocks_multiple() {
236        let preprocessor = DefaultShellPreprocessor::new();
237        let content = r#"```!
238ls -la
239```
240Some text
241```!
242pwd
243```"#;
244        let matches = preprocessor.process_blocks(content);
245        assert_eq!(matches.len(), 2);
246        assert_eq!(matches[0].1, "ls -la");
247        assert_eq!(matches[1].1, "pwd");
248    }
249
250    #[test]
251    fn test_process_blocks_no_matches() {
252        let preprocessor = DefaultShellPreprocessor::new();
253        let content = "```\nsome code\n```";
254        let matches = preprocessor.process_blocks(content);
255        assert!(matches.is_empty());
256    }
257
258    #[test]
259    fn test_process_blocks_empty_command() {
260        let preprocessor = DefaultShellPreprocessor::new();
261        let content = "```!\n```";
262        let matches = preprocessor.process_blocks(content);
263        // Empty commands are filtered out
264        assert!(matches.is_empty());
265    }
266
267    #[test]
268    fn test_default_shell_preprocessor_new() {
269        let preprocessor = DefaultShellPreprocessor::new();
270        assert_eq!(preprocessor.max_output_length, 10 * 1024);
271    }
272}