Skip to main content

devscout_rs/
hookio.rs

1// `devscout hook read` and `devscout hook bash`: stdin JSON -> decision -> stdout,
2// fail-open, < 10 ms end-to-end.
3//
4// Components:
5//   - the read hook -- `handle_read` below
6//   - the bash hook -- `handle_bash` below
7//   - hashing helpers (sha256, count_lines, short_sha)
8//   - debug helpers (debug_enabled, debug_log)
9//
10// Ordering-sensitive JSON: both hooks echo back fragments of the ORIGINAL stdin
11// payload (the bash hook mirrors the response with `stdout` overridden; the read
12// hook mirrors the original `file` object's fields) and JSON object key order is
13// text insertion order. `serde_json::Value` is BTreeMap-backed (no
14// `preserve_order` feature, the same constraint manifest.rs's header documents)
15// and would silently re-sort keys, changing the output bytes. So this module
16// parses and re-serializes through `crate::manifest::Value` instead -- a public,
17// order-preserving JSON tree with a correct-escaping Serialize impl (it delegates
18// to serde_json's string serializer, just keeps a `Vec<(String, Value)>` instead
19// of a map).
20//
21// sha256: the `sha2` crate. The unit tests keep the standard NIST vectors as a
22// regression guard on the wrapper.
23//
24// Fail-open: `run_read`/`run_bash` are the ONLY boundary that matters for "any
25// error, timeout, or malformed stdin -> exit 0, tool result untouched" -- they
26// wrap the entire decision path in `catch_unwind` and collapse every
27// `Result::Err` to empty output. The one deliberately NARROWER catch is
28// `content_dedup`, which has its own local try/catch around cross-repo
29// content-store access (a failure there falls through to the fresh-record path,
30// it does not abort the whole hook).
31
32use 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
45// ---------------------------------------------------------------------------
46// CLI entry points (called from cli.rs's `hook read` / `hook bash` arms).
47// This IS the fail-open boundary: `catch_unwind` catches a genuine panic,
48// `.unwrap_or_default()` catches every `Result::Err` (malformed stdin,
49// non-UTF8 stdin, missing store, sqlite failure, ...) -- both collapse to
50// empty output -- the exit-0, tool-result-untouched contract.
51// ---------------------------------------------------------------------------
52
53/// Processes a read-hook JSON payload and returns the replacement payload.
54pub 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
60/// Processes a shell-hook JSON payload and returns the replacement payload.
61pub 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
79// ---------------------------------------------------------------------------
80// Shared stdin-shape helpers (both hooks read session_id/agent_id the same way).
81// ---------------------------------------------------------------------------
82
83// `input.session_id`, defaulting to `"unknown"` when missing or null. A
84// present-but-non-string session_id is also treated as missing; not reachable
85// with a real harness payload (session_id is always a string), so not chased.
86fn session_id_of(input: &JVal) -> &str {
87    input
88        .get("session_id")
89        .and_then(JVal::as_str)
90        .unwrap_or("unknown")
91}
92
93// `input.agent_id` when it is a string, else `""`: a non-string agent_id
94// (including explicit null) defaults to `""` the same as absent.
95fn 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
113// JavaScript-style truthiness for a `Value`: `false`, `""`, numeric `0`,
114// `null`, and "missing" (`None`) are falsy; everything else (including an empty
115// array/object) is truthy.
116fn 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
126// Nullish-coalescing for one object field: `obj[key]` unless that read is
127// missing or null, in which case `default`. Missing key and explicit JSON `null`
128// both fall back; any other value (including falsy-but-not-nullish values like
129// `0`/`""`) passes through unchanged.
130fn 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
137// ---------------------------------------------------------------------------
138// JSON output construction -- order-preserving via `manifest::Value`.
139// ---------------------------------------------------------------------------
140
141// Wraps `value` in the hook output envelope:
142// `{ hookSpecificOutput: { hookEventName: "PostToolUse", <field>: value } }`.
143fn 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
152// ---------------------------------------------------------------------------
153// Hashing helpers.
154// ---------------------------------------------------------------------------
155
156// Lowercase hex SHA-256 of `bytes`.
157fn sha256_hex(bytes: &[u8]) -> String {
158    sha256::digest_hex(bytes)
159}
160
161// Number of lines in `text`. `\n` is a single ASCII byte, so splitting on it is
162// encoding-independent -- no offset translation needed here.
163fn 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
171// First 8 hex chars of `hex`.
172fn 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            // NIST's standard 448-bit multi-block vector.
212            let input = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
213            assert_eq!(
214                digest_hex(input),
215                "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
216            );
217        }
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Debug helpers.
223// ---------------------------------------------------------------------------
224
225// Whether debug logging is on: env `SCOUT_DEBUG=1`, or a `<root>/.scout/debug` file.
226fn 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
233// Appends one JSON line to the debug log. Must never throw or otherwise disturb
234// the caller; every failure (disabled, io error, json error) is a silent no-op.
235// Field order is `ts`, `event`, then caller-supplied fields in call order.
236fn 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
260// An ISO-8601 UTC timestamp (`YYYY-MM-DDThh:mm:ss.sssZ`), hand-rolled with no
261// date/time dependency. Civil-from-days is Howard Hinnant's well-known
262// constant-time algorithm. `pub(crate)`: manifest.rs's `write_index_state`
263// reuses it rather than hand-rolling a second copy.
264pub(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; // [0, 146096]
283    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
284    let y = yoe + era * 400;
285    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
286    let mp = (5 * doy + 2) / 153; // [0, 11]
287    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
288    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
289    (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        // Days from 1970-01-01 to 2026-08-15 (verified via
304        // `datetime.date(2026,8,15) - datetime.date(1970,1,1)`).
305        assert_eq!(civil_from_days(20680), (2026, 8, 15));
306    }
307}
308
309// ===========================================================================
310// Read-hook implementation
311// ===========================================================================
312
313// The file/text content out of a tool response, if any.
314fn 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
326// Whether the read covered only part of the file (offset > 1, or numLines < totalLines).
327fn 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
348// The read-hook envelope carrying the stub text, mirroring the original file object's shape when present.
349fn 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// Cross-repo content dedup. Deliberately swallows every internal error locally
378// (its own try/catch) instead of using `?` -- a content-store failure must fall
379// through to the fresh-record path, not abort the whole hook.
380#[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// The read hook's decision: stub, cross-repo stub, fresh record, or stale-manifest note.
421#[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    // 1. Cheapest layer: per-root (session, agent, rel_path) path cache.
502    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    // 2. Second chance: cross-repo content cache, path miss only.
524    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                    // Formats `<built_at_head> -> <head or "unknown">`. A real
586                    // manifest writer only ever puts a string in `built_at_head`
587                    // (see manifest.rs's header comment); a truthy non-string is
588                    // not produced in practice.
589                    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
613// Builds the one-line `devscout read` offer for a freshly-delivered file:
614// `None` whenever there is nothing honest to offer -- no graph artifact for
615// this root, no defs mapped in this file -- so those reads keep today's
616// empty output exactly. The offered symbol is the def whose declaration span
617// sits nearest to the range this read returned; when the payload does not
618// say where the read sat (the legacy text-only response shape carries no
619// line bounds at all), the fallback is the file's FIRST mapped symbol --
620// noted here because it is a limitation of the payload shape, not a choice
621// about the code: a whole-file read would tie-break to that same first row
622// anyway.
623fn 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    // Requested range: what the tool_response reported about itself
631    // (`file.startLine`, `file.numLines`), falling back to tool_input's
632    // `offset` when the response carries no start of its own. A start with
633    // no length reads as "everything from here down" (`usize::MAX` end),
634    // which makes every span at-or-below the start overlap it; no bounds in
635    // the payload at all reads as a whole-file read, `(1, MAX)` -- whose
636    // tie-break is exactly the first-mapped-symbol fallback documented on
637    // this function.
638    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
658// Gap between a def's declaration span and the requested range: zero on any
659// overlap, otherwise how many lines apart they sit. Ties break by earlier
660// start line, then by id -- deterministic, independent of map order.
661fn 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            // `0` is the "no end recorded" sentinel (TS defs); such a
670            // span measures as its start line alone rather than as a
671            // guessed range.
672            let de = if d.end_line >= ds { d.end_line } else { ds };
673            // Below the range: how far short it falls. Otherwise 0 on a true
674            // overlap, and for a span past the range's end saturating_sub
675            // yields the same positive gap the explicit branch did.
676            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
687// The offer sentence itself. The symbol is named the way the verb can take
688// it: the simple name when no other def shares it (the resolver answers a
689// unique name directly), the full def id when the bare name would print an
690// ambiguity -- never a guess between the two.
691fn 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
706// ===========================================================================
707// Bash-hook implementation
708// ===========================================================================
709
710struct BashTarget {
711    anchor_path: PathBuf,
712}
713
714// Trims the command and strips a leading `rtk proxy ` or `rtk ` prefix.
715fn 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
728// Consumes one or more whitespace chars from the front of `s`; `None` if `s`
729// has no leading whitespace at all.
730fn 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
739// Matches `git [-C <dir>] show <ref>:<path>`. Returns
740// `(optional -C arg, ref, path)`.
741fn 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
758// Splits the tail after "show " into `<ref>:<path>`: neither side may contain
759// whitespace, `ref` may not contain `:` (so the FIRST `:` is unambiguously the
760// separator), both sides non-empty.
761fn 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
774// Matches `cat <path>` with a single whitespace-free path argument.
775fn 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
783// Whether `s` contains any shell metacharacter (`* ? [ ] | & ; < >`).
784fn has_forbidden_chars(s: &str) -> bool {
785    s.chars()
786        .any(|c| matches!(c, '*' | '?' | '[' | ']' | '|' | '&' | ';' | '<' | '>'))
787}
788
789// Matches `read <token>...`. Unlike `match_cat` this is NOT end-anchored: it
790// captures the first whitespace-delimited token after `read ` and tolerates
791// trailing flags (e.g. `--max-lines 5`), which are simply not part of the
792// capture.
793fn match_rtk_read(command: &str) -> Option<&str> {
794    let rest = command.strip_prefix("read ")?;
795    let first = rest.chars().next()?; // \S+ needs >=1 char; "read " (nothing after) -> None
796    if first.is_whitespace() {
797        return None; // \S+ cannot start on a whitespace char ("read  x" double space)
798    }
799    let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
800    Some(&rest[..end])
801}
802
803// Resolves `target` against `base` for the two call sites inside `classify`
804// where the payload's `cwd` (NOT this process's own cwd) is the base. Falls back
805// to this process's actual cwd only in the unreachable-in-practice case where
806// `cwd` itself is relative or absent. No `.`/`..` collapsing here --
807// `repo::find_scout_root` re-normalizes through its own `resolve_path`/
808// `normalize` before this path is used for anything, so a second normalization
809// pass here would be redundant, not incorrect either way.
810fn 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
826// Classifies a bash command as a file-read target (git show / cat / rtk read), or `None`.
827fn 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    // rtk's PreToolUse hook renames the verb: `cat FILE` -> `rtk read FILE` and
849    // `head -n N FILE` -> `rtk read FILE --max-lines N`. normalize_command()
850    // strips only the `rtk ` prefix, so without this branch every
851    // rtk-rewritten read classifies as None and never dedups. Trailing flags
852    // are tolerated here but remain part of the cache key (the caller keys on
853    // the full normalized string), so a truncated read can never collide with
854    // a full one.
855    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
868// Copies the ORIGINAL tool_response, overriding only `stdout`'s value.
869// Overwriting an existing key in place preserves its position, so the mirrored
870// object keeps `resp`'s exact original key order.
871fn 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
889// The bash hook's decision: stub the output on a repeat, else record it fresh.
890fn 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// ===========================================================================
982// Unit tests -- pure-function coverage (classify/normalize/count_lines/envelope
983// shape). Cross-process interop and the latency measurement run out-of-crate:
984// both need a throwaway `.scout` fixture tree and a separate process, which does
985// not belong in a `cargo test` unit block any more than store.rs's own interop
986// coverage does (see that module's header comment on the same split).
987// ===========================================================================
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992    use std::sync::atomic::{AtomicU64, Ordering};
993
994    // Same convention as store.rs's own test module (unique_temp_dir/repo):
995    // a per-process, monotonically-increasing suffix keeps parallel `cargo
996    // test` threads from colliding on the same directory name.
997    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    // `'line of output\n'.repeat(40)` -- > MIN_STDOUT_BYTES (256) so it clears
1014    // the size gate.
1015    fn big_stdout() -> String {
1016        "line of output\n".repeat(40)
1017    }
1018
1019    // Builds a harness-shaped bash payload (defaults `stderr: ""`,
1020    // `interrupted: false`, `isImage: false`, `noOutputExpected: false`).
1021    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    // rtk read dedupes; glob or pipe forms do not.
1035    #[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    // rtk read --max-lines is a distinct cache key from the full read.
1062    #[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    // -- count_lines ------------------------------------------------------
1089
1090    #[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    // -- short_sha ----------------------------------------------------------
1101
1102    #[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    // -- normalize_command --------------------------------------------------
1110
1111    #[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    // -- match_git_show / classify_bash -------------------------------------
1121
1122    #[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()); // no colon
1142        assert!(match_git_show("git show :path").is_none()); // empty ref
1143        assert!(match_git_show("git show ref:").is_none()); // empty path
1144    }
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()); // doesn't even match `cat` shape
1158    }
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    // -- match_rtk_read / classify_bash rtk-read branch ----------------------
1177
1178    #[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        // The read pattern is not end-anchored -- flags after the first token
1186        // are tolerated but not part of the captured path.
1187        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); // wrong verb
1196        assert_eq!(match_rtk_read("read"), None); // no trailing space, no path
1197        assert_eq!(match_rtk_read("read "), None); // trailing space, empty path (\S+ needs >=1 char)
1198        assert_eq!(match_rtk_read("read  src/a.ts"), None); // double space -- \S+ can't start on whitespace
1199        assert_eq!(match_rtk_read("aread src/a.ts"), None); // not a prefix match
1200    }
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    // Behavioral subtlety: unlike `match_cat` (end-anchored, so it rejects
1208    // `cat a.ts | head` outright because the whole remainder must be the path),
1209    // `match_rtk_read` is NOT end-anchored. It only ever captures the FIRST
1210    // whitespace-delimited token, so "read a.ts | head" DOES classify -- anchor
1211    // path "a.ts" -- and the pipe/trailing text never reaches the metacharacter
1212    // guard (it isn't part of the captured token). The command still dedupes
1213    // correctly (keyed on the FULL normalized string, pipe included, per
1214    // `handle_bash`'s cache-key contract), so this is not a correctness bug --
1215    // just a sharper trigger condition than `cat`'s for what counts as a
1216    // "read-shaped" command.
1217    #[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        // classify() only ever sees the anchor path; the cache key distinction
1239        // between `read FILE` and `read FILE --max-lines 50` lives one layer up
1240        // (handle_bash keys on the full normalized string) -- covered by the
1241        // full-pipeline test rtk_read_max_lines_is_distinct_cache_key below.
1242        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    // -- envelope / stub_envelope shape --------------------------------------
1248
1249    #[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    // -- mirror_with_stdout ---------------------------------------------------
1287
1288    #[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    // -- run_read / run_bash fail-open boundary ------------------------------
1301
1302    #[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        // A real file_path/tool_response but no `.scout` anywhere above it
1330        // (temp dir root) -- must produce empty output, not an error exit.
1331        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}