Skip to main content

batuta/agent/
repl_directives.rs

1//! REPL inline directives (Claude-Code parity Phase 2):
2//! `!<cmd>` shell prefix and `@<path>` file expansion
3//! (PMAT-CODE-REPL-PHASE2-001).
4//!
5//! Both directives mirror Claude Code's interactive shortcuts. They run
6//! BEFORE slash-command parsing in `read_input`:
7//!
8//! * `!<cmd>` — anything after a leading `!` is treated as a shell
9//!   command. Output is printed inline; the agent loop is **not**
10//!   invoked. Useful for quick `!ls`, `!git status`, etc.
11//!
12//! * `@<path>` — a token of the form `@./README.md` (or any
13//!   non-whitespace path after `@`) is expanded INLINE in the prompt
14//!   text: the token is replaced with the file's contents wrapped in
15//!   `<file path="...">...</file>` so the model sees both the path
16//!   and the contents. Multiple `@` tokens per prompt expand left-to-
17//!   right. Missing files are reported on stderr and the token is left
18//!   verbatim (Poka-Yoke: no silent partial expansion).
19//!
20//! Pure-function design — terminal I/O lives in `repl.rs`, this module
21//! only does string parsing and filesystem reads. That keeps the parser
22//! headlessly testable (no TTY needed).
23
24use std::io;
25use std::path::Path;
26
27/// If `input` starts with `!`, return the trimmed shell command after
28/// the `!` prefix. Returns `None` for non-bang inputs (the regular
29/// agent loop should handle them).
30///
31/// `!` alone (no command) returns `None` — there's nothing to run.
32pub fn parse_bang_command(input: &str) -> Option<&str> {
33    let trimmed = input.trim_start();
34    let after_bang = trimmed.strip_prefix('!')?;
35    let cmd = after_bang.trim();
36    if cmd.is_empty() {
37        None
38    } else {
39        Some(cmd)
40    }
41}
42
43/// Run `cmd` via the system shell (`sh -c "<cmd>"`) and return
44/// (exit_code, captured_output). `output` interleaves stdout+stderr
45/// the way the user would see them in a terminal.
46///
47/// Failure to spawn (e.g. `sh` missing) returns `Err`. Non-zero shell
48/// exit codes are NOT errors at this layer — they're returned in the
49/// tuple so the REPL can echo them.
50pub fn execute_bang_command(cmd: &str) -> io::Result<(i32, String)> {
51    let output = std::process::Command::new("sh").arg("-c").arg(cmd).output()?;
52    let mut buf = String::new();
53    if !output.stdout.is_empty() {
54        buf.push_str(&String::from_utf8_lossy(&output.stdout));
55    }
56    if !output.stderr.is_empty() {
57        if !buf.is_empty() && !buf.ends_with('\n') {
58            buf.push('\n');
59        }
60        buf.push_str(&String::from_utf8_lossy(&output.stderr));
61    }
62    let code = output.status.code().unwrap_or(-1);
63    Ok((code, buf))
64}
65
66/// Find every `@<path>` token in `input`. A path token is `@` followed
67/// by one or more non-whitespace characters; the `@` must be at the
68/// start of input or preceded by whitespace (so `email@host` is NOT
69/// matched).
70///
71/// Returns each (start_byte_offset, end_byte_offset, path_string)
72/// triple in order so callers can splice without re-scanning.
73pub fn find_at_path_tokens(input: &str) -> Vec<(usize, usize, String)> {
74    let bytes = input.as_bytes();
75    let mut out = Vec::new();
76    let mut i = 0;
77    while i < bytes.len() {
78        if bytes[i] == b'@' {
79            // boundary: either start-of-input or previous byte is whitespace
80            let at_boundary = i == 0 || bytes[i - 1].is_ascii_whitespace();
81            if at_boundary {
82                let start = i;
83                let mut end = i + 1; // skip '@'
84                while end < bytes.len() && !bytes[end].is_ascii_whitespace() {
85                    end += 1;
86                }
87                let path = &input[start + 1..end];
88                if !path.is_empty() {
89                    out.push((start, end, path.to_owned()));
90                }
91                i = end;
92                continue;
93            }
94        }
95        i += 1;
96    }
97    out
98}
99
100/// Replace every `@<path>` token in `input` with the file's contents
101/// wrapped in `<file path="...">...</file>`. Missing or unreadable
102/// files have their token left verbatim AND a warning is appended to
103/// `warnings`. This way a typo'd `@/nope` makes it through to the
104/// agent verbatim (the agent can complain) and the operator gets a
105/// stderr warning.
106///
107/// Returns the expanded prompt text.
108pub fn expand_at_paths(input: &str, warnings: &mut Vec<String>) -> String {
109    let tokens = find_at_path_tokens(input);
110    if tokens.is_empty() {
111        return input.to_owned();
112    }
113    let mut out = String::with_capacity(input.len());
114    let mut cursor = 0;
115    for (start, end, path) in &tokens {
116        out.push_str(&input[cursor..*start]);
117        let p = Path::new(path);
118        match std::fs::read_to_string(p) {
119            Ok(body) => {
120                out.push_str(&format!("<file path=\"{path}\">\n{body}\n</file>"));
121            }
122            Err(e) => {
123                warnings.push(format!("@{path}: {e}"));
124                out.push_str(&input[*start..*end]);
125            }
126        }
127        cursor = *end;
128    }
129    out.push_str(&input[cursor..]);
130    out
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use std::fs;
137
138    // ── parse_bang_command ─────────────────────────────────────────
139
140    #[test]
141    fn bang_simple_command() {
142        assert_eq!(parse_bang_command("!ls"), Some("ls"));
143    }
144
145    #[test]
146    fn bang_with_args() {
147        assert_eq!(parse_bang_command("!ls -la"), Some("ls -la"));
148    }
149
150    #[test]
151    fn bang_strips_inner_padding() {
152        assert_eq!(parse_bang_command("!   ls -la"), Some("ls -la"));
153    }
154
155    #[test]
156    fn bang_strips_leading_whitespace_before_bang() {
157        // Common typo — trailing newline from read_line is already stripped
158        // by the caller, but leading spaces before `!` are charitable.
159        assert_eq!(parse_bang_command("  !ls"), Some("ls"));
160    }
161
162    #[test]
163    fn no_bang_returns_none() {
164        assert_eq!(parse_bang_command("ls"), None);
165        assert_eq!(parse_bang_command("hello !ls"), None, "bang must lead the line");
166    }
167
168    #[test]
169    fn bare_bang_returns_none() {
170        // `!` alone is not a valid command — no shell to run.
171        assert_eq!(parse_bang_command("!"), None);
172        assert_eq!(parse_bang_command("!  "), None);
173    }
174
175    // ── execute_bang_command (LIVE shell) ──────────────────────────
176
177    #[test]
178    fn exec_bang_echoes_string() {
179        let (code, out) = execute_bang_command("echo hello-bang-test").expect("exec");
180        assert_eq!(code, 0);
181        assert!(out.contains("hello-bang-test"), "stdout missing: {out:?}");
182    }
183
184    #[test]
185    fn exec_bang_returns_nonzero_on_false() {
186        let (code, _out) = execute_bang_command("false").expect("exec");
187        assert_ne!(code, 0, "false must return nonzero");
188    }
189
190    #[test]
191    fn exec_bang_captures_stderr() {
192        let (_code, out) =
193            execute_bang_command("printf 'stdout-line\\n'; printf 'err-line\\n' 1>&2; true")
194                .expect("exec");
195        assert!(out.contains("stdout-line"));
196        assert!(out.contains("err-line"), "stderr missing in {out:?}");
197    }
198
199    // ── find_at_path_tokens ────────────────────────────────────────
200
201    #[test]
202    fn at_at_start_of_line() {
203        let tokens = find_at_path_tokens("@README.md");
204        assert_eq!(tokens, vec![(0, 10, "README.md".to_string())]);
205    }
206
207    #[test]
208    fn at_after_whitespace() {
209        let tokens = find_at_path_tokens("look at @./foo.txt please");
210        assert_eq!(tokens.len(), 1);
211        assert_eq!(tokens[0].2, "./foo.txt");
212    }
213
214    #[test]
215    fn email_not_matched() {
216        // `noah@paiml.com` must NOT be parsed as `@paiml.com`.
217        let tokens = find_at_path_tokens("ping noah@paiml.com");
218        assert!(tokens.is_empty(), "email must not be at-path: {tokens:?}");
219    }
220
221    #[test]
222    fn multiple_at_tokens() {
223        let tokens = find_at_path_tokens("compare @./a.txt and @./b.txt");
224        assert_eq!(tokens.len(), 2);
225        assert_eq!(tokens[0].2, "./a.txt");
226        assert_eq!(tokens[1].2, "./b.txt");
227    }
228
229    #[test]
230    fn bare_at_yields_nothing() {
231        // `@` followed by whitespace is not a path token.
232        let tokens = find_at_path_tokens("hello @ world");
233        assert!(tokens.is_empty());
234    }
235
236    // ── expand_at_paths ────────────────────────────────────────────
237
238    #[test]
239    fn expand_single_existing_file() {
240        let dir = tempfile::tempdir().expect("tempdir");
241        let p = dir.path().join("note.md");
242        fs::write(&p, "Hello, world").expect("write");
243        let input = format!("read @{}", p.display());
244        let mut warns = Vec::new();
245        let out = expand_at_paths(&input, &mut warns);
246        assert!(out.contains("<file path="));
247        assert!(out.contains("Hello, world"));
248        assert!(out.contains("</file>"));
249        assert!(warns.is_empty());
250    }
251
252    #[test]
253    fn expand_missing_file_keeps_token_and_warns() {
254        let mut warns = Vec::new();
255        let out = expand_at_paths("look @/no/such/path here", &mut warns);
256        // Token kept verbatim
257        assert!(out.contains("@/no/such/path"));
258        // Warning issued
259        assert_eq!(warns.len(), 1);
260        assert!(warns[0].contains("/no/such/path"));
261    }
262
263    #[test]
264    fn expand_no_at_returns_input_unchanged() {
265        let mut warns = Vec::new();
266        let out = expand_at_paths("plain text no at-tokens", &mut warns);
267        assert_eq!(out, "plain text no at-tokens");
268        assert!(warns.is_empty());
269    }
270
271    #[test]
272    fn expand_two_files_in_one_prompt() {
273        let dir = tempfile::tempdir().expect("tempdir");
274        let a = dir.path().join("a.md");
275        let b = dir.path().join("b.md");
276        fs::write(&a, "AAA").expect("write");
277        fs::write(&b, "BBB").expect("write");
278        let input = format!("@{} and @{}", a.display(), b.display());
279        let mut warns = Vec::new();
280        let out = expand_at_paths(&input, &mut warns);
281        assert!(out.contains("AAA"));
282        assert!(out.contains("BBB"));
283        assert!(out.contains(" and "));
284        assert!(warns.is_empty());
285    }
286
287    #[test]
288    fn expand_email_unaffected() {
289        let mut warns = Vec::new();
290        let out = expand_at_paths("ping noah@paiml.com", &mut warns);
291        assert_eq!(out, "ping noah@paiml.com");
292        assert!(warns.is_empty());
293    }
294}