batuta/agent/
repl_directives.rs1use std::io;
25use std::path::Path;
26
27pub 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
43pub 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
66pub 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 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; 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
100pub 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 #[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 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 assert_eq!(parse_bang_command("!"), None);
172 assert_eq!(parse_bang_command("! "), None);
173 }
174
175 #[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 #[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 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 let tokens = find_at_path_tokens("hello @ world");
233 assert!(tokens.is_empty());
234 }
235
236 #[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 assert!(out.contains("@/no/such/path"));
258 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}