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
420fn handle_read(input: &JVal) -> HookResult<String> {
422 let file_path = input
423 .get("tool_input")
424 .and_then(|t| t.get("file_path"))
425 .and_then(JVal::as_str);
426 let text = extract_text(input.get("tool_response"));
427 let (file_path, text) = match (file_path, text) {
428 (Some(fp), Some(t)) => (fp, t),
429 _ => return Ok(String::new()),
430 };
431
432 let root = match repo::find_scout_root(Path::new(file_path)) {
433 Some(r) => r,
434 None => return Ok(String::new()),
435 };
436
437 let rel = repo::rel_path(&root, Path::new(file_path));
438 let session_id = session_id_of(input);
439 let agent_id = agent_scope(input);
440 let size = text.len() as i64;
441
442 if is_partial_read(input) {
443 let db = store::open_store(&root)?;
444 store::record_spend(
445 &db,
446 &store::RecordSpend {
447 session_id,
448 agent_id,
449 rel_path: &rel,
450 size,
451 },
452 )?;
453 debug_log(
454 &root,
455 "skip-partial",
456 vec![
457 ("rel", JVal::string(rel.clone())),
458 ("session", JVal::string(session_id)),
459 ("agent", JVal::string(agent_id)),
460 ("bytes", JVal::number(size)),
461 ],
462 );
463 if let Some(m) = manifest::read_manifest(&root)? {
464 let built_at_head = m.get("built_at_head");
465 if jval_truthy(built_at_head) {
466 let head = manifest::git_head(&root);
467 let equal = matches!((head.as_deref(), built_at_head), (Some(h), Some(JVal::String(s))) if h == s);
468 if !equal {
469 return Ok(String::new());
470 }
471 }
472 }
473 if !repo::scout_dir(&root).join("refresh-needed").exists() {
474 if let Some(offer) = first_read_offer(&root, &rel, input) {
475 return Ok(envelope("additionalContext", JVal::string(offer)));
476 }
477 }
478 return Ok(String::new());
479 }
480
481 let hash = sha256_hex(text.as_bytes());
482 let orig_file = input.get("tool_response").and_then(|r| r.get("file"));
483 let lines = orig_file
484 .and_then(|f| f.get("numLines"))
485 .and_then(jval_i64)
486 .unwrap_or_else(|| count_lines(text));
487 let mtime = fs::metadata(file_path)
488 .ok()
489 .and_then(|m| m.modified().ok())
490 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
491 .map(|d| d.as_millis() as i64)
492 .unwrap_or(0);
493
494 let db = store::open_store(&root)?;
495 let prior = store::lookup_read(&db, session_id, &rel, agent_id)?;
496
497 if let Some(p) = &prior {
499 if p.sha256 == hash {
500 store::bump_stub(&db, session_id, &rel, agent_id)?;
501 debug_log(
502 &root,
503 "stub",
504 vec![
505 ("rel", JVal::string(rel.clone())),
506 ("sha", JVal::string(short_sha(&hash))),
507 ("session", JVal::string(session_id)),
508 ("agent", JVal::string(agent_id)),
509 ],
510 );
511 let stub_text = format!(
512 "[devscout: unchanged since first read this session — {lines} lines, sha {}. Full content already in context.]",
513 short_sha(&hash)
514 );
515 return Ok(stub_envelope(&stub_text, orig_file, file_path, lines));
516 }
517 }
518
519 let root_str = root.to_string_lossy().into_owned();
521 let cross_stub = content_dedup(session_id, agent_id, &hash, &root_str, &rel, size, lines);
522 if let Some(stub_text) = cross_stub {
523 store::record_fresh(
524 &db,
525 &store::RecordFresh {
526 session_id,
527 agent_id,
528 rel_path: &rel,
529 sha256: &hash,
530 size,
531 mtime,
532 lines,
533 delivered: false,
534 },
535 )?;
536 debug_log(
537 &root,
538 "content-stub",
539 vec![
540 ("rel", JVal::string(rel.clone())),
541 ("sha", JVal::string(short_sha(&hash))),
542 ("session", JVal::string(session_id)),
543 ("agent", JVal::string(agent_id)),
544 ],
545 );
546 return Ok(stub_envelope(&stub_text, orig_file, file_path, lines));
547 }
548
549 store::record_fresh(
550 &db,
551 &store::RecordFresh {
552 session_id,
553 agent_id,
554 rel_path: &rel,
555 sha256: &hash,
556 size,
557 mtime,
558 lines,
559 delivered: true,
560 },
561 )?;
562 debug_log(
563 &root,
564 "fresh",
565 vec![
566 ("rel", JVal::string(rel.clone())),
567 ("sha", JVal::string(short_sha(&hash))),
568 ("session", JVal::string(session_id)),
569 ("agent", JVal::string(agent_id)),
570 ],
571 );
572
573 if let Some(m) = manifest::read_manifest(&root)? {
574 let built_at_head = m.get("built_at_head");
575 if jval_truthy(built_at_head) {
576 let head = manifest::git_head(&root);
577 let equal = matches!((head.as_deref(), built_at_head), (Some(h), Some(JVal::String(s))) if h == s);
578 if !equal {
579 let flag = repo::scout_dir(&root).join("refresh-needed");
580 if !flag.exists() {
581 let built_display = match built_at_head {
586 Some(JVal::String(s)) => s.clone(),
587 _ => String::new(),
588 };
589 let head_display = head.unwrap_or_else(|| "unknown".to_string());
590 fs::write(&flag, format!("{built_display} -> {head_display}\n"))?;
591 return Ok(envelope(
592 "additionalContext",
593 JVal::string("[devscout: manifest stale — HEAD moved since last map. Run 'devscout map --refresh'.]"),
594 ));
595 }
596 }
597 }
598 }
599
600 if !repo::scout_dir(&root).join("refresh-needed").exists() {
601 if let Some(offer) = first_read_offer(&root, &rel, input) {
602 return Ok(envelope("additionalContext", JVal::string(offer)));
603 }
604 }
605
606 Ok(String::new())
607}
608
609fn first_read_offer(root: &Path, rel: &str, input: &JVal) -> Option<String> {
620 let g = crate::graph::read_graph(root)?;
621 let defs: Vec<&crate::graph::Def> = g.defs.iter().filter(|d| d.file == rel).collect();
622 if defs.is_empty() {
623 return None;
624 }
625
626 let orig_file = input.get("tool_response").and_then(|r| r.get("file"));
635 let req_start = orig_file
636 .and_then(|f| f.get("startLine"))
637 .and_then(jval_i64)
638 .or_else(|| {
639 input
640 .get("tool_input")
641 .and_then(|t| t.get("offset"))
642 .and_then(jval_i64)
643 })
644 .map(|v| v.max(1) as usize);
645 let num_lines = orig_file.and_then(|f| f.get("numLines")).and_then(jval_i64);
646 let (rs, re) = match (req_start, num_lines) {
647 (Some(s), Some(n)) => (s, s.saturating_sub(1).saturating_add(n.max(0) as usize)),
648 (Some(s), None) => (s, usize::MAX),
649 (None, _) => (1, usize::MAX),
650 };
651 nearest_def(&defs, rs, re).map(|d| offer_text(&g, d))
652}
653
654fn nearest_def<'a>(
658 defs: &[&'a crate::graph::Def],
659 req_start: usize,
660 req_end: usize,
661) -> Option<&'a crate::graph::Def> {
662 defs.iter().copied().min_by(|a, b| {
663 let key = |d: &crate::graph::Def| {
664 let ds = d.line.max(1);
665 let de = if d.end_line >= ds { d.end_line } else { ds };
669 let gap = if de < req_start {
673 req_start - de
674 } else {
675 ds.saturating_sub(req_end)
676 };
677 (gap, ds)
678 };
679 key(a).cmp(&key(b)).then_with(|| a.id.cmp(&b.id))
680 })
681}
682
683fn offer_text(g: &crate::graph::Graph, chosen: &crate::graph::Def) -> String {
688 let ambiguous = g
689 .defs
690 .iter()
691 .any(|d| d.id != chosen.id && d.name == chosen.name);
692 let subject = if ambiguous {
693 chosen.id.as_str()
694 } else {
695 chosen.name.as_str()
696 };
697 format!(
698 "[devscout: this file is indexed — 'devscout read {subject}' shows its declaration span and inbound references.]"
699 )
700}
701
702struct BashTarget {
707 anchor_path: PathBuf,
708}
709
710fn normalize_command(command: &str) -> String {
712 let trimmed = command.trim();
713 if let Some(after_rtk) = trimmed.strip_prefix("rtk").and_then(skip_ws1) {
714 if let Some(after_proxy) = after_rtk.strip_prefix("proxy").and_then(skip_ws1) {
715 return after_proxy.to_string();
716 }
717 }
718 if let Some(after_rtk) = trimmed.strip_prefix("rtk").and_then(skip_ws1) {
719 return after_rtk.to_string();
720 }
721 trimmed.to_string()
722}
723
724fn skip_ws1(s: &str) -> Option<&str> {
727 let trimmed = s.trim_start();
728 if trimmed.len() == s.len() {
729 None
730 } else {
731 Some(trimmed)
732 }
733}
734
735fn match_git_show(command: &str) -> Option<(Option<&str>, &str, &str)> {
738 let rest = command.strip_prefix("git ")?;
739 if let Some(after_c) = rest.strip_prefix("-C ") {
740 let sp = after_c.find(' ')?;
741 if sp == 0 {
742 return None;
743 }
744 let dir = &after_c[..sp];
745 let after_show = after_c[sp + 1..].strip_prefix("show ")?;
746 let (r, p) = split_ref_path(after_show)?;
747 return Some((Some(dir), r, p));
748 }
749 let after_show = rest.strip_prefix("show ")?;
750 let (r, p) = split_ref_path(after_show)?;
751 Some((None, r, p))
752}
753
754fn split_ref_path(s: &str) -> Option<(&str, &str)> {
758 if s.is_empty() || s.chars().any(char::is_whitespace) {
759 return None;
760 }
761 let colon = s.find(':')?;
762 let (r, rest) = s.split_at(colon);
763 let p = &rest[1..];
764 if r.is_empty() || p.is_empty() {
765 return None;
766 }
767 Some((r, p))
768}
769
770fn match_cat(command: &str) -> Option<&str> {
772 let rest = command.strip_prefix("cat ")?;
773 if rest.is_empty() || rest.chars().any(char::is_whitespace) {
774 return None;
775 }
776 Some(rest)
777}
778
779fn has_forbidden_chars(s: &str) -> bool {
781 s.chars()
782 .any(|c| matches!(c, '*' | '?' | '[' | ']' | '|' | '&' | ';' | '<' | '>'))
783}
784
785fn match_rtk_read(command: &str) -> Option<&str> {
790 let rest = command.strip_prefix("read ")?;
791 let first = rest.chars().next()?; if first.is_whitespace() {
793 return None; }
795 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
796 Some(&rest[..end])
797}
798
799fn resolve_against(base: &str, target: &str) -> PathBuf {
807 let target_path = Path::new(target);
808 if target_path.is_absolute() {
809 return target_path.to_path_buf();
810 }
811 let base_path = Path::new(base);
812 if base_path.is_absolute() {
813 base_path.join(target_path)
814 } else {
815 env::current_dir()
816 .unwrap_or_else(|_| PathBuf::from("/"))
817 .join(base_path)
818 .join(target_path)
819 }
820}
821
822fn classify_bash(command: &str, cwd: Option<&str>) -> Option<BashTarget> {
824 if let Some((c_arg, _git_ref, git_path)) = match_git_show(command) {
825 let base: PathBuf = match c_arg {
826 Some(dir) if Path::new(dir).is_absolute() => PathBuf::from(dir),
827 Some(dir) => resolve_against(cwd.unwrap_or("."), dir),
828 None => PathBuf::from(cwd.unwrap_or(".")),
829 };
830 return Some(BashTarget {
831 anchor_path: base.join(git_path),
832 });
833 }
834 if let Some(path) = match_cat(command) {
835 if !has_forbidden_chars(path) {
836 let p = if Path::new(path).is_absolute() {
837 PathBuf::from(path)
838 } else {
839 resolve_against(cwd.unwrap_or("."), path)
840 };
841 return Some(BashTarget { anchor_path: p });
842 }
843 }
844 if let Some(path) = match_rtk_read(command) {
852 if !has_forbidden_chars(path) {
853 let p = if Path::new(path).is_absolute() {
854 PathBuf::from(path)
855 } else {
856 resolve_against(cwd.unwrap_or("."), path)
857 };
858 return Some(BashTarget { anchor_path: p });
859 }
860 }
861 None
862}
863
864fn mirror_with_stdout(resp: &JVal, stub_text: &str) -> JVal {
868 match resp {
869 JVal::Object(entries) => JVal::Object(
870 entries
871 .iter()
872 .map(|(k, v)| {
873 if k == "stdout" {
874 (k.clone(), JVal::string(stub_text))
875 } else {
876 (k.clone(), v.clone())
877 }
878 })
879 .collect(),
880 ),
881 other => other.clone(),
882 }
883}
884
885fn handle_bash(input: &JVal) -> HookResult<String> {
887 let command = input
888 .get("tool_input")
889 .and_then(|t| t.get("command"))
890 .and_then(JVal::as_str);
891 let resp = input.get("tool_response");
892 let stdout = resp.and_then(|r| r.get("stdout")).and_then(JVal::as_str);
893 let (command, resp, stdout) = match (command, resp, stdout) {
894 (Some(c), Some(r), Some(s)) => (c, r, s),
895 _ => return Ok(String::new()),
896 };
897
898 if matches!(resp.get("interrupted"), Some(JVal::Bool(true))) {
899 return Ok(String::new());
900 }
901 if matches!(resp.get("isImage"), Some(JVal::Bool(true))) {
902 return Ok(String::new());
903 }
904 if let Some(stderr) = resp.get("stderr").and_then(JVal::as_str) {
905 if !stderr.trim().is_empty() {
906 return Ok(String::new());
907 }
908 }
909 if stdout.len() < MIN_STDOUT_BYTES {
910 return Ok(String::new());
911 }
912
913 let normalized = normalize_command(command);
914 let cwd = input.get("cwd").and_then(JVal::as_str);
915 let target = match classify_bash(&normalized, cwd) {
916 Some(t) => t,
917 None => return Ok(String::new()),
918 };
919
920 let root = match repo::find_scout_root(&target.anchor_path) {
921 Some(r) => r,
922 None => return Ok(String::new()),
923 };
924
925 let session_id = session_id_of(input);
926 let agent_id = agent_scope(input);
927 let hash = sha256_hex(stdout.as_bytes());
928 let lines = count_lines(stdout);
929
930 let db = store::open_store(&root)?;
931 let prior = store::lookup_bash(&db, session_id, &normalized, agent_id)?;
932
933 if let Some(p) = &prior {
934 if p.sha256 == hash {
935 store::bump_bash_stub(&db, session_id, &normalized, agent_id)?;
936 debug_log(
937 &root,
938 "bash-stub",
939 vec![
940 ("cmd", JVal::string(normalized.clone())),
941 ("sha", JVal::string(short_sha(&hash))),
942 ("session", JVal::string(session_id)),
943 ("agent", JVal::string(agent_id)),
944 ],
945 );
946 let stub_text =
947 format!("[devscout: identical output already in context this session — {lines} lines, sha {}.]", short_sha(&hash));
948 let mirrored = mirror_with_stdout(resp, &stub_text);
949 return Ok(envelope("updatedToolOutput", mirrored));
950 }
951 }
952
953 store::record_bash_fresh(
954 &db,
955 &store::RecordBashFresh {
956 session_id,
957 agent_id,
958 cache_key: &normalized,
959 sha256: &hash,
960 size: stdout.len() as i64,
961 lines,
962 },
963 )?;
964 debug_log(
965 &root,
966 "bash-fresh",
967 vec![
968 ("cmd", JVal::string(normalized.clone())),
969 ("sha", JVal::string(short_sha(&hash))),
970 ("session", JVal::string(session_id)),
971 ("agent", JVal::string(agent_id)),
972 ],
973 );
974 Ok(String::new())
975}
976
977#[cfg(test)]
986mod tests {
987 use super::*;
988 use std::sync::atomic::{AtomicU64, Ordering};
989
990 static COUNTER: AtomicU64 = AtomicU64::new(0);
994
995 fn unique_temp_dir(prefix: &str) -> PathBuf {
996 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
997 env::temp_dir().join(format!(
998 "scout-hookio-rs-{prefix}-{}-{n}",
999 std::process::id()
1000 ))
1001 }
1002
1003 fn bash_repo() -> PathBuf {
1004 let root = unique_temp_dir("bash-repo");
1005 fs::create_dir_all(root.join(".scout")).unwrap();
1006 root
1007 }
1008
1009 fn big_stdout() -> String {
1012 "line of output\n".repeat(40)
1013 }
1014
1015 fn bash_payload(root: &Path, command: &str, stdout: &str, session: &str) -> String {
1018 format!(
1019 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}}}}"#,
1020 cwd = serde_json::to_string(&root.to_string_lossy()).unwrap(),
1021 command = serde_json::to_string(command).unwrap(),
1022 stdout = serde_json::to_string(stdout).unwrap(),
1023 )
1024 }
1025
1026 fn run_bash_str(payload: &str) -> String {
1027 String::from_utf8(run_bash(payload.as_bytes())).unwrap()
1028 }
1029
1030 #[test]
1032 fn rtk_read_dedupes_glob_or_pipe_forms_do_not() {
1033 let root = bash_repo();
1034 let big = big_stdout();
1035 assert_eq!(
1036 run_bash_str(&bash_payload(&root, "rtk read src/a.ts", &big, "b1")),
1037 ""
1038 );
1039 assert!(
1040 run_bash_str(&bash_payload(&root, "rtk read src/a.ts", &big, "b1"))
1041 .contains("devscout: identical output")
1042 );
1043 assert_eq!(
1044 run_bash_str(&bash_payload(&root, "rtk read src/*.ts", &big, "b1")),
1045 ""
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 a.ts | head", &big, "b1")),
1053 ""
1054 );
1055 }
1056
1057 #[test]
1059 fn rtk_read_max_lines_is_distinct_cache_key_from_full_read() {
1060 let root = bash_repo();
1061 let big = big_stdout();
1062 assert_eq!(
1063 run_bash_str(&bash_payload(
1064 &root,
1065 "rtk read src/a.ts --max-lines 50",
1066 &big,
1067 "b1"
1068 )),
1069 ""
1070 );
1071 assert_eq!(
1072 run_bash_str(&bash_payload(&root, "rtk read src/a.ts", &big, "b1")),
1073 ""
1074 );
1075 assert!(run_bash_str(&bash_payload(
1076 &root,
1077 "rtk read src/a.ts --max-lines 50",
1078 &big,
1079 "b1"
1080 ))
1081 .contains("devscout: identical output"));
1082 }
1083
1084 #[test]
1087 fn count_lines_matches_node_semantics() {
1088 assert_eq!(count_lines(""), 0);
1089 assert_eq!(count_lines("a"), 1);
1090 assert_eq!(count_lines("a\n"), 1);
1091 assert_eq!(count_lines("a\nb"), 2);
1092 assert_eq!(count_lines("a\nb\n"), 2);
1093 assert_eq!(count_lines("\n"), 1);
1094 }
1095
1096 #[test]
1099 fn short_sha_takes_first_eight_hex_chars() {
1100 let hex = sha256_hex(b"abc");
1101 assert_eq!(short_sha(&hex), &hex[..8]);
1102 assert_eq!(short_sha(&hex).len(), 8);
1103 }
1104
1105 #[test]
1108 fn normalize_command_strips_rtk_and_rtk_proxy_prefixes() {
1109 assert_eq!(normalize_command("git status"), "git status");
1110 assert_eq!(normalize_command(" git status "), "git status");
1111 assert_eq!(normalize_command("rtk git status"), "git status");
1112 assert_eq!(normalize_command("rtk proxy git status"), "git status");
1113 assert_eq!(normalize_command("rtkgit status"), "rtkgit status");
1114 }
1115
1116 #[test]
1119 fn git_show_matches_bare_form() {
1120 let (c, r, p) = match_git_show("git show origin/main:src/a.ts").unwrap();
1121 assert_eq!(c, None);
1122 assert_eq!(r, "origin/main");
1123 assert_eq!(p, "src/a.ts");
1124 }
1125
1126 #[test]
1127 fn git_show_matches_dash_c_form() {
1128 let (c, r, p) = match_git_show("git -C /tmp/repo show HEAD:src/a.ts").unwrap();
1129 assert_eq!(c, Some("/tmp/repo"));
1130 assert_eq!(r, "HEAD");
1131 assert_eq!(p, "src/a.ts");
1132 }
1133
1134 #[test]
1135 fn git_show_rejects_non_matching_shapes() {
1136 assert!(match_git_show("git status").is_none());
1137 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()); }
1141
1142 #[test]
1143 fn cat_matches_single_token_only() {
1144 assert_eq!(match_cat("cat src/a.ts"), Some("src/a.ts"));
1145 assert_eq!(match_cat("cat src/a.ts extra"), None);
1146 assert_eq!(match_cat("cat"), None);
1147 }
1148
1149 #[test]
1150 fn cat_glob_and_pipe_are_excluded_by_classify() {
1151 assert!(has_forbidden_chars("src/*.ts"));
1152 assert!(classify_bash("cat src/*.ts", Some("/tmp")).is_none());
1153 assert!(classify_bash("cat a.ts | head", Some("/tmp")).is_none()); }
1155
1156 #[test]
1157 fn classify_git_c_anchors_to_dash_c_dir_ignoring_cwd() {
1158 let target = classify_bash(
1159 "git -C /repo-root show HEAD:src/a.ts",
1160 Some("/somewhere/else"),
1161 )
1162 .unwrap();
1163 assert_eq!(target.anchor_path, Path::new("/repo-root/src/a.ts"));
1164 }
1165
1166 #[test]
1167 fn classify_cat_resolves_relative_against_cwd() {
1168 let target = classify_bash("cat src/a.ts", Some("/repo-root")).unwrap();
1169 assert_eq!(target.anchor_path, Path::new("/repo-root/src/a.ts"));
1170 }
1171
1172 #[test]
1175 fn rtk_read_matches_bare_form() {
1176 assert_eq!(match_rtk_read("read src/a.ts"), Some("src/a.ts"));
1177 }
1178
1179 #[test]
1180 fn rtk_read_matches_and_excludes_trailing_flags_from_capture() {
1181 assert_eq!(
1184 match_rtk_read("read src/a.ts --max-lines 50"),
1185 Some("src/a.ts")
1186 );
1187 }
1188
1189 #[test]
1190 fn rtk_read_rejects_non_matching_shapes() {
1191 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); }
1197
1198 #[test]
1199 fn rtk_read_glob_is_excluded_by_classify() {
1200 assert!(classify_bash("read src/*.ts", Some("/tmp")).is_none());
1201 }
1202
1203 #[test]
1214 fn rtk_read_pipe_form_still_classifies_first_token_only() {
1215 let target = classify_bash("read a.ts | head", Some("/tmp")).unwrap();
1216 assert_eq!(target.anchor_path, Path::new("/tmp/a.ts"));
1217 }
1218
1219 #[test]
1220 fn classify_rtk_read_resolves_relative_against_cwd() {
1221 let target = classify_bash("read src/a.ts", Some("/repo-root")).unwrap();
1222 assert_eq!(target.anchor_path, Path::new("/repo-root/src/a.ts"));
1223 }
1224
1225 #[test]
1226 fn classify_rtk_read_absolute_path_passes_through() {
1227 let target = classify_bash("read /abs/src/a.ts", Some("/repo-root")).unwrap();
1228 assert_eq!(target.anchor_path, Path::new("/abs/src/a.ts"));
1229 }
1230
1231 #[test]
1232 fn classify_rtk_read_flags_stay_out_of_anchor_path_but_flagged_and_bare_forms_are_still_distinct_commands(
1233 ) {
1234 let bare = classify_bash("read src/a.ts", Some("/repo-root")).unwrap();
1239 let flagged = classify_bash("read src/a.ts --max-lines 50", Some("/repo-root")).unwrap();
1240 assert_eq!(bare.anchor_path, flagged.anchor_path);
1241 }
1242
1243 #[test]
1246 fn envelope_field_order_matches_node() {
1247 let out = envelope("additionalContext", JVal::string("hi"));
1248 assert_eq!(
1249 out,
1250 r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"hi"}}"#
1251 );
1252 }
1253
1254 #[test]
1255 fn stub_envelope_legacy_text_shape_when_no_orig_file() {
1256 let out = stub_envelope("stub", None, "/a/b.ts", 3);
1257 assert_eq!(
1258 out,
1259 r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{"type":"text","text":"stub"}}}"#
1260 );
1261 }
1262
1263 #[test]
1264 fn stub_envelope_file_shape_defaults_missing_fields() {
1265 let orig = JVal::object(vec![("filePath", JVal::string("/a/b.ts"))]);
1266 let out = stub_envelope("stub", Some(&orig), "/a/b.ts", 7);
1267 assert_eq!(
1268 out,
1269 r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{"type":"text","file":{"filePath":"/a/b.ts","content":"stub","numLines":1,"startLine":1,"totalLines":7}}}}"#
1270 );
1271 }
1272
1273 #[test]
1274 fn stub_envelope_null_orig_file_falls_back_to_legacy_shape() {
1275 let out = stub_envelope("stub", Some(&JVal::Null), "/a/b.ts", 3);
1276 assert_eq!(
1277 out,
1278 r#"{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput":{"type":"text","text":"stub"}}}"#
1279 );
1280 }
1281
1282 #[test]
1285 fn mirror_with_stdout_preserves_original_key_order() {
1286 let resp = JVal::object(vec![
1287 ("stdout", JVal::string("orig")),
1288 ("stderr", JVal::string("")),
1289 ("interrupted", JVal::Bool(false)),
1290 ]);
1291 let mirrored = mirror_with_stdout(&resp, "stub");
1292 let out = serde_json::to_string(&mirrored).unwrap();
1293 assert_eq!(out, r#"{"stdout":"stub","stderr":"","interrupted":false}"#);
1294 }
1295
1296 #[test]
1299 fn run_read_malformed_json_fails_open_empty_output() {
1300 assert_eq!(run_read(b"{ not json"), Vec::<u8>::new());
1301 }
1302
1303 #[test]
1304 fn run_read_non_utf8_fails_open_empty_output() {
1305 assert_eq!(run_read(&[0xff, 0xfe, 0x00]), Vec::<u8>::new());
1306 }
1307
1308 #[test]
1309 fn run_read_missing_fields_fails_open_empty_output() {
1310 assert_eq!(run_read(b"{}"), Vec::<u8>::new());
1311 }
1312
1313 #[test]
1314 fn run_bash_malformed_json_fails_open_empty_output() {
1315 assert_eq!(run_bash(b"not json at all"), Vec::<u8>::new());
1316 }
1317
1318 #[test]
1319 fn run_bash_missing_fields_fails_open_empty_output() {
1320 assert_eq!(run_bash(b"{}"), Vec::<u8>::new());
1321 }
1322
1323 #[test]
1324 fn run_read_no_scout_root_fails_open_empty_output() {
1325 let dir = std::env::temp_dir().join(format!("scout-hookio-noroot-{}", std::process::id()));
1328 std::fs::create_dir_all(&dir).unwrap();
1329 let file = dir.join("a.ts");
1330 std::fs::write(&file, "hello\n").unwrap();
1331 let payload = format!(
1332 r#"{{"session_id":"s1","tool_input":{{"file_path":"{}"}},"tool_response":{{"text":"hello\n"}}}}"#,
1333 file.to_string_lossy().replace('\\', "\\\\")
1334 );
1335 assert_eq!(run_read(payload.as_bytes()), Vec::<u8>::new());
1336 }
1337}