1use std::path::{Path, PathBuf};
23use std::sync::OnceLock;
24
25use regex::Regex;
26
27const MAX_FILE_BYTES: usize = 64 * 1024;
28const MAX_FOLDER_ENTRIES: usize = 200;
29const MAX_DIFF_BYTES: usize = 32 * 1024;
30const MAX_URL_BYTES: usize = 64 * 1024;
31const URL_FETCH_TIMEOUT_SECS: u64 = 15;
32
33fn reference_re() -> &'static Regex {
34 static RE: OnceLock<Regex> = OnceLock::new();
35 RE.get_or_init(|| {
36 Regex::new(r"@(?:(?P<simple>diff|staged)\b|(?P<kind>file|folder|url):(?P<value>\S+))")
42 .expect("static regex")
43 })
44}
45
46const SENSITIVE_HOME_DIRS: &[&str] = &[".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure"];
47
48const SENSITIVE_HOME_FILES: &[&str] = &[
49 ".bashrc",
50 ".zshrc",
51 ".profile",
52 ".bash_profile",
53 ".zprofile",
54 ".netrc",
55 ".pgpass",
56 ".npmrc",
57 ".pypirc",
58];
59
60#[derive(Debug, Clone)]
62pub struct ExpansionResult {
63 pub stripped_message: String,
65 pub final_message: String,
67 pub warnings: Vec<String>,
69 pub expanded: bool,
71}
72
73pub async fn expand_references(message: &str, cwd: &Path) -> ExpansionResult {
78 let refs: Vec<ReferenceMatch> = parse_references(message);
79 if refs.is_empty() {
80 return ExpansionResult {
81 stripped_message: message.to_string(),
82 final_message: message.to_string(),
83 warnings: Vec::new(),
84 expanded: false,
85 };
86 }
87
88 let mut blocks: Vec<String> = Vec::new();
89 let mut warnings: Vec<String> = Vec::new();
90
91 for r in &refs {
92 match expand_one(r, cwd).await {
93 Ok(block) => blocks.push(block),
94 Err(w) => warnings.push(w),
95 }
96 }
97
98 let stripped = strip_reference_tokens(message, &refs);
99 let mut final_msg = stripped.clone();
100 if !warnings.is_empty() {
101 final_msg.push_str("\n\n--- Context Warnings ---\n");
102 for w in &warnings {
103 final_msg.push_str("- ");
104 final_msg.push_str(w);
105 final_msg.push('\n');
106 }
107 }
108 if !blocks.is_empty() {
109 final_msg.push_str("\n\n--- Attached Context ---\n\n");
110 final_msg.push_str(&blocks.join("\n\n"));
111 }
112
113 ExpansionResult {
114 stripped_message: stripped,
115 final_message: final_msg.trim().to_string(),
116 warnings,
117 expanded: !blocks.is_empty(),
118 }
119}
120
121#[derive(Debug, Clone)]
122struct ReferenceMatch {
123 #[allow(dead_code)]
124 raw: String,
125 kind: ReferenceKind,
126 target: String,
127 line_start: Option<usize>,
128 line_end: Option<usize>,
129 token_start: usize,
131 token_end: usize,
132}
133
134#[derive(Debug, Clone, Copy)]
135enum ReferenceKind {
136 File,
137 Folder,
138 Url,
139 Diff,
140 Staged,
141}
142
143fn parse_references(message: &str) -> Vec<ReferenceMatch> {
144 let mut out = Vec::new();
145 for cap in reference_re().captures_iter(message) {
146 let m = cap.get(0).unwrap();
147 if m.start() > 0 {
150 let prev = message[..m.start()].chars().next_back();
151 if let Some(c) = prev {
152 if !c.is_whitespace() {
153 continue;
154 }
155 }
156 }
157 if let Some(simple) = cap.name("simple") {
158 let kind = if simple.as_str() == "staged" {
159 ReferenceKind::Staged
160 } else {
161 ReferenceKind::Diff
162 };
163 out.push(ReferenceMatch {
164 raw: m.as_str().to_string(),
165 kind,
166 target: String::new(),
167 line_start: None,
168 line_end: None,
169 token_start: m.start(),
170 token_end: m.end(),
171 });
172 continue;
173 }
174
175 let kind = match cap.name("kind").map(|m| m.as_str()) {
176 Some("file") => ReferenceKind::File,
177 Some("folder") => ReferenceKind::Folder,
178 Some("url") => ReferenceKind::Url,
179 _ => continue,
180 };
181 let value_raw = cap.name("value").map(|m| m.as_str()).unwrap_or("");
182 let value = strip_trailing_punctuation(value_raw);
183 let (target, line_start, line_end) = match kind {
184 ReferenceKind::File => parse_file_value(value),
185 _ => (value.to_string(), None, None),
186 };
187 out.push(ReferenceMatch {
188 raw: m.as_str().to_string(),
189 kind,
190 target,
191 line_start,
192 line_end,
193 token_start: m.start(),
194 token_end: m.end(),
195 });
196 }
197 out
198}
199
200fn strip_trailing_punctuation(value: &str) -> &str {
201 value.trim_end_matches([',', '.', ';', '!', '?'])
202}
203
204fn parse_file_value(value: &str) -> (String, Option<usize>, Option<usize>) {
205 if let Some((rest, suffix)) = value.rsplit_once(':') {
209 if let Some((a, b)) = suffix.split_once('-') {
210 if let (Ok(a), Ok(b)) = (a.parse(), b.parse()) {
211 return (rest.to_string(), Some(a), Some(b));
212 }
213 } else if let Ok(n) = suffix.parse() {
214 return (rest.to_string(), Some(n), Some(n));
215 }
216 }
217 (value.to_string(), None, None)
218}
219
220fn strip_reference_tokens(message: &str, refs: &[ReferenceMatch]) -> String {
221 if refs.is_empty() {
222 return message.to_string();
223 }
224 let mut out = String::with_capacity(message.len());
225 let mut last = 0;
226 for r in refs {
227 if r.token_start >= last {
228 out.push_str(&message[last..r.token_start]);
229 last = r.token_end;
230 }
231 }
232 out.push_str(&message[last..]);
233 let collapsed = out.trim().to_string();
235 collapsed
236}
237
238async fn expand_one(r: &ReferenceMatch, cwd: &Path) -> Result<String, String> {
239 match r.kind {
240 ReferenceKind::File => expand_file(&r.target, r.line_start, r.line_end, cwd).await,
241 ReferenceKind::Folder => expand_folder(&r.target, cwd).await,
242 ReferenceKind::Url => expand_url(&r.target).await,
243 ReferenceKind::Diff => expand_git_diff(false, cwd).await,
244 ReferenceKind::Staged => expand_git_diff(true, cwd).await,
245 }
246}
247
248fn resolve_path_safe(target: &str, cwd: &Path) -> Result<PathBuf, String> {
249 let raw = PathBuf::from(target);
250 let path = if raw.is_absolute() {
251 raw
252 } else {
253 cwd.join(raw)
254 };
255 let canonical = path
256 .canonicalize()
257 .map_err(|e| format!("@{} not found: {e}", target))?;
258 let component_names: Vec<String> = canonical
263 .components()
264 .filter_map(|c| match c {
265 std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
266 _ => None,
267 })
268 .collect();
269 for d in SENSITIVE_HOME_DIRS {
270 if component_names.iter().any(|name| name == d) {
271 return Err(format!("@{} blocked: refuses to read inside `{d}`", target));
272 }
273 }
274 if let Some(home) = dirs_home() {
275 for d in SENSITIVE_HOME_DIRS {
276 let blocked = home.join(d);
277 if canonical.starts_with(&blocked) {
278 return Err(format!(
279 "@{} blocked: refuses to read inside {}",
280 target,
281 blocked.display()
282 ));
283 }
284 }
285 for f in SENSITIVE_HOME_FILES {
286 if canonical == home.join(f) {
287 return Err(format!(
288 "@{} blocked: refuses to read sensitive dotfile {}",
289 target, f
290 ));
291 }
292 }
293 }
294 Ok(canonical)
295}
296
297fn dirs_home() -> Option<PathBuf> {
298 std::env::var_os("HOME").map(PathBuf::from)
299}
300
301async fn expand_file(
302 target: &str,
303 line_start: Option<usize>,
304 line_end: Option<usize>,
305 cwd: &Path,
306) -> Result<String, String> {
307 let path = resolve_path_safe(target, cwd)?;
308 let bytes = tokio::fs::read(&path)
309 .await
310 .map_err(|e| format!("@file:{target} read failed: {e}"))?;
311 if bytes.len() > MAX_FILE_BYTES * 8 {
312 return Err(format!(
313 "@file:{target} too large ({} bytes); read with read_file instead",
314 bytes.len()
315 ));
316 }
317 let text = String::from_utf8_lossy(&bytes).into_owned();
318 let snippet = match (line_start, line_end) {
319 (Some(s), Some(e)) if e >= s => {
320 let lines: Vec<&str> = text.lines().collect();
321 let s = s.saturating_sub(1).min(lines.len());
322 let e = e.min(lines.len());
323 lines[s..e].join("\n")
324 }
325 _ => text,
326 };
327 let snippet = if snippet.len() > MAX_FILE_BYTES {
328 format!(
329 "{}\n... [truncated to {} bytes]",
330 &snippet[..MAX_FILE_BYTES],
331 MAX_FILE_BYTES
332 )
333 } else {
334 snippet
335 };
336 let mut header = format!("@file:{}", target);
337 if let (Some(s), Some(e)) = (line_start, line_end) {
338 header.push_str(&format!(" (lines {s}-{e})"));
339 }
340 Ok(format!("**{}**\n```\n{}\n```", header, snippet))
341}
342
343async fn expand_folder(target: &str, cwd: &Path) -> Result<String, String> {
344 let path = resolve_path_safe(target, cwd)?;
345 let mut rd = tokio::fs::read_dir(&path)
346 .await
347 .map_err(|e| format!("@folder:{target} read failed: {e}"))?;
348 let mut entries: Vec<String> = Vec::new();
349 let mut total = 0usize;
350 while let Ok(Some(entry)) = rd.next_entry().await {
351 if total >= MAX_FOLDER_ENTRIES {
352 entries.push(format!("... (more, capped at {MAX_FOLDER_ENTRIES})"));
353 break;
354 }
355 let name = entry.file_name();
356 let name = name.to_string_lossy();
357 let kind = entry
358 .file_type()
359 .await
360 .ok()
361 .map(|t| if t.is_dir() { "/" } else { "" })
362 .unwrap_or("");
363 entries.push(format!("{name}{kind}"));
364 total += 1;
365 }
366 entries.sort();
367 Ok(format!(
368 "**@folder:{}** ({} entries)\n```\n{}\n```",
369 target,
370 entries.len(),
371 entries.join("\n")
372 ))
373}
374
375async fn expand_url(target: &str) -> Result<String, String> {
376 let body = crate::internal::tool_runtime::web_fetch::fetch_url_with_timeout(
377 target,
378 URL_FETCH_TIMEOUT_SECS,
379 )
380 .await
381 .map_err(|e| format!("@url:{target} fetch failed: {e}"))?;
382 let snippet = if body.len() > MAX_URL_BYTES {
383 format!(
384 "{}\n... [truncated to {} bytes]",
385 &body[..MAX_URL_BYTES],
386 MAX_URL_BYTES
387 )
388 } else {
389 body
390 };
391 Ok(format!("**@url:{}**\n```\n{}\n```", target, snippet))
392}
393
394async fn expand_git_diff(staged: bool, cwd: &Path) -> Result<String, String> {
395 let args: &[&str] = if staged {
396 &["diff", "--cached"]
397 } else {
398 &["diff"]
399 };
400 let label = if staged { "@staged" } else { "@diff" };
401 let output = tokio::process::Command::new("git")
402 .args(args)
403 .current_dir(cwd)
404 .output()
405 .await
406 .map_err(|e| format!("{label}: git not available ({e})"))?;
407 if !output.status.success() {
408 return Err(format!(
409 "{label} failed: {}",
410 String::from_utf8_lossy(&output.stderr).trim()
411 ));
412 }
413 let body = String::from_utf8_lossy(&output.stdout).into_owned();
414 if body.trim().is_empty() {
415 return Ok(format!("**{label}** (no changes)"));
416 }
417 let snippet = if body.len() > MAX_DIFF_BYTES {
418 format!(
419 "{}\n... [truncated to {} bytes]",
420 &body[..MAX_DIFF_BYTES],
421 MAX_DIFF_BYTES
422 )
423 } else {
424 body
425 };
426 Ok(format!("**{label}**\n```diff\n{}\n```", snippet))
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn tmp_dir() -> PathBuf {
434 let dir = std::env::temp_dir().join(format!("microclaw_ctxref_{}", uuid::Uuid::new_v4()));
435 std::fs::create_dir_all(&dir).unwrap();
436 dir
437 }
438
439 #[tokio::test]
440 async fn no_refs_passthrough() {
441 let cwd = tmp_dir();
442 let r = expand_references("plain text, no refs", &cwd).await;
443 assert!(!r.expanded);
444 assert_eq!(r.final_message, "plain text, no refs");
445 let _ = std::fs::remove_dir_all(&cwd);
446 }
447
448 #[tokio::test]
449 async fn expands_file_reference() {
450 let cwd = tmp_dir();
451 std::fs::write(cwd.join("hello.txt"), "world").unwrap();
452 let r = expand_references("look at @file:hello.txt please", &cwd).await;
453 assert!(r.expanded);
454 assert!(r.final_message.contains("@file:hello.txt"));
455 assert!(r.final_message.contains("world"));
456 assert!(r.final_message.contains("look at"));
457 assert!(r.final_message.contains("please"));
458 assert!(!r.stripped_message.contains("@file:hello.txt"));
461 let _ = std::fs::remove_dir_all(&cwd);
462 }
463
464 #[tokio::test]
465 async fn file_with_line_range() {
466 let cwd = tmp_dir();
467 std::fs::write(cwd.join("multi.txt"), "line1\nline2\nline3\nline4\n").unwrap();
468 let r = expand_references("@file:multi.txt:2-3", &cwd).await;
469 assert!(r.expanded);
470 assert!(r.final_message.contains("line2\nline3"));
471 assert!(!r.final_message.contains("line1"));
472 assert!(!r.final_message.contains("line4"));
473 let _ = std::fs::remove_dir_all(&cwd);
474 }
475
476 #[tokio::test]
477 async fn folder_lists_entries() {
478 let cwd = tmp_dir();
479 std::fs::create_dir_all(cwd.join("sub/inner")).unwrap();
480 std::fs::write(cwd.join("sub/a.txt"), "a").unwrap();
481 std::fs::write(cwd.join("sub/b.txt"), "b").unwrap();
482 let r = expand_references("@folder:sub", &cwd).await;
483 assert!(r.expanded);
484 assert!(r.final_message.contains("a.txt"));
485 assert!(r.final_message.contains("b.txt"));
486 assert!(r.final_message.contains("inner/"));
487 let _ = std::fs::remove_dir_all(&cwd);
488 }
489
490 #[tokio::test]
491 async fn missing_file_warns_does_not_panic() {
492 let cwd = tmp_dir();
493 let r = expand_references("@file:nope.txt", &cwd).await;
494 assert!(!r.expanded);
495 assert!(r.warnings.iter().any(|w| w.contains("nope.txt")));
496 let _ = std::fs::remove_dir_all(&cwd);
497 }
498
499 #[test]
500 fn sensitive_path_blocked() {
501 let home = std::env::temp_dir().join(format!("fake_home_{}", uuid::Uuid::new_v4()));
506 let secrets = home.join(".ssh");
507 std::fs::create_dir_all(&secrets).unwrap();
508 std::fs::write(secrets.join("id_rsa"), "secret").unwrap();
509 let target = format!("@file:{}/id_rsa", secrets.display());
510
511 let r = {
512 let _guard = crate::test_support::env_lock();
513 let prev_home = std::env::var_os("HOME");
514 std::env::set_var("HOME", &home);
515
516 let r = tokio::runtime::Builder::new_current_thread()
517 .enable_all()
518 .build()
519 .unwrap()
520 .block_on(expand_references(&target, &home));
521
522 if let Some(prev) = prev_home {
523 std::env::set_var("HOME", prev);
524 } else {
525 std::env::remove_var("HOME");
526 }
527 r
528 };
530
531 let _ = std::fs::remove_dir_all(&home);
532 assert!(!r.expanded, "expected expansion to be blocked");
533 assert!(
534 r.warnings.iter().any(|w| w.contains("blocked")),
535 "expected a 'blocked' warning, got: {:?}",
536 r.warnings
537 );
538 }
539
540 #[test]
541 fn parse_extracts_token_offsets() {
542 let msg = "before @file:foo.txt:10-20 after";
543 let refs = parse_references(msg);
544 assert_eq!(refs.len(), 1);
545 assert_eq!(refs[0].target, "foo.txt");
546 assert_eq!(refs[0].line_start, Some(10));
547 assert_eq!(refs[0].line_end, Some(20));
548 assert_eq!(refs[0].token_start, 7);
549 assert_eq!(
550 &msg[refs[0].token_start..refs[0].token_end],
551 "@file:foo.txt:10-20"
552 );
553 }
554
555 #[test]
556 fn parse_handles_trailing_punctuation() {
557 let refs = parse_references("see @file:hello.txt, please");
558 assert_eq!(refs.len(), 1);
559 assert_eq!(refs[0].target, "hello.txt");
560 }
561
562 #[test]
563 fn parse_recognizes_diff_and_staged() {
564 let refs = parse_references("@diff and @staged");
565 assert_eq!(refs.len(), 2);
566 assert!(matches!(refs[0].kind, ReferenceKind::Diff));
567 assert!(matches!(refs[1].kind, ReferenceKind::Staged));
568 }
569
570 #[test]
571 fn ref_at_start_of_message_matches() {
572 let refs = parse_references("@file:foo.txt explain");
573 assert_eq!(refs.len(), 1);
574 assert_eq!(refs[0].target, "foo.txt");
575 }
576}