1use std::env;
33use std::fs;
34use std::path::{Path, PathBuf};
35use std::time::{SystemTime, UNIX_EPOCH};
36
37use crate::manifest::{self, Value as JVal};
38use crate::repo;
39use crate::store;
40
41type HookResult<T> = Result<T, Box<dyn std::error::Error>>;
42
43const MIN_STDOUT_BYTES: usize = 256;
44
45pub fn run_read(raw: &[u8]) -> Vec<u8> {
55 std::panic::catch_unwind(|| decode_and_handle(raw, handle_read))
56 .unwrap_or_default()
57 .into_bytes()
58}
59
60pub fn run_bash(raw: &[u8]) -> Vec<u8> {
62 std::panic::catch_unwind(|| decode_and_handle(raw, handle_bash))
63 .unwrap_or_default()
64 .into_bytes()
65}
66
67fn decode_and_handle(raw: &[u8], handler: fn(&JVal) -> HookResult<String>) -> String {
68 let text = match std::str::from_utf8(raw) {
69 Ok(t) => t,
70 Err(_) => return String::new(),
71 };
72 let input: JVal = match serde_json::from_str(text) {
73 Ok(v) => v,
74 Err(_) => return String::new(),
75 };
76 handler(&input).unwrap_or_default()
77}
78
79fn session_id_of(input: &JVal) -> &str {
87 input
88 .get("session_id")
89 .and_then(JVal::as_str)
90 .unwrap_or("unknown")
91}
92
93fn agent_scope(input: &JVal) -> &str {
96 input.get("agent_id").and_then(JVal::as_str).unwrap_or("")
97}
98
99fn jval_i64(v: &JVal) -> Option<i64> {
100 match v {
101 JVal::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
102 _ => None,
103 }
104}
105
106fn jval_f64(v: &JVal) -> Option<f64> {
107 match v {
108 JVal::Number(n) => n.as_f64(),
109 _ => None,
110 }
111}
112
113fn jval_truthy(v: Option<&JVal>) -> bool {
117 match v {
118 None | Some(JVal::Null) => false,
119 Some(JVal::Bool(b)) => *b,
120 Some(JVal::Number(n)) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
121 Some(JVal::String(s)) => !s.is_empty(),
122 Some(JVal::Array(_)) | Some(JVal::Object(_)) => true,
123 }
124}
125
126fn nullish_or(obj: &JVal, key: &str, default: JVal) -> JVal {
131 match obj.get(key) {
132 Some(v) if !matches!(v, JVal::Null) => v.clone(),
133 _ => default,
134 }
135}
136
137fn envelope(field: &str, value: JVal) -> String {
144 let inner = JVal::object(vec![
145 ("hookEventName", JVal::string("PostToolUse")),
146 (field, value),
147 ]);
148 let outer = JVal::object(vec![("hookSpecificOutput", inner)]);
149 serde_json::to_string(&outer).unwrap_or_default()
150}
151
152fn sha256_hex(bytes: &[u8]) -> String {
158 sha256::digest_hex(bytes)
159}
160
161fn count_lines(text: &str) -> i64 {
164 if text.is_empty() {
165 return 0;
166 }
167 let trimmed = text.strip_suffix('\n').unwrap_or(text);
168 trimmed.split('\n').count() as i64
169}
170
171fn short_sha(hex: &str) -> &str {
173 &hex[..hex.len().min(8)]
174}
175
176mod sha256 {
177 use sha2::{Digest, Sha256};
178 use std::fmt::Write;
179
180 pub fn digest_hex(input: &[u8]) -> String {
181 let digest = Sha256::digest(input);
182 let mut out = String::with_capacity(64);
183 for byte in digest {
184 let _ = write!(out, "{byte:02x}");
185 }
186 out
187 }
188
189 #[cfg(test)]
190 mod tests {
191 use super::*;
192
193 #[test]
194 fn empty_string_vector() {
195 assert_eq!(
196 digest_hex(b""),
197 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
198 );
199 }
200
201 #[test]
202 fn abc_vector() {
203 assert_eq!(
204 digest_hex(b"abc"),
205 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
206 );
207 }
208
209 #[test]
210 fn two_block_vector() {
211 let input = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
213 assert_eq!(
214 digest_hex(input),
215 "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
216 );
217 }
218 }
219}
220
221fn debug_enabled(root: &Path) -> bool {
227 if env::var("SCOUT_DEBUG").ok().as_deref() == Some("1") {
228 return true;
229 }
230 repo::scout_dir(root).join("debug").exists()
231}
232
233fn debug_log(root: &Path, event: &str, fields: Vec<(&str, JVal)>) {
237 if !debug_enabled(root) {
238 return;
239 }
240 let mut entries: Vec<(String, JVal)> = vec![
241 ("ts".to_string(), JVal::string(iso8601_now())),
242 ("event".to_string(), JVal::string(event)),
243 ];
244 entries.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v)));
245 let line = match serde_json::to_string(&JVal::Object(entries)) {
246 Ok(s) => s,
247 Err(_) => return,
248 };
249 let log_path = repo::scout_dir(root).join("debug.log");
250 if let Ok(mut f) = fs::OpenOptions::new()
251 .create(true)
252 .append(true)
253 .open(&log_path)
254 {
255 use std::io::Write;
256 let _ = writeln!(f, "{line}");
257 }
258}
259
260pub(crate) fn iso8601_now() -> String {
265 let now = SystemTime::now()
266 .duration_since(UNIX_EPOCH)
267 .unwrap_or_default();
268 let secs = now.as_secs() as i64;
269 let millis = now.subsec_millis();
270 let days = secs.div_euclid(86400);
271 let secs_of_day = secs.rem_euclid(86400);
272 let (y, m, d) = civil_from_days(days);
273 let hh = secs_of_day / 3600;
274 let mm = (secs_of_day % 3600) / 60;
275 let ss = secs_of_day % 60;
276 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z")
277}
278
279fn civil_from_days(z: i64) -> (i64, u32, u32) {
280 let z = z + 719468;
281 let era = if z >= 0 { z } else { z - 146096 } / 146097;
282 let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
285 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
290}
291
292#[cfg(test)]
293mod civil_tests {
294 use super::civil_from_days;
295
296 #[test]
297 fn epoch_day_zero_is_1970_01_01() {
298 assert_eq!(civil_from_days(0), (1970, 1, 1));
299 }
300
301 #[test]
302 fn known_date_2026_08_15() {
303 assert_eq!(civil_from_days(20680), (2026, 8, 15));
306 }
307}
308
309fn extract_text<'a>(tool_response: Option<&'a JVal>) -> Option<&'a str> {
315 let tr = tool_response?;
316 if let Some(s) = tr
317 .get("file")
318 .and_then(|f| f.get("content"))
319 .and_then(JVal::as_str)
320 {
321 return Some(s);
322 }
323 tr.get("text").and_then(JVal::as_str)
324}
325
326fn is_partial_read(input: &JVal) -> bool {
328 if let Some(offset) = input
329 .get("tool_input")
330 .and_then(|t| t.get("offset"))
331 .and_then(jval_f64)
332 {
333 if offset > 1.0 {
334 return true;
335 }
336 }
337 if let Some(file) = input.get("tool_response").and_then(|r| r.get("file")) {
338 if let (Some(num_lines), Some(total_lines)) = (
339 file.get("numLines").and_then(jval_f64),
340 file.get("totalLines").and_then(jval_f64),
341 ) {
342 return num_lines < total_lines;
343 }
344 }
345 false
346}
347
348fn stub_envelope(stub_text: &str, orig_file: Option<&JVal>, file_path: &str, lines: i64) -> String {
350 let has_orig = matches!(orig_file, Some(v) if !matches!(v, JVal::Null));
351 if has_orig {
352 let of = orig_file.expect("has_orig guarantees Some");
353 let file_obj = JVal::object(vec![
354 (
355 "filePath",
356 nullish_or(of, "filePath", JVal::string(file_path)),
357 ),
358 ("content", JVal::string(stub_text)),
359 ("numLines", JVal::number(1i64)),
360 ("startLine", nullish_or(of, "startLine", JVal::number(1i64))),
361 (
362 "totalLines",
363 nullish_or(of, "totalLines", JVal::number(lines)),
364 ),
365 ]);
366 let inner = JVal::object(vec![("type", JVal::string("text")), ("file", file_obj)]);
367 envelope("updatedToolOutput", inner)
368 } else {
369 let inner = JVal::object(vec![
370 ("type", JVal::string("text")),
371 ("text", JVal::string(stub_text)),
372 ]);
373 envelope("updatedToolOutput", inner)
374 }
375}
376
377#[allow(clippy::too_many_arguments)]
381fn content_dedup(
382 session_id: &str,
383 agent_id: &str,
384 hash: &str,
385 root: &str,
386 rel: &str,
387 size: i64,
388 lines: i64,
389) -> Option<String> {
390 let attempt = || -> HookResult<Option<String>> {
391 let cdb = store::open_content_store()?;
392 if let Some(hit) = store::lookup_content(&cdb, session_id, hash, agent_id)? {
393 if hit.root == root && hit.rel_path == rel {
394 return Ok(None);
395 }
396 store::bump_content_stub(&cdb, session_id, hash, agent_id)?;
397 return Ok(Some(format!(
398 "[devscout: identical content already read this session as {} — {lines} lines, sha {}. Full content already in context.]",
399 hit.rel_path,
400 short_sha(hash)
401 )));
402 }
403 store::record_content(
404 &cdb,
405 &store::RecordContent {
406 session_id,
407 agent_id,
408 sha256: hash,
409 root,
410 rel_path: rel,
411 size,
412 lines,
413 },
414 )?;
415 Ok(None)
416 };
417 attempt().unwrap_or(None)
418}
419
420#[allow(
422 clippy::too_many_lines,
423 reason = "one ordered decision tree over every read-hook outcome; the outcomes only make sense read against each other in the order they are tried"
424)]
425fn handle_read(input: &JVal) -> HookResult<String> {
426 let file_path = input
427 .get("tool_input")
428 .and_then(|t| t.get("file_path"))
429 .and_then(JVal::as_str);
430 let text = extract_text(input.get("tool_response"));
431 let (file_path, text) = match (file_path, text) {
432 (Some(fp), Some(t)) => (fp, t),
433 _ => return Ok(String::new()),
434 };
435
436 let root = match repo::find_scout_root(Path::new(file_path)) {
437 Some(r) => r,
438 None => return Ok(String::new()),
439 };
440
441 let rel = repo::rel_path(&root, Path::new(file_path));
442 let session_id = session_id_of(input);
443 let agent_id = agent_scope(input);
444 let size = text.len() as i64;
445
446 if is_partial_read(input) {
447 let db = store::open_store(&root)?;
448 store::record_spend(
449 &db,
450 &store::RecordSpend {
451 session_id,
452 agent_id,
453 rel_path: &rel,
454 size,
455 },
456 )?;
457 debug_log(
458 &root,
459 "skip-partial",
460 vec![
461 ("rel", JVal::string(rel.clone())),
462 ("session", JVal::string(session_id)),
463 ("agent", JVal::string(agent_id)),
464 ("bytes", JVal::number(size)),
465 ],
466 );
467 if let Some(m) = manifest::read_manifest(&root)? {
468 let built_at_head = m.get("built_at_head");
469 if jval_truthy(built_at_head) {
470 let head = manifest::git_head(&root);
471 let equal = matches!((head.as_deref(), built_at_head), (Some(h), Some(JVal::String(s))) if h == s);
472 if !equal {
473 return Ok(String::new());
474 }
475 }
476 }
477 if !repo::scout_dir(&root).join("refresh-needed").exists() {
478 if let Some(offer) = first_read_offer(&root, &rel, input) {
479 return Ok(envelope("additionalContext", JVal::string(offer)));
480 }
481 }
482 return Ok(String::new());
483 }
484
485 let hash = sha256_hex(text.as_bytes());
486 let orig_file = input.get("tool_response").and_then(|r| r.get("file"));
487 let lines = orig_file
488 .and_then(|f| f.get("numLines"))
489 .and_then(jval_i64)
490 .unwrap_or_else(|| count_lines(text));
491 let mtime = fs::metadata(file_path)
492 .ok()
493 .and_then(|m| m.modified().ok())
494 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
495 .map(|d| d.as_millis() as i64)
496 .unwrap_or(0);
497
498 let db = store::open_store(&root)?;
499 let prior = store::lookup_read(&db, session_id, &rel, agent_id)?;
500
501 if let Some(p) = &prior {
503 if p.sha256 == hash {
504 store::bump_stub(&db, session_id, &rel, agent_id)?;
505 debug_log(
506 &root,
507 "stub",
508 vec![
509 ("rel", JVal::string(rel.clone())),
510 ("sha", JVal::string(short_sha(&hash))),
511 ("session", JVal::string(session_id)),
512 ("agent", JVal::string(agent_id)),
513 ],
514 );
515 let stub_text = format!(
516 "[devscout: unchanged since first read this session — {lines} lines, sha {}. Full content already in context.]",
517 short_sha(&hash)
518 );
519 return Ok(stub_envelope(&stub_text, orig_file, file_path, lines));
520 }
521 }
522
523 let root_str = root.to_string_lossy().into_owned();
525 let cross_stub = content_dedup(session_id, agent_id, &hash, &root_str, &rel, size, lines);
526 if let Some(stub_text) = cross_stub {
527 store::record_fresh(
528 &db,
529 &store::RecordFresh {
530 session_id,
531 agent_id,
532 rel_path: &rel,
533 sha256: &hash,
534 size,
535 mtime,
536 lines,
537 delivered: false,
538 },
539 )?;
540 debug_log(
541 &root,
542 "content-stub",
543 vec![
544 ("rel", JVal::string(rel.clone())),
545 ("sha", JVal::string(short_sha(&hash))),
546 ("session", JVal::string(session_id)),
547 ("agent", JVal::string(agent_id)),
548 ],
549 );
550 return Ok(stub_envelope(&stub_text, orig_file, file_path, lines));
551 }
552
553 store::record_fresh(
554 &db,
555 &store::RecordFresh {
556 session_id,
557 agent_id,
558 rel_path: &rel,
559 sha256: &hash,
560 size,
561 mtime,
562 lines,
563 delivered: true,
564 },
565 )?;
566 debug_log(
567 &root,
568 "fresh",
569 vec![
570 ("rel", JVal::string(rel.clone())),
571 ("sha", JVal::string(short_sha(&hash))),
572 ("session", JVal::string(session_id)),
573 ("agent", JVal::string(agent_id)),
574 ],
575 );
576
577 if let Some(m) = manifest::read_manifest(&root)? {
578 let built_at_head = m.get("built_at_head");
579 if jval_truthy(built_at_head) {
580 let head = manifest::git_head(&root);
581 let equal = matches!((head.as_deref(), built_at_head), (Some(h), Some(JVal::String(s))) if h == s);
582 if !equal {
583 let flag = repo::scout_dir(&root).join("refresh-needed");
584 if !flag.exists() {
585 let built_display = match built_at_head {
590 Some(JVal::String(s)) => s.clone(),
591 _ => String::new(),
592 };
593 let head_display = head.unwrap_or_else(|| "unknown".to_string());
594 fs::write(&flag, format!("{built_display} -> {head_display}\n"))?;
595 return Ok(envelope(
596 "additionalContext",
597 JVal::string("[devscout: manifest stale — HEAD moved since last map. Run 'devscout map --refresh'.]"),
598 ));
599 }
600 }
601 }
602 }
603
604 if !repo::scout_dir(&root).join("refresh-needed").exists() {
605 if let Some(offer) = first_read_offer(&root, &rel, input) {
606 return Ok(envelope("additionalContext", JVal::string(offer)));
607 }
608 }
609
610 Ok(String::new())
611}
612
613fn first_read_offer(root: &Path, rel: &str, input: &JVal) -> Option<String> {
624 let g = crate::graph::read_graph(root)?;
625 let defs: Vec<&crate::graph::Def> = g.defs.iter().filter(|d| d.file == rel).collect();
626 if defs.is_empty() {
627 return None;
628 }
629
630 let orig_file = input.get("tool_response").and_then(|r| r.get("file"));
639 let req_start = orig_file
640 .and_then(|f| f.get("startLine"))
641 .and_then(jval_i64)
642 .or_else(|| {
643 input
644 .get("tool_input")
645 .and_then(|t| t.get("offset"))
646 .and_then(jval_i64)
647 })
648 .map(|v| v.max(1) as usize);
649 let num_lines = orig_file.and_then(|f| f.get("numLines")).and_then(jval_i64);
650 let (rs, re) = match (req_start, num_lines) {
651 (Some(s), Some(n)) => (s, s.saturating_sub(1).saturating_add(n.max(0) as usize)),
652 (Some(s), None) => (s, usize::MAX),
653 (None, _) => (1, usize::MAX),
654 };
655 nearest_def(&defs, rs, re).map(|d| offer_text(&g, d))
656}
657
658fn nearest_def<'a>(
662 defs: &[&'a crate::graph::Def],
663 req_start: usize,
664 req_end: usize,
665) -> Option<&'a crate::graph::Def> {
666 defs.iter().copied().min_by(|a, b| {
667 let key = |d: &crate::graph::Def| {
668 let ds = d.line.max(1);
669 let de = if d.end_line >= ds { d.end_line } else { ds };
673 let gap = if de < req_start {
677 req_start - de
678 } else {
679 ds.saturating_sub(req_end)
680 };
681 (gap, ds)
682 };
683 key(a).cmp(&key(b)).then_with(|| a.id.cmp(&b.id))
684 })
685}
686
687fn offer_text(g: &crate::graph::Graph, chosen: &crate::graph::Def) -> String {
692 let ambiguous = g
693 .defs
694 .iter()
695 .any(|d| d.id != chosen.id && d.name == chosen.name);
696 let subject = if ambiguous {
697 chosen.id.as_str()
698 } else {
699 chosen.name.as_str()
700 };
701 format!(
702 "[devscout: this file is indexed — 'devscout read {subject}' shows its declaration span and inbound references.]"
703 )
704}
705
706struct BashTarget {
711 anchor_path: PathBuf,
712}
713
714fn normalize_command(command: &str) -> String {
716 let trimmed = command.trim();
717 if let Some(after_rtk) = trimmed.strip_prefix("rtk").and_then(skip_ws1) {
718 if let Some(after_proxy) = after_rtk.strip_prefix("proxy").and_then(skip_ws1) {
719 return after_proxy.to_string();
720 }
721 }
722 if let Some(after_rtk) = trimmed.strip_prefix("rtk").and_then(skip_ws1) {
723 return after_rtk.to_string();
724 }
725 trimmed.to_string()
726}
727
728fn skip_ws1(s: &str) -> Option<&str> {
731 let trimmed = s.trim_start();
732 if trimmed.len() == s.len() {
733 None
734 } else {
735 Some(trimmed)
736 }
737}
738
739fn match_git_show(command: &str) -> Option<(Option<&str>, &str, &str)> {
742 let rest = command.strip_prefix("git ")?;
743 if let Some(after_c) = rest.strip_prefix("-C ") {
744 let sp = after_c.find(' ')?;
745 if sp == 0 {
746 return None;
747 }
748 let dir = &after_c[..sp];
749 let after_show = after_c[sp + 1..].strip_prefix("show ")?;
750 let (r, p) = split_ref_path(after_show)?;
751 return Some((Some(dir), r, p));
752 }
753 let after_show = rest.strip_prefix("show ")?;
754 let (r, p) = split_ref_path(after_show)?;
755 Some((None, r, p))
756}
757
758fn split_ref_path(s: &str) -> Option<(&str, &str)> {
762 if s.is_empty() || s.chars().any(char::is_whitespace) {
763 return None;
764 }
765 let colon = s.find(':')?;
766 let (r, rest) = s.split_at(colon);
767 let p = &rest[1..];
768 if r.is_empty() || p.is_empty() {
769 return None;
770 }
771 Some((r, p))
772}
773
774fn match_cat(command: &str) -> Option<&str> {
776 let rest = command.strip_prefix("cat ")?;
777 if rest.is_empty() || rest.chars().any(char::is_whitespace) {
778 return None;
779 }
780 Some(rest)
781}
782
783fn has_forbidden_chars(s: &str) -> bool {
785 s.chars()
786 .any(|c| matches!(c, '*' | '?' | '[' | ']' | '|' | '&' | ';' | '<' | '>'))
787}
788
789fn match_rtk_read(command: &str) -> Option<&str> {
794 let rest = command.strip_prefix("read ")?;
795 let first = rest.chars().next()?; if first.is_whitespace() {
797 return None; }
799 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
800 Some(&rest[..end])
801}
802
803fn resolve_against(base: &str, target: &str) -> PathBuf {
811 let target_path = Path::new(target);
812 if target_path.is_absolute() {
813 return target_path.to_path_buf();
814 }
815 let base_path = Path::new(base);
816 if base_path.is_absolute() {
817 base_path.join(target_path)
818 } else {
819 env::current_dir()
820 .unwrap_or_else(|_| PathBuf::from("/"))
821 .join(base_path)
822 .join(target_path)
823 }
824}
825
826fn classify_bash(command: &str, cwd: Option<&str>) -> Option<BashTarget> {
828 if let Some((c_arg, _git_ref, git_path)) = match_git_show(command) {
829 let base: PathBuf = match c_arg {
830 Some(dir) if Path::new(dir).is_absolute() => PathBuf::from(dir),
831 Some(dir) => resolve_against(cwd.unwrap_or("."), dir),
832 None => PathBuf::from(cwd.unwrap_or(".")),
833 };
834 return Some(BashTarget {
835 anchor_path: base.join(git_path),
836 });
837 }
838 if let Some(path) = match_cat(command) {
839 if !has_forbidden_chars(path) {
840 let p = if Path::new(path).is_absolute() {
841 PathBuf::from(path)
842 } else {
843 resolve_against(cwd.unwrap_or("."), path)
844 };
845 return Some(BashTarget { anchor_path: p });
846 }
847 }
848 if let Some(path) = match_rtk_read(command) {
856 if !has_forbidden_chars(path) {
857 let p = if Path::new(path).is_absolute() {
858 PathBuf::from(path)
859 } else {
860 resolve_against(cwd.unwrap_or("."), path)
861 };
862 return Some(BashTarget { anchor_path: p });
863 }
864 }
865 None
866}
867
868fn mirror_with_stdout(resp: &JVal, stub_text: &str) -> JVal {
872 match resp {
873 JVal::Object(entries) => JVal::Object(
874 entries
875 .iter()
876 .map(|(k, v)| {
877 if k == "stdout" {
878 (k.clone(), JVal::string(stub_text))
879 } else {
880 (k.clone(), v.clone())
881 }
882 })
883 .collect(),
884 ),
885 other => other.clone(),
886 }
887}
888
889fn handle_bash(input: &JVal) -> HookResult<String> {
891 let command = input
892 .get("tool_input")
893 .and_then(|t| t.get("command"))
894 .and_then(JVal::as_str);
895 let resp = input.get("tool_response");
896 let stdout = resp.and_then(|r| r.get("stdout")).and_then(JVal::as_str);
897 let (command, resp, stdout) = match (command, resp, stdout) {
898 (Some(c), Some(r), Some(s)) => (c, r, s),
899 _ => return Ok(String::new()),
900 };
901
902 if matches!(resp.get("interrupted"), Some(JVal::Bool(true))) {
903 return Ok(String::new());
904 }
905 if matches!(resp.get("isImage"), Some(JVal::Bool(true))) {
906 return Ok(String::new());
907 }
908 if let Some(stderr) = resp.get("stderr").and_then(JVal::as_str) {
909 if !stderr.trim().is_empty() {
910 return Ok(String::new());
911 }
912 }
913 if stdout.len() < MIN_STDOUT_BYTES {
914 return Ok(String::new());
915 }
916
917 let normalized = normalize_command(command);
918 let cwd = input.get("cwd").and_then(JVal::as_str);
919 let target = match classify_bash(&normalized, cwd) {
920 Some(t) => t,
921 None => return Ok(String::new()),
922 };
923
924 let root = match repo::find_scout_root(&target.anchor_path) {
925 Some(r) => r,
926 None => return Ok(String::new()),
927 };
928
929 let session_id = session_id_of(input);
930 let agent_id = agent_scope(input);
931 let hash = sha256_hex(stdout.as_bytes());
932 let lines = count_lines(stdout);
933
934 let db = store::open_store(&root)?;
935 let prior = store::lookup_bash(&db, session_id, &normalized, agent_id)?;
936
937 if let Some(p) = &prior {
938 if p.sha256 == hash {
939 store::bump_bash_stub(&db, session_id, &normalized, agent_id)?;
940 debug_log(
941 &root,
942 "bash-stub",
943 vec![
944 ("cmd", JVal::string(normalized.clone())),
945 ("sha", JVal::string(short_sha(&hash))),
946 ("session", JVal::string(session_id)),
947 ("agent", JVal::string(agent_id)),
948 ],
949 );
950 let stub_text =
951 format!("[devscout: identical output already in context this session — {lines} lines, sha {}.]", short_sha(&hash));
952 let mirrored = mirror_with_stdout(resp, &stub_text);
953 return Ok(envelope("updatedToolOutput", mirrored));
954 }
955 }
956
957 store::record_bash_fresh(
958 &db,
959 &store::RecordBashFresh {
960 session_id,
961 agent_id,
962 cache_key: &normalized,
963 sha256: &hash,
964 size: stdout.len() as i64,
965 lines,
966 },
967 )?;
968 debug_log(
969 &root,
970 "bash-fresh",
971 vec![
972 ("cmd", JVal::string(normalized.clone())),
973 ("sha", JVal::string(short_sha(&hash))),
974 ("session", JVal::string(session_id)),
975 ("agent", JVal::string(agent_id)),
976 ],
977 );
978 Ok(String::new())
979}
980
981#[cfg(test)]
990mod tests {
991 use super::*;
992 use std::sync::atomic::{AtomicU64, Ordering};
993
994 static COUNTER: AtomicU64 = AtomicU64::new(0);
998
999 fn unique_temp_dir(prefix: &str) -> PathBuf {
1000 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
1001 env::temp_dir().join(format!(
1002 "scout-hookio-rs-{prefix}-{}-{n}",
1003 std::process::id()
1004 ))
1005 }
1006
1007 fn bash_repo() -> PathBuf {
1008 let root = unique_temp_dir("bash-repo");
1009 fs::create_dir_all(root.join(".scout")).unwrap();
1010 root
1011 }
1012
1013 fn big_stdout() -> String {
1016 "line of output\n".repeat(40)
1017 }
1018
1019 fn bash_payload(root: &Path, command: &str, stdout: &str, session: &str) -> String {
1022 format!(
1023 r#"{{"session_id":"{session}","cwd":{cwd},"tool_name":"Bash","tool_input":{{"command":{command},"description":"test"}},"tool_response":{{"stdout":{stdout},"stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}}"#,
1024 cwd = serde_json::to_string(&root.to_string_lossy()).unwrap(),
1025 command = serde_json::to_string(command).unwrap(),
1026 stdout = serde_json::to_string(stdout).unwrap(),
1027 )
1028 }
1029
1030 fn run_bash_str(payload: &str) -> String {
1031 String::from_utf8(run_bash(payload.as_bytes())).unwrap()
1032 }
1033
1034 #[test]
1036 fn rtk_read_dedupes_glob_or_pipe_forms_do_not() {
1037 let root = bash_repo();
1038 let big = big_stdout();
1039 assert_eq!(
1040 run_bash_str(&bash_payload(&root, "rtk read src/a.ts", &big, "b1")),
1041 ""
1042 );
1043 assert!(
1044 run_bash_str(&bash_payload(&root, "rtk read src/a.ts", &big, "b1"))
1045 .contains("devscout: identical output")
1046 );
1047 assert_eq!(
1048 run_bash_str(&bash_payload(&root, "rtk read src/*.ts", &big, "b1")),
1049 ""
1050 );
1051 assert_eq!(
1052 run_bash_str(&bash_payload(&root, "rtk read src/*.ts", &big, "b1")),
1053 ""
1054 );
1055 assert_eq!(
1056 run_bash_str(&bash_payload(&root, "rtk read a.ts | head", &big, "b1")),
1057 ""
1058 );
1059 }
1060
1061 #[test]
1063 fn rtk_read_max_lines_is_distinct_cache_key_from_full_read() {
1064 let root = bash_repo();
1065 let big = big_stdout();
1066 assert_eq!(
1067 run_bash_str(&bash_payload(
1068 &root,
1069 "rtk read src/a.ts --max-lines 50",
1070 &big,
1071 "b1"
1072 )),
1073 ""
1074 );
1075 assert_eq!(
1076 run_bash_str(&bash_payload(&root, "rtk read src/a.ts", &big, "b1")),
1077 ""
1078 );
1079 assert!(run_bash_str(&bash_payload(
1080 &root,
1081 "rtk read src/a.ts --max-lines 50",
1082 &big,
1083 "b1"
1084 ))
1085 .contains("devscout: identical output"));
1086 }
1087
1088 #[test]
1091 fn count_lines_matches_node_semantics() {
1092 assert_eq!(count_lines(""), 0);
1093 assert_eq!(count_lines("a"), 1);
1094 assert_eq!(count_lines("a\n"), 1);
1095 assert_eq!(count_lines("a\nb"), 2);
1096 assert_eq!(count_lines("a\nb\n"), 2);
1097 assert_eq!(count_lines("\n"), 1);
1098 }
1099
1100 #[test]
1103 fn short_sha_takes_first_eight_hex_chars() {
1104 let hex = sha256_hex(b"abc");
1105 assert_eq!(short_sha(&hex), &hex[..8]);
1106 assert_eq!(short_sha(&hex).len(), 8);
1107 }
1108
1109 #[test]
1112 fn normalize_command_strips_rtk_and_rtk_proxy_prefixes() {
1113 assert_eq!(normalize_command("git status"), "git status");
1114 assert_eq!(normalize_command(" git status "), "git status");
1115 assert_eq!(normalize_command("rtk git status"), "git status");
1116 assert_eq!(normalize_command("rtk proxy git status"), "git status");
1117 assert_eq!(normalize_command("rtkgit status"), "rtkgit status");
1118 }
1119
1120 #[test]
1123 fn git_show_matches_bare_form() {
1124 let (c, r, p) = match_git_show("git show origin/main:src/a.ts").unwrap();
1125 assert_eq!(c, None);
1126 assert_eq!(r, "origin/main");
1127 assert_eq!(p, "src/a.ts");
1128 }
1129
1130 #[test]
1131 fn git_show_matches_dash_c_form() {
1132 let (c, r, p) = match_git_show("git -C /tmp/repo show HEAD:src/a.ts").unwrap();
1133 assert_eq!(c, Some("/tmp/repo"));
1134 assert_eq!(r, "HEAD");
1135 assert_eq!(p, "src/a.ts");
1136 }
1137
1138 #[test]
1139 fn git_show_rejects_non_matching_shapes() {
1140 assert!(match_git_show("git status").is_none());
1141 assert!(match_git_show("git show x").is_none()); assert!(match_git_show("git show :path").is_none()); assert!(match_git_show("git show ref:").is_none()); }
1145
1146 #[test]
1147 fn cat_matches_single_token_only() {
1148 assert_eq!(match_cat("cat src/a.ts"), Some("src/a.ts"));
1149 assert_eq!(match_cat("cat src/a.ts extra"), None);
1150 assert_eq!(match_cat("cat"), None);
1151 }
1152
1153 #[test]
1154 fn cat_glob_and_pipe_are_excluded_by_classify() {
1155 assert!(has_forbidden_chars("src/*.ts"));
1156 assert!(classify_bash("cat src/*.ts", Some("/tmp")).is_none());
1157 assert!(classify_bash("cat a.ts | head", Some("/tmp")).is_none()); }
1159
1160 #[test]
1161 fn classify_git_c_anchors_to_dash_c_dir_ignoring_cwd() {
1162 let target = classify_bash(
1163 "git -C /repo-root show HEAD:src/a.ts",
1164 Some("/somewhere/else"),
1165 )
1166 .unwrap();
1167 assert_eq!(target.anchor_path, Path::new("/repo-root/src/a.ts"));
1168 }
1169
1170 #[test]
1171 fn classify_cat_resolves_relative_against_cwd() {
1172 let target = classify_bash("cat src/a.ts", Some("/repo-root")).unwrap();
1173 assert_eq!(target.anchor_path, Path::new("/repo-root/src/a.ts"));
1174 }
1175
1176 #[test]
1179 fn rtk_read_matches_bare_form() {
1180 assert_eq!(match_rtk_read("read src/a.ts"), Some("src/a.ts"));
1181 }
1182
1183 #[test]
1184 fn rtk_read_matches_and_excludes_trailing_flags_from_capture() {
1185 assert_eq!(
1188 match_rtk_read("read src/a.ts --max-lines 50"),
1189 Some("src/a.ts")
1190 );
1191 }
1192
1193 #[test]
1194 fn rtk_read_rejects_non_matching_shapes() {
1195 assert_eq!(match_rtk_read("cat src/a.ts"), None); assert_eq!(match_rtk_read("read"), None); assert_eq!(match_rtk_read("read "), None); assert_eq!(match_rtk_read("read src/a.ts"), None); assert_eq!(match_rtk_read("aread src/a.ts"), None); }
1201
1202 #[test]
1203 fn rtk_read_glob_is_excluded_by_classify() {
1204 assert!(classify_bash("read src/*.ts", Some("/tmp")).is_none());
1205 }
1206
1207 #[test]
1218 fn rtk_read_pipe_form_still_classifies_first_token_only() {
1219 let target = classify_bash("read a.ts | head", Some("/tmp")).unwrap();
1220 assert_eq!(target.anchor_path, Path::new("/tmp/a.ts"));
1221 }
1222
1223 #[test]
1224 fn classify_rtk_read_resolves_relative_against_cwd() {
1225 let target = classify_bash("read src/a.ts", Some("/repo-root")).unwrap();
1226 assert_eq!(target.anchor_path, Path::new("/repo-root/src/a.ts"));
1227 }
1228
1229 #[test]
1230 fn classify_rtk_read_absolute_path_passes_through() {
1231 let target = classify_bash("read /abs/src/a.ts", Some("/repo-root")).unwrap();
1232 assert_eq!(target.anchor_path, Path::new("/abs/src/a.ts"));
1233 }
1234
1235 #[test]
1236 fn classify_rtk_read_flags_stay_out_of_anchor_path_but_flagged_and_bare_forms_are_still_distinct_commands(
1237 ) {
1238 let bare = classify_bash("read src/a.ts", Some("/repo-root")).unwrap();
1243 let flagged = classify_bash("read src/a.ts --max-lines 50", Some("/repo-root")).unwrap();
1244 assert_eq!(bare.anchor_path, flagged.anchor_path);
1245 }
1246
1247 #[test]
1250 fn envelope_field_order_matches_node() {
1251 let out = envelope("additionalContext", JVal::string("hi"));
1252 assert_eq!(
1253 out,
1254 r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"hi"}}"#
1255 );
1256 }
1257
1258 #[test]
1259 fn stub_envelope_legacy_text_shape_when_no_orig_file() {
1260 let out = stub_envelope("stub", None, "/a/b.ts", 3);
1261 assert_eq!(
1262 out,
1263 r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{"type":"text","text":"stub"}}}"#
1264 );
1265 }
1266
1267 #[test]
1268 fn stub_envelope_file_shape_defaults_missing_fields() {
1269 let orig = JVal::object(vec![("filePath", JVal::string("/a/b.ts"))]);
1270 let out = stub_envelope("stub", Some(&orig), "/a/b.ts", 7);
1271 assert_eq!(
1272 out,
1273 r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{"type":"text","file":{"filePath":"/a/b.ts","content":"stub","numLines":1,"startLine":1,"totalLines":7}}}}"#
1274 );
1275 }
1276
1277 #[test]
1278 fn stub_envelope_null_orig_file_falls_back_to_legacy_shape() {
1279 let out = stub_envelope("stub", Some(&JVal::Null), "/a/b.ts", 3);
1280 assert_eq!(
1281 out,
1282 r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{"type":"text","text":"stub"}}}"#
1283 );
1284 }
1285
1286 #[test]
1289 fn mirror_with_stdout_preserves_original_key_order() {
1290 let resp = JVal::object(vec![
1291 ("stdout", JVal::string("orig")),
1292 ("stderr", JVal::string("")),
1293 ("interrupted", JVal::Bool(false)),
1294 ]);
1295 let mirrored = mirror_with_stdout(&resp, "stub");
1296 let out = serde_json::to_string(&mirrored).unwrap();
1297 assert_eq!(out, r#"{"stdout":"stub","stderr":"","interrupted":false}"#);
1298 }
1299
1300 #[test]
1303 fn run_read_malformed_json_fails_open_empty_output() {
1304 assert_eq!(run_read(b"{ not json"), Vec::<u8>::new());
1305 }
1306
1307 #[test]
1308 fn run_read_non_utf8_fails_open_empty_output() {
1309 assert_eq!(run_read(&[0xff, 0xfe, 0x00]), Vec::<u8>::new());
1310 }
1311
1312 #[test]
1313 fn run_read_missing_fields_fails_open_empty_output() {
1314 assert_eq!(run_read(b"{}"), Vec::<u8>::new());
1315 }
1316
1317 #[test]
1318 fn run_bash_malformed_json_fails_open_empty_output() {
1319 assert_eq!(run_bash(b"not json at all"), Vec::<u8>::new());
1320 }
1321
1322 #[test]
1323 fn run_bash_missing_fields_fails_open_empty_output() {
1324 assert_eq!(run_bash(b"{}"), Vec::<u8>::new());
1325 }
1326
1327 #[test]
1328 fn run_read_no_scout_root_fails_open_empty_output() {
1329 let dir = std::env::temp_dir().join(format!("scout-hookio-noroot-{}", std::process::id()));
1332 std::fs::create_dir_all(&dir).unwrap();
1333 let file = dir.join("a.ts");
1334 std::fs::write(&file, "hello\n").unwrap();
1335 let payload = format!(
1336 r#"{{"session_id":"s1","tool_input":{{"file_path":"{}"}},"tool_response":{{"text":"hello\n"}}}}"#,
1337 file.to_string_lossy().replace('\\', "\\\\")
1338 );
1339 assert_eq!(run_read(payload.as_bytes()), Vec::<u8>::new());
1340 }
1341}