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.
421fn 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    // 1. Cheapest layer: per-root (session, agent, rel_path) path cache.
498    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    // 2. Second chance: cross-repo content cache, path miss only.
520    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                    // Formats `<built_at_head> -> <head or "unknown">`. A real
582                    // manifest writer only ever puts a string in `built_at_head`
583                    // (see manifest.rs's header comment); a truthy non-string is
584                    // not produced in practice.
585                    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
609// Builds the one-line `devscout read` offer for a freshly-delivered file:
610// `None` whenever there is nothing honest to offer -- no graph artifact for
611// this root, no defs mapped in this file -- so those reads keep today's
612// empty output exactly. The offered symbol is the def whose declaration span
613// sits nearest to the range this read returned; when the payload does not
614// say where the read sat (the legacy text-only response shape carries no
615// line bounds at all), the fallback is the file's FIRST mapped symbol --
616// noted here because it is a limitation of the payload shape, not a choice
617// about the code: a whole-file read would tie-break to that same first row
618// anyway.
619fn 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    // Requested range: what the tool_response reported about itself
627    // (`file.startLine`, `file.numLines`), falling back to tool_input's
628    // `offset` when the response carries no start of its own. A start with
629    // no length reads as "everything from here down" (`usize::MAX` end),
630    // which makes every span at-or-below the start overlap it; no bounds in
631    // the payload at all reads as a whole-file read, `(1, MAX)` -- whose
632    // tie-break is exactly the first-mapped-symbol fallback documented on
633    // this function.
634    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
654// Gap between a def's declaration span and the requested range: zero on any
655// overlap, otherwise how many lines apart they sit. Ties break by earlier
656// start line, then by id -- deterministic, independent of map order.
657fn 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            // `0` is the "no end recorded" sentinel (TS defs); such a
666            // span measures as its start line alone rather than as a
667            // guessed range.
668            let de = if d.end_line >= ds { d.end_line } else { ds };
669            // Below the range: how far short it falls. Otherwise 0 on a true
670            // overlap, and for a span past the range's end saturating_sub
671            // yields the same positive gap the explicit branch did.
672            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
683// The offer sentence itself. The symbol is named the way the verb can take
684// it: the simple name when no other def shares it (the resolver answers a
685// unique name directly), the full def id when the bare name would print an
686// ambiguity -- never a guess between the two.
687fn 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
702// ===========================================================================
703// Bash-hook implementation
704// ===========================================================================
705
706struct BashTarget {
707    anchor_path: PathBuf,
708}
709
710// Trims the command and strips a leading `rtk proxy ` or `rtk ` prefix.
711fn 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
724// Consumes one or more whitespace chars from the front of `s`; `None` if `s`
725// has no leading whitespace at all.
726fn 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
735// Matches `git [-C <dir>] show <ref>:<path>`. Returns
736// `(optional -C arg, ref, path)`.
737fn 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
754// Splits the tail after "show " into `<ref>:<path>`: neither side may contain
755// whitespace, `ref` may not contain `:` (so the FIRST `:` is unambiguously the
756// separator), both sides non-empty.
757fn 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
770// Matches `cat <path>` with a single whitespace-free path argument.
771fn 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
779// Whether `s` contains any shell metacharacter (`* ? [ ] | & ; < >`).
780fn has_forbidden_chars(s: &str) -> bool {
781    s.chars()
782        .any(|c| matches!(c, '*' | '?' | '[' | ']' | '|' | '&' | ';' | '<' | '>'))
783}
784
785// Matches `read <token>...`. Unlike `match_cat` this is NOT end-anchored: it
786// captures the first whitespace-delimited token after `read ` and tolerates
787// trailing flags (e.g. `--max-lines 5`), which are simply not part of the
788// capture.
789fn match_rtk_read(command: &str) -> Option<&str> {
790    let rest = command.strip_prefix("read ")?;
791    let first = rest.chars().next()?; // \S+ needs >=1 char; "read " (nothing after) -> None
792    if first.is_whitespace() {
793        return None; // \S+ cannot start on a whitespace char ("read  x" double space)
794    }
795    let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
796    Some(&rest[..end])
797}
798
799// Resolves `target` against `base` for the two call sites inside `classify`
800// where the payload's `cwd` (NOT this process's own cwd) is the base. Falls back
801// to this process's actual cwd only in the unreachable-in-practice case where
802// `cwd` itself is relative or absent. No `.`/`..` collapsing here --
803// `repo::find_scout_root` re-normalizes through its own `resolve_path`/
804// `normalize` before this path is used for anything, so a second normalization
805// pass here would be redundant, not incorrect either way.
806fn 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
822// Classifies a bash command as a file-read target (git show / cat / rtk read), or `None`.
823fn 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    // rtk's PreToolUse hook renames the verb: `cat FILE` -> `rtk read FILE` and
845    // `head -n N FILE` -> `rtk read FILE --max-lines N`. normalize_command()
846    // strips only the `rtk ` prefix, so without this branch every
847    // rtk-rewritten read classifies as None and never dedups. Trailing flags
848    // are tolerated here but remain part of the cache key (the caller keys on
849    // the full normalized string), so a truncated read can never collide with
850    // a full one.
851    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
864// Copies the ORIGINAL tool_response, overriding only `stdout`'s value.
865// Overwriting an existing key in place preserves its position, so the mirrored
866// object keeps `resp`'s exact original key order.
867fn 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
885// The bash hook's decision: stub the output on a repeat, else record it fresh.
886fn 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// ===========================================================================
978// Unit tests -- pure-function coverage (classify/normalize/count_lines/envelope
979// shape). Cross-process interop and the latency measurement run out-of-crate:
980// both need a throwaway `.scout` fixture tree and a separate process, which does
981// not belong in a `cargo test` unit block any more than store.rs's own interop
982// coverage does (see that module's header comment on the same split).
983// ===========================================================================
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988    use std::sync::atomic::{AtomicU64, Ordering};
989
990    // Same convention as store.rs's own test module (unique_temp_dir/repo):
991    // a per-process, monotonically-increasing suffix keeps parallel `cargo
992    // test` threads from colliding on the same directory name.
993    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    // `'line of output\n'.repeat(40)` -- > MIN_STDOUT_BYTES (256) so it clears
1010    // the size gate.
1011    fn big_stdout() -> String {
1012        "line of output\n".repeat(40)
1013    }
1014
1015    // Builds a harness-shaped bash payload (defaults `stderr: ""`,
1016    // `interrupted: false`, `isImage: false`, `noOutputExpected: false`).
1017    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    // rtk read dedupes; glob or pipe forms do not.
1031    #[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    // rtk read --max-lines is a distinct cache key from the full read.
1058    #[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    // -- count_lines ------------------------------------------------------
1085
1086    #[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    // -- short_sha ----------------------------------------------------------
1097
1098    #[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    // -- normalize_command --------------------------------------------------
1106
1107    #[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    // -- match_git_show / classify_bash -------------------------------------
1117
1118    #[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()); // no colon
1138        assert!(match_git_show("git show :path").is_none()); // empty ref
1139        assert!(match_git_show("git show ref:").is_none()); // empty path
1140    }
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()); // doesn't even match `cat` shape
1154    }
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    // -- match_rtk_read / classify_bash rtk-read branch ----------------------
1173
1174    #[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        // The read pattern is not end-anchored -- flags after the first token
1182        // are tolerated but not part of the captured path.
1183        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); // wrong verb
1192        assert_eq!(match_rtk_read("read"), None); // no trailing space, no path
1193        assert_eq!(match_rtk_read("read "), None); // trailing space, empty path (\S+ needs >=1 char)
1194        assert_eq!(match_rtk_read("read  src/a.ts"), None); // double space -- \S+ can't start on whitespace
1195        assert_eq!(match_rtk_read("aread src/a.ts"), None); // not a prefix match
1196    }
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    // Behavioral subtlety: unlike `match_cat` (end-anchored, so it rejects
1204    // `cat a.ts | head` outright because the whole remainder must be the path),
1205    // `match_rtk_read` is NOT end-anchored. It only ever captures the FIRST
1206    // whitespace-delimited token, so "read a.ts | head" DOES classify -- anchor
1207    // path "a.ts" -- and the pipe/trailing text never reaches the metacharacter
1208    // guard (it isn't part of the captured token). The command still dedupes
1209    // correctly (keyed on the FULL normalized string, pipe included, per
1210    // `handle_bash`'s cache-key contract), so this is not a correctness bug --
1211    // just a sharper trigger condition than `cat`'s for what counts as a
1212    // "read-shaped" command.
1213    #[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        // classify() only ever sees the anchor path; the cache key distinction
1235        // between `read FILE` and `read FILE --max-lines 50` lives one layer up
1236        // (handle_bash keys on the full normalized string) -- covered by the
1237        // full-pipeline test rtk_read_max_lines_is_distinct_cache_key below.
1238        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    // -- envelope / stub_envelope shape --------------------------------------
1244
1245    #[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    // -- mirror_with_stdout ---------------------------------------------------
1283
1284    #[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    // -- run_read / run_bash fail-open boundary ------------------------------
1297
1298    #[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        // A real file_path/tool_response but no `.scout` anywhere above it
1326        // (temp dir root) -- must produce empty output, not an error exit.
1327        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}