Skip to main content

coding_tools/
steer.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! Redirection steering: recognise the ad-hoc shell idioms a `ct` tool serves
5//! better, and (as a Claude Code `PreToolUse` hook) steer the agent to the
6//! `ct` equivalent instead.
7//!
8//! Agents reach for raw shell — `find | xargs grep`, `sed -i`, `cat | head`,
9//! `for`/`while` loops, sleep-polling waits, `wc -l` counts, and `python -c`/
10//! `jq` file reads — even when a suite tool would do the job bounded,
11//! deterministic, and self-verifying. [`analyze`] is the pure heart: it
12//! classifies a shell
13//! command string into an optional [`Steer`] naming the `ct` tool that serves
14//! it and a best-effort equivalent command. The [`hook`] submodule wraps that
15//! in the Claude Code `PreToolUse` JSON protocol (deny / ask / warn); the
16//! [`install`] submodule wires the hook into a project's `.claude/settings.json`.
17//!
18//! The matcher is deliberately **conservative**: it only fires on a fixed set
19//! of high-confidence 1:1 idioms, never re-steers a command that already
20//! invokes `ct`, and returns [`None`] (allow) whenever it is unsure. The hook
21//! is **fail-open** — any malformed input or unrecognised command is allowed —
22//! because it runs ahead of *every* shell call.
23
24/// What the hook does when a command matches a steering rule.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum Mode {
27    /// Block the call and feed the `ct` suggestion back to the agent (default).
28    #[default]
29    Deny,
30    /// Surface a confirmation prompt naming the `ct` suggestion.
31    Ask,
32    /// Allow the call, but inject the `ct` suggestion as context.
33    Warn,
34}
35
36impl Mode {
37    /// Parse the `--mode` value.
38    ///
39    /// ```
40    /// use coding_tools::steer::Mode;
41    /// assert_eq!(Mode::from_name("ask"), Some(Mode::Ask));
42    /// assert_eq!(Mode::from_name("nope"), None);
43    /// ```
44    pub fn from_name(s: &str) -> Option<Mode> {
45        match s {
46            "deny" => Some(Mode::Deny),
47            "ask" => Some(Mode::Ask),
48            "warn" => Some(Mode::Warn),
49            _ => None,
50        }
51    }
52
53    /// The canonical name, as accepted by `--mode` and written into settings.
54    pub fn name(self) -> &'static str {
55        match self {
56            Mode::Deny => "deny",
57            Mode::Ask => "ask",
58            Mode::Warn => "warn",
59        }
60    }
61}
62
63/// A steering match: a `ct` tool serves the inspected command.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Steer {
66    /// Stable identifier for the rule that fired (e.g. `"find-grep"`).
67    pub rule_id: &'static str,
68    /// The `ct` tool that serves the idiom (e.g. `"ct search"`).
69    pub tool: &'static str,
70    /// A best-effort equivalent `ct` command line.
71    pub suggestion: String,
72    /// One line teaching why the `ct` tool is the better fit.
73    pub note: &'static str,
74}
75
76impl Steer {
77    /// The reason text shown to the agent (the `ct` command plus the lesson).
78    pub fn reason(&self) -> String {
79        format!(
80            "A `ct` tool serves this more reliably — bounded, deterministic, \
81             and self-verifying. Use instead:\n  {}\n({})",
82            self.suggestion, self.note
83        )
84    }
85}
86
87// ----- Tool-call logging -------------------------------------------------------
88
89/// The UTC calendar date `yyyy-mm-dd` for `epoch_secs` seconds since the Unix
90/// epoch — the daily tool-call-log filename stem. Pure (it reads no clock), via
91/// Howard Hinnant's civil-from-days algorithm, so it is deterministic and
92/// testable; the caller supplies the current time.
93///
94/// # Examples
95///
96/// ```
97/// use coding_tools::steer::date_stem;
98/// assert_eq!(date_stem(0), "1970-01-01");
99/// assert_eq!(date_stem(1_600_000_000), "2020-09-13");
100/// ```
101pub fn date_stem(epoch_secs: i64) -> String {
102    let (y, m, d) = civil_from_days(epoch_secs.div_euclid(86_400));
103    format!("{y:04}-{m:02}-{d:02}")
104}
105
106/// `(year, month, day)` for a count of days since 1970-01-01 (Hinnant's
107/// `civil_from_days`). Handles negative days (pre-epoch) via Euclidean division.
108fn civil_from_days(days: i64) -> (i64, u32, u32) {
109    let z = days + 719_468;
110    let era = z.div_euclid(146_097);
111    let doe = z - era * 146_097; // [0, 146096]
112    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
113    let y = yoe + era * 400;
114    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
115    let mp = (5 * doy + 2) / 153; // [0, 11]
116    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
117    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
118    (if m <= 2 { y + 1 } else { y }, m, d)
119}
120
121/// The gitignore rule that hides the tool-call log directory. Placed in
122/// `.ct/.gitignore`, the pattern `*log` matches the `tclog` directory (and any
123/// other `…log` entry under `.ct`), keeping the logs out of version control
124/// while the `.gitignore` itself stays tracked.
125pub const LOG_IGNORE_RULE: &str = "*log";
126
127/// Given a `.ct/.gitignore`'s current contents (or [`None`] when it is absent),
128/// return the contents to write so it carries [`LOG_IGNORE_RULE`], or [`None`]
129/// when the rule is already present (no write needed). Existing lines are
130/// preserved; the rule is appended.
131///
132/// # Examples
133///
134/// ```
135/// use coding_tools::steer::gitignore_with_log_rule;
136/// assert_eq!(gitignore_with_log_rule(None).as_deref(), Some("*log\n"));
137/// assert_eq!(gitignore_with_log_rule(Some("*log\n")), None); // already there
138/// assert_eq!(gitignore_with_log_rule(Some("target\n")).as_deref(), Some("target\n*log\n"));
139/// ```
140pub fn gitignore_with_log_rule(existing: Option<&str>) -> Option<String> {
141    match existing {
142        None => Some(format!("{LOG_IGNORE_RULE}\n")),
143        Some(text) if text.lines().any(|l| l.trim() == LOG_IGNORE_RULE) => None,
144        Some(text) => {
145            let mut out = text.to_string();
146            if !out.is_empty() && !out.ends_with('\n') {
147                out.push('\n');
148            }
149            out.push_str(LOG_IGNORE_RULE);
150            out.push('\n');
151            Some(out)
152        }
153    }
154}
155
156// ----- Lexing ------------------------------------------------------------------
157
158/// A shell token: either a word (quoted regions collapse into the surrounding
159/// word, so operators inside quotes are inert) or one of the control operators
160/// we split on. Redirections and grouping are dropped to word boundaries.
161#[derive(Debug, Clone, PartialEq, Eq)]
162enum Tok {
163    Word(String),
164    Pipe,
165    And,
166    Or,
167    Semi,
168}
169
170/// Tokenise a command string. Single/double quotes and backslash escapes keep
171/// their contents inside the current word, so `echo "a | b"` is one word and
172/// the `|` does not register as a pipe.
173fn lex(cmd: &str) -> Vec<Tok> {
174    let mut toks = Vec::new();
175    let mut cur = String::new();
176    let mut have = false; // cur holds a word (possibly empty, from `""`)
177    let mut chars = cmd.chars().peekable();
178
179    fn flush(toks: &mut Vec<Tok>, cur: &mut String, have: &mut bool) {
180        if *have {
181            toks.push(Tok::Word(std::mem::take(cur)));
182            *have = false;
183        }
184    }
185
186    while let Some(c) = chars.next() {
187        match c {
188            '\'' => {
189                have = true;
190                for d in chars.by_ref() {
191                    if d == '\'' {
192                        break;
193                    }
194                    cur.push(d);
195                }
196            }
197            '"' => {
198                have = true;
199                while let Some(d) = chars.next() {
200                    if d == '"' {
201                        break;
202                    }
203                    if d == '\\' {
204                        if let Some(e) = chars.next() {
205                            cur.push(e);
206                        }
207                    } else {
208                        cur.push(d);
209                    }
210                }
211            }
212            '\\' => {
213                if let Some(d) = chars.next() {
214                    cur.push(d);
215                    have = true;
216                }
217            }
218            '|' => {
219                flush(&mut toks, &mut cur, &mut have);
220                if chars.peek() == Some(&'|') {
221                    chars.next();
222                    toks.push(Tok::Or);
223                } else {
224                    toks.push(Tok::Pipe);
225                }
226            }
227            '&' => {
228                flush(&mut toks, &mut cur, &mut have);
229                if chars.peek() == Some(&'&') {
230                    chars.next();
231                    toks.push(Tok::And);
232                } else {
233                    toks.push(Tok::Semi); // a lone `&` (background) ends a command
234                }
235            }
236            ';' => {
237                flush(&mut toks, &mut cur, &mut have);
238                toks.push(Tok::Semi);
239            }
240            // Redirections and grouping: end the current word, drop the symbol.
241            '>' | '<' | '(' | ')' | '{' | '}' | '`' => {
242                flush(&mut toks, &mut cur, &mut have);
243            }
244            c if c.is_whitespace() => flush(&mut toks, &mut cur, &mut have),
245            _ => {
246                cur.push(c);
247                have = true;
248            }
249        }
250    }
251    flush(&mut toks, &mut cur, &mut have);
252    toks
253}
254
255/// Split a token stream into control segments (on `&&` / `||` / `;`) plus the
256/// list of joiner operators between them (length = segments − 1).
257fn control_segments(toks: &[Tok]) -> (Vec<Vec<Tok>>, Vec<Tok>) {
258    let mut segs = vec![Vec::new()];
259    let mut joiners = Vec::new();
260    for t in toks {
261        match t {
262            Tok::And | Tok::Or | Tok::Semi => {
263                joiners.push(t.clone());
264                segs.push(Vec::new());
265            }
266            other => segs.last_mut().unwrap().push(other.clone()),
267        }
268    }
269    // Drop a trailing empty segment (e.g. a command ending in `;`).
270    if segs.last().is_some_and(Vec::is_empty) {
271        segs.pop();
272        joiners.pop();
273    }
274    (segs, joiners)
275}
276
277/// Split one control segment into pipeline stages (on `|`); each stage is its
278/// word list.
279fn pipe_stages(seg: &[Tok]) -> Vec<Vec<String>> {
280    let mut stages = vec![Vec::new()];
281    for t in seg {
282        match t {
283            Tok::Pipe => stages.push(Vec::new()),
284            Tok::Word(w) => stages.last_mut().unwrap().push(w.clone()),
285            _ => {}
286        }
287    }
288    stages
289}
290
291// ----- Word/flag helpers -------------------------------------------------------
292
293/// The basename of a command word (`/usr/bin/grep` → `grep`).
294fn base_name(w: &str) -> &str {
295    w.rsplit(['/', '\\']).next().unwrap_or(w)
296}
297
298/// The command name of a stage (its first word, basename-stripped).
299fn cmd_of(stage: &[String]) -> Option<&str> {
300    stage.first().map(|w| base_name(w))
301}
302
303/// Whether a stage carries a single-dash short flag containing `ch` (so `-r`,
304/// `-rn`, `-Rl` all count for `'r'`), excluding `--long` words.
305fn has_short(stage: &[String], ch: char) -> bool {
306    stage
307        .iter()
308        .any(|w| w.starts_with('-') && !w.starts_with("--") && w[1..].chars().any(|c| c == ch))
309}
310
311/// Whether a stage carries `flag` exactly, or `flag=…`.
312fn has_flag(stage: &[String], flag: &str) -> bool {
313    stage
314        .iter()
315        .any(|w| w == flag || w.starts_with(&format!("{flag}=")))
316}
317
318/// The value of `-flag VALUE`, `--flag VALUE`, or `--flag=VALUE` in a stage.
319fn flag_value<'a>(stage: &'a [String], names: &[&str]) -> Option<&'a str> {
320    for (i, w) in stage.iter().enumerate() {
321        for n in names {
322            if w == n {
323                return stage.get(i + 1).map(String::as_str);
324            }
325            let eq = format!("{n}=");
326            if let Some(v) = w.strip_prefix(&eq) {
327                return Some(v);
328            }
329        }
330    }
331    None
332}
333
334/// The positional (non-flag) words of a stage after its command. Imperfect —
335/// a value-taking flag's value (e.g. the `40` in `head -n 40`) leaks through —
336/// so callers that care filter further.
337fn positionals(stage: &[String]) -> Vec<&str> {
338    stage
339        .iter()
340        .skip(1)
341        .filter(|w| !w.starts_with('-'))
342        .map(String::as_str)
343        .collect()
344}
345
346/// A `find` start path: the first argument, when it is not a `-option`
347/// (`find <path> -name …`; a bare `find -name …` defaults to the cwd).
348fn find_base(find: &[String]) -> Option<&str> {
349    find.get(1)
350        .filter(|w| !w.starts_with('-'))
351        .map(String::as_str)
352}
353
354/// Single-quote a value for display inside a suggested command.
355fn q(s: &str) -> String {
356    format!("'{}'", s.replace('\'', "'\\''"))
357}
358
359// ----- Rules -------------------------------------------------------------------
360
361/// Classify a shell command. [`None`] means "allow" — no `ct` tool clearly
362/// serves it. The matcher only fires on high-confidence idioms and never
363/// re-steers a command that already invokes `ct`.
364///
365/// ```
366/// use coding_tools::steer::analyze;
367/// let s = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
368/// assert_eq!(s.tool, "ct search");
369/// assert!(analyze("cargo build && cargo test").is_none());
370/// assert!(analyze("ct search --grep TODO").is_none());
371/// ```
372pub fn analyze(command: &str) -> Option<Steer> {
373    let toks = lex(command);
374    if toks.is_empty() {
375        return None;
376    }
377    let (segs, joiners) = control_segments(&toks);
378    let seg_stages: Vec<Vec<Vec<String>>> = segs.iter().map(|s| pipe_stages(s)).collect();
379
380    // Never re-steer a command that already involves `ct` / `ct-*` anywhere
381    // (as a command, or behind `xargs`/`env`/…). Erring toward allow here is
382    // safe — at worst we decline to steer a grep that merely mentions `ct-…`.
383    let touches_ct = seg_stages.iter().flatten().flatten().any(|w| {
384        let b = base_name(w);
385        b == "ct" || b.starts_with("ct-")
386    });
387    if touches_ct {
388        return None;
389    }
390
391    // Shell loops (`for`/`while`/`until`) — a control word starting the first
392    // segment. A loop whose body `sleep`s and re-probes is a bounded *wait*
393    // (steer to `ct await`); any other loop is a per-item map (`ct each`).
394    if let Some(first) = seg_stages
395        .first()
396        .and_then(|s| s.first())
397        .and_then(|s| cmd_of(s))
398        && matches!(first, "for" | "while" | "until")
399    {
400        let waits = seg_stages
401            .iter()
402            .flatten()
403            .flatten()
404            .any(|w| matches!(base_name(w), "sleep" | "usleep" | "Start-Sleep"));
405        return Some(if waits {
406            Steer {
407                rule_id: "wait-loop",
408                tool: "ct await",
409                suggestion: "ct await --timeout <SECS> --every <N> -- <probe-argv>".to_string(),
410                note: "ct await polls a read-only probe until it passes (or a timeout/abort fires) with no shell loop — and being the wait itself, it should be launched in the background, never wrapped in `for/while … sleep`",
411            }
412        } else {
413            Steer {
414                rule_id: "shell-loop",
415                tool: "ct each",
416                suggestion: "ct each --items <a> <b> -- <cmd-template-with-{ITEM}>".to_string(),
417                note: "ct each runs a command template once per item with no shell and an aggregate --expect verdict",
418            }
419        });
420    }
421
422    // A single command (possibly with pipes): the common, high-value case.
423    if segs.len() == 1 {
424        return analyze_segment(&seg_stages[0]);
425    }
426
427    // A chain (`&&` / `||`): only steer when *every* segment is itself
428    // ct-serviceable and the joiners are uniform, so `ct and`/`ct or`
429    // reproduces it faithfully. A mixed chain (e.g. `grep -r x && make`) is
430    // left alone.
431    let matches: Vec<Steer> = seg_stages
432        .iter()
433        .filter_map(|st| analyze_segment(st))
434        .collect();
435    if matches.len() == segs.len() && !joiners.is_empty() {
436        if joiners.iter().all(|j| *j == Tok::And) {
437            return Some(chain_steer("ct and", &matches));
438        }
439        if joiners.iter().all(|j| *j == Tok::Or) {
440            return Some(chain_steer("ct or", &matches));
441        }
442    }
443    None
444}
445
446/// Build the chain suggestion from each segment's own `ct` suggestion, joined
447/// with the suite's shell-less `:::` separator.
448fn chain_steer(head: &'static str, parts: &[Steer]) -> Steer {
449    let body = parts
450        .iter()
451        .map(|p| p.suggestion.trim_start_matches("ct ").to_string())
452        .collect::<Vec<_>>()
453        .join(" ::: ");
454    let (rule_id, note) = if head == "ct and" {
455        (
456            "and-chain",
457            "ct and runs each step in turn, stopping at the first failure — a shell-less && with no quoting",
458        )
459    } else {
460        (
461            "or-chain",
462            "ct or runs each step in turn, stopping at the first success — a shell-less || with no quoting",
463        )
464    };
465    Steer {
466        rule_id,
467        tool: head,
468        suggestion: format!("{head} {body}"),
469        note,
470    }
471}
472
473/// Classify a single control segment (its pipeline stages). Rule order encodes
474/// priority: the most specific idiom wins.
475fn analyze_segment(stages: &[Vec<String>]) -> Option<Steer> {
476    rule_find_grep(stages)
477        .or_else(|| rule_grep_recursive(stages))
478        .or_else(|| rule_grep_count(stages))
479        .or_else(|| rule_sed_inplace(stages))
480        .or_else(|| rule_read_range(stages))
481        .or_else(|| rule_interpreter_read(stages))
482        .or_else(|| rule_find_files(stages))
483        .or_else(|| rule_list_recursive(stages))
484        .or_else(|| rule_count_lines(stages))
485}
486
487/// `find … | xargs grep` / `find … -exec grep` → `ct search`.
488fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
489    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
490    // grep appearing anywhere (its own stage, after xargs, or after -exec).
491    let grep_stage = stages
492        .iter()
493        .find(|s| s.iter().any(|w| base_name(w) == "grep"))?;
494    let glob = flag_value(find, &["-name", "-iname"]);
495    let pat = grep_pattern(grep_stage);
496    Some(Steer {
497        rule_id: "find-grep",
498        tool: "ct search",
499        suggestion: search_suggestion(find_base(find), glob, pat),
500        note: "ct search recurses, filters by name/type/size, and greps in one declarative pass — find | xargs grep in a single command",
501    })
502}
503
504/// `grep -r` / `rg` / `ag` → `ct search`.
505fn rule_grep_recursive(stages: &[Vec<String>]) -> Option<Steer> {
506    for s in stages {
507        let Some(cmd) = cmd_of(s) else { continue };
508        let recursive_grep =
509            cmd == "grep" && (has_short(s, 'r') || has_short(s, 'R') || has_flag(s, "--recursive"));
510        if recursive_grep || matches!(cmd, "rg" | "ripgrep" | "ag") {
511            let pat = grep_pattern(s);
512            // `grep -r PAT PATH` / `rg PAT PATH`: the second positional is the path.
513            let base = positionals(s).get(1).copied();
514            return Some(Steer {
515                rule_id: "grep-recursive",
516                tool: "ct search",
517                suggestion: search_suggestion(base, None, pat),
518                note: "ct search is the suite's recursive content search, with a framed --expect verdict (e.g. --expect none asserts absence)",
519            });
520        }
521    }
522    None
523}
524
525/// `grep -c PATTERN FILE` (count matching lines) → `ct search … --summary`.
526fn rule_grep_count(stages: &[Vec<String>]) -> Option<Steer> {
527    for s in stages {
528        let Some(cmd) = cmd_of(s) else { continue };
529        if matches!(cmd, "grep" | "egrep" | "fgrep") && has_short(s, 'c') {
530            // `grep -c PATTERN FILE`: the second positional is the path.
531            let base = positionals(s).get(1).copied();
532            return Some(Steer {
533                rule_id: "grep-count",
534                tool: "ct search",
535                suggestion: format!(
536                    "{} --summary",
537                    search_suggestion(base, None, grep_pattern(s))
538                ),
539                note: "ct search --summary reports the match count directly (and --expect +N|=N turns it into a pass/fail assertion), replacing grep -c",
540            });
541        }
542    }
543    None
544}
545
546/// An interpreter one-liner that READS a file — `jq EXPR FILE`,
547/// `python -c '…open("x")…'`, `node -e`, `perl -e`, `ruby -e` — with no write
548/// signal → `ct view` / `ct search`. Pure-compute one-liners (no file read) and
549/// anything that looks like it writes are left alone.
550fn rule_interpreter_read(stages: &[Vec<String>]) -> Option<Steer> {
551    for s in stages {
552        let Some(cmd) = cmd_of(s) else { continue };
553        // `jq EXPR FILE…`: a file argument means it reads a file, not a stream.
554        if cmd == "jq" {
555            if let Some(&file) = positionals(s).get(1) {
556                return Some(interpreter_steer(Some(file)));
557            }
558            continue;
559        }
560        // `python/node/perl/ruby -c|-e '<body>'`: inspect the inline script.
561        let interp = matches!(
562            cmd,
563            "python" | "python3" | "node" | "nodejs" | "perl" | "ruby"
564        );
565        if interp
566            && let Some(body) = flag_value(s, &["-c", "-e"])
567            && reads_file(body)
568            && !writes_file(body)
569        {
570            return Some(interpreter_steer(quoted_path(body)));
571        }
572    }
573    None
574}
575
576/// `find … -name` with no grep → `ct search` (name filter only).
577fn rule_find_files(stages: &[Vec<String>]) -> Option<Steer> {
578    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
579    let glob = flag_value(find, &["-name", "-iname"])?;
580    let base = find_base(find);
581    Some(Steer {
582        rule_id: "find-files",
583        tool: "ct search",
584        suggestion: search_suggestion(base, Some(glob), None),
585        note: "ct search selects files by --name/--type/--size and reports them, replacing a bare find",
586    })
587}
588
589/// `sed -i` / `perl -i` → `ct edit`.
590fn rule_sed_inplace(stages: &[Vec<String>]) -> Option<Steer> {
591    let stage = stages.iter().find(|s| {
592        let cmd = cmd_of(s);
593        let sed_i =
594            cmd == Some("sed") && s.iter().any(|w| w.starts_with("-i") || w == "--in-place");
595        let perl_i = cmd == Some("perl") && s.iter().any(|w| w.starts_with("-i"));
596        sed_i || perl_i
597    })?;
598    let (find, replace) = sed_subst(stage);
599    let suggestion = match (find, replace) {
600        (Some(f), Some(r)) => format!(
601            "ct edit --base . --find {} --replace {} --expect =1 --dry-run",
602            q(f),
603            q(r)
604        ),
605        _ => "ct edit --base . --find <text> --replace <text> --expect =1 --dry-run".to_string(),
606    };
607    Some(Steer {
608        rule_id: "sed-inplace",
609        tool: "ct edit",
610        suggestion,
611        note: "ct edit previews the diff (--dry-run) and writes only when the match count matches --expect, so a wrong-sized in-place edit fails loudly instead of applying silently",
612    })
613}
614
615/// `head`/`tail`/`sed -n 'A,Bp'` on a file → `ct view --range`.
616fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
617    // sed -n 'A,Bp'
618    for s in stages {
619        if cmd_of(s) == Some("sed")
620            && has_flag(s, "-n")
621            && let Some((a, b)) = positionals(s).into_iter().find_map(parse_sed_range)
622        {
623            let file = positionals(s).into_iter().find(|&w| !is_sed_script(w));
624            return Some(view_steer(file, Some((a, b))));
625        }
626    }
627    // head / tail, reading a named file or fed by `cat FILE`.
628    for (i, s) in stages.iter().enumerate() {
629        let cmd = cmd_of(s);
630        if cmd != Some("head") && cmd != Some("tail") {
631            continue;
632        }
633        let n = head_count(s);
634        // The file is head/tail's own positional (not the numeric `-n` value),
635        // or an upstream `cat FILE`.
636        let own = positionals(s)
637            .into_iter()
638            .find(|w| w.parse::<u64>().is_err());
639        let upstream = (i > 0 && cmd_of(&stages[i - 1]) == Some("cat"))
640            .then(|| positionals(&stages[i - 1]).into_iter().next())
641            .flatten();
642        let file = own.or(upstream)?; // no concrete file → not a file read; skip
643        let range = match (cmd, n) {
644            (Some("head"), Some(n)) => Some((1, n)),
645            _ => None, // tail = last-N lines; leave the range to the agent
646        };
647        return Some(view_steer(Some(file), range));
648    }
649    None
650}
651
652/// `ls -R` / `tree` → `ct tree`.
653fn rule_list_recursive(stages: &[Vec<String>]) -> Option<Steer> {
654    let stage = stages
655        .iter()
656        .find(|s| cmd_of(s) == Some("tree") || (cmd_of(s) == Some("ls") && has_short(s, 'R')))?;
657    let base = positionals(stage).first().copied();
658    let suggestion = match base {
659        Some(b) => format!("ct tree --base {b}"),
660        None => "ct tree".to_string(),
661    };
662    Some(Steer {
663        rule_id: "list-recursive",
664        tool: "ct tree",
665        suggestion,
666        note: "ct tree reports the file tree with per-file line/word/char counts, filtering and sorting — a richer, bounded ls -R / tree",
667    })
668}
669
670/// `wc` over files (not a bare piped stream) → `ct tree`. Counts files named
671/// directly, fed by `find`/`ls`, or read from a `cat FILE…` upstream; a stream
672/// with no file behind it (e.g. `ps aux | wc -l`) is left alone.
673fn rule_count_lines(stages: &[Vec<String>]) -> Option<Steer> {
674    for (i, s) in stages.iter().enumerate() {
675        if cmd_of(s) != Some("wc") {
676            continue;
677        }
678        let has_files = !positionals(s).is_empty();
679        let upstream = i.checked_sub(1).map(|j| &stages[j]);
680        let from_find = upstream.is_some_and(|u| matches!(cmd_of(u), Some("find") | Some("ls")));
681        let from_cat =
682            upstream.is_some_and(|u| cmd_of(u) == Some("cat") && !positionals(u).is_empty());
683        if has_files || from_find || from_cat {
684            return Some(Steer {
685                rule_id: "count-lines",
686                tool: "ct tree",
687                suggestion: "ct tree --summary".to_string(),
688                note: "ct tree reports per-file and total line/word/char counts directly, replacing wc -l over a file set",
689            });
690        }
691    }
692    None
693}
694
695// ----- Extraction helpers ------------------------------------------------------
696
697/// Assemble a `ct search` suggestion from optional base/name/grep parts.
698fn search_suggestion(base: Option<&str>, name: Option<&str>, grep: Option<&str>) -> String {
699    let mut out = String::from("ct search");
700    if let Some(b) = base {
701        out.push_str(&format!(" --base {b}"));
702    }
703    if let Some(n) = name {
704        out.push_str(&format!(" --name {}", q(n)));
705    }
706    match grep {
707        Some(g) => out.push_str(&format!(" --grep {}", q(g))),
708        None => out.push_str(" --grep <pattern>"),
709    }
710    out
711}
712
713/// Build a `ct view` suggestion for a file and optional line range.
714fn view_steer(file: Option<&str>, range: Option<(u32, u32)>) -> Steer {
715    let f = file.unwrap_or("<file>");
716    let suggestion = match range {
717        Some((a, b)) => format!("ct view {f} --range {a}:{b}"),
718        None => format!("ct view {f} --range <start>:<end>"),
719    };
720    Steer {
721        rule_id: "read-range",
722        tool: "ct view",
723        suggestion,
724        note: "ct view shows a file's lines by range (or the regions around a pattern with context) — a precise, bounded read",
725    }
726}
727
728/// Build a `ct view` suggestion for an interpreter one-liner that reads `file`.
729fn interpreter_steer(file: Option<&str>) -> Steer {
730    let f = file.unwrap_or("<file>");
731    Steer {
732        rule_id: "interpreter-read",
733        tool: "ct view",
734        suggestion: format!("ct view {f} --range <start>:<end>"),
735        note: "an interpreter one-liner that reads a file is a bounded read — `ct view` shows a line range (or `--match <pat> --context N`), and `ct search <file> --grep <pat> --detail` finds the matching record, both without a hand-rolled parser",
736    }
737}
738
739/// Whether an inline interpreter script reads a file.
740fn reads_file(body: &str) -> bool {
741    const READS: &[&str] = &[
742        "open(",
743        "json.load",
744        "readlines",
745        "read_text",
746        "readFileSync",
747        "JSON.parse",
748        "File.read",
749        "IO.read",
750        "Get-Content",
751    ];
752    READS.iter().any(|m| body.contains(m))
753}
754
755/// Whether an inline interpreter script appears to write/produce a file — used
756/// to leave read+write one-liners alone (only pure reads are steered).
757fn writes_file(body: &str) -> bool {
758    const WRITES: &[&str] = &[
759        ",'w'",
760        ", 'w'",
761        ",\"w\"",
762        ", \"w\"",
763        ",'a'",
764        ", 'a'",
765        ",\"a\"",
766        "'r+'",
767        "\"r+\"",
768        "'wb'",
769        "\"wb\"",
770        ".write(",
771        "writeFile",
772        "json.dump",
773        "to_csv",
774        "to_json(",
775        "File.write",
776    ];
777    WRITES.iter().any(|m| body.contains(m))
778}
779
780/// The first quoted token in an interpreter body that looks like a file path
781/// (contains `.` or `/`), for use in the suggested `ct view` command.
782fn quoted_path(body: &str) -> Option<&str> {
783    let bytes = body.as_bytes();
784    let mut i = 0;
785    while i < bytes.len() {
786        let c = bytes[i];
787        if (c == b'\'' || c == b'"')
788            && let Some(rel) = body[i + 1..].find(c as char)
789        {
790            let inner = &body[i + 1..i + 1 + rel];
791            if inner.contains('.') || inner.contains('/') {
792                return Some(inner);
793            }
794            i += 1 + rel + 1;
795            continue;
796        }
797        i += 1;
798    }
799    None
800}
801
802/// The PATTERN of a grep-family stage: an explicit `-e VALUE`, else the first
803/// bare word *after the grep token* (which may follow `xargs`, `-exec`, …, so
804/// keying off the stage's own command word would pick up the wrong thing).
805fn grep_pattern(stage: &[String]) -> Option<&str> {
806    if let Some(v) = flag_value(stage, &["-e", "--regexp"]) {
807        return Some(v);
808    }
809    let start = stage
810        .iter()
811        .position(|w| {
812            matches!(
813                base_name(w),
814                "grep" | "egrep" | "fgrep" | "rg" | "ripgrep" | "ag"
815            )
816        })
817        .map_or(1, |i| i + 1);
818    stage[start..]
819        .iter()
820        .find(|w| !w.starts_with('-'))
821        .map(String::as_str)
822}
823
824/// Parse `s/FIND/REPLACE/flags` (any single-char delimiter) → (find, replace).
825fn sed_subst(stage: &[String]) -> (Option<&str>, Option<&str>) {
826    for w in stage.iter().skip(1) {
827        if let Some(rest) = w.strip_prefix('s')
828            && let Some(delim) = rest.chars().next()
829            && !delim.is_alphanumeric()
830        {
831            let parts: Vec<&str> = rest[delim.len_utf8()..].split(delim).collect();
832            if parts.len() >= 2 {
833                return (Some(parts[0]), Some(parts[1]));
834            }
835        }
836    }
837    (None, None)
838}
839
840/// The N from a `head`/`tail` count flag: `-n N`, `-nN`, or `-N`.
841fn head_count(stage: &[String]) -> Option<u32> {
842    if let Some(v) = flag_value(stage, &["-n", "--lines"])
843        && let Ok(n) = v.parse::<u32>()
844    {
845        return Some(n);
846    }
847    stage
848        .iter()
849        .skip(1)
850        .find_map(|w| w.strip_prefix('-').and_then(|d| d.parse::<u32>().ok()))
851}
852
853/// Whether a word is a `sed` script (`A,Bp`, `Np`, or an `s<delim>…` subst)
854/// rather than a file. Deliberately narrow so filenames like `src/lib.rs`
855/// (which begin with `s`) are not misread as scripts.
856fn is_sed_script(w: &str) -> bool {
857    if parse_sed_range(w).is_some() {
858        return true;
859    }
860    let mut ch = w.chars();
861    ch.next() == Some('s') && ch.next().is_some_and(|d| !d.is_alphanumeric())
862}
863
864/// Parse a `sed -n` line range like `10,20p` or `10p` → `(start, end)`.
865fn parse_sed_range(w: &str) -> Option<(u32, u32)> {
866    let body = w.strip_suffix('p').unwrap_or(w);
867    match body.split_once(',') {
868        Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
869        None => {
870            let n = body.parse().ok()?;
871            Some((n, n))
872        }
873    }
874}
875
876// ----- Harness tool-envelope steers --------------------------------------------
877//
878// The hook can gate not just `Bash` but the harness's own `Grep` / `Glob` /
879// `Read` tools — the *other* channel by which an agent reaches around `ct`.
880// Those calls carry structured fields (a `pattern`, a `path`, a `file_path`)
881// rather than a shell line, so each gets its own builder rather than going
882// through [`analyze`].
883
884/// Steer a harness `Grep` call to `ct search` (the suite's content search).
885pub fn grep_steer(pattern: &str, path: Option<&str>, glob: Option<&str>) -> Steer {
886    Steer {
887        rule_id: "harness-grep",
888        tool: "ct search",
889        suggestion: search_suggestion(path, glob, Some(pattern)),
890        note: "ct search is the suite's content search — recursive, filtered by name/type/size, with a framed --expect verdict; ct outline maps a file's symbols when you are after a definition",
891    }
892}
893
894/// Steer a harness `Glob` call to `ct search` (name filter from a root).
895pub fn glob_steer(pattern: &str, path: Option<&str>) -> Steer {
896    let (glob_base, name) = split_glob(pattern);
897    let base = path.map(str::to_string).or(glob_base);
898    let mut out = String::from("ct search");
899    if let Some(b) = base {
900        out.push_str(&format!(" --base {b}"));
901    }
902    out.push_str(&format!(" --name {} --type f", q(&name)));
903    Steer {
904        rule_id: "harness-glob",
905        tool: "ct search",
906        suggestion: out,
907        note: "ct search selects files by --name/--type/--size from a chosen root and reports them — the suite's glob, recursive by default",
908    }
909}
910
911/// Split a glob into a literal directory prefix (its leading wildcard-free
912/// segments) and the file-name segment (its last component): `src/**/*.rs` →
913/// `(Some("src"), "*.rs")`; `**/*.rs` → `(None, "*.rs")`.
914fn split_glob(pattern: &str) -> (Option<String>, String) {
915    let segs: Vec<&str> = pattern.split('/').collect();
916    let name = segs.last().copied().unwrap_or(pattern).to_string();
917    let is_wild = |s: &str| s.contains(['*', '?', '[', '{']);
918    let literal: Vec<&str> = segs
919        .iter()
920        .take(segs.len().saturating_sub(1))
921        .take_while(|s| !is_wild(s) && !s.is_empty())
922        .copied()
923        .collect();
924    ((!literal.is_empty()).then(|| literal.join("/")), name)
925}
926
927/// Steer a harness `Read` call to `ct view` — unless the path is something
928/// `ct view` (a line reader) cannot render (an image, PDF, or notebook), where
929/// `Read` is the right tool and the call is left alone ([`None`]).
930pub fn read_steer(file_path: &str, offset: Option<i64>, limit: Option<i64>) -> Option<Steer> {
931    if is_unrenderable(file_path) {
932        return None;
933    }
934    // Read's offset is a 1-based start line and limit a line count; map both to
935    // `ct view --range`. A bare read (neither) views the whole file.
936    let range = match (offset, limit) {
937        (Some(o), Some(l)) => {
938            let start = o.max(1);
939            Some(format!("{start}:{}", (start + l - 1).max(start)))
940        }
941        (Some(o), None) => Some(format!("{}:", o.max(1))),
942        (None, Some(l)) => Some(format!("1:{}", l.max(1))),
943        (None, None) => None,
944    };
945    let suggestion = match range {
946        Some(r) => format!("ct view {file_path} --range {r}"),
947        None => format!("ct view {file_path}"),
948    };
949    Some(Steer {
950        rule_id: "harness-read",
951        tool: "ct view",
952        suggestion,
953        note: "ct view is the suite's bounded file reader — a line range, or --match with context (Read stays the tool for images, PDFs, and notebooks ct view cannot render)",
954    })
955}
956
957/// Whether a path's extension is a binary/rendered format `ct view` cannot
958/// usefully show as text — so a `Read` of it is left alone.
959fn is_unrenderable(path: &str) -> bool {
960    const EXTS: &[&str] = &[
961        "png", "jpg", "jpeg", "gif", "bmp", "webp", "ico", "tif", "tiff", "pdf", "ipynb",
962    ];
963    let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
964    path.contains('.') && EXTS.contains(&ext.as_str())
965}
966
967// ----- Hook protocol -----------------------------------------------------------
968
969/// The Claude Code `PreToolUse` hook protocol: turn a stdin envelope into a
970/// steering decision.
971pub mod hook {
972    use super::{Mode, Steer, analyze, glob_steer, grep_steer, read_steer};
973    use serde_json::{Value, json};
974
975    /// Build the `PreToolUse` decision JSON for a [`Steer`] under `mode`.
976    pub fn decision(steer: &Steer, mode: Mode) -> Value {
977        let reason = steer.reason();
978        match mode {
979            Mode::Deny => json!({"hookSpecificOutput": {
980                "hookEventName": "PreToolUse",
981                "permissionDecision": "deny",
982                "permissionDecisionReason": reason,
983            }}),
984            Mode::Ask => json!({"hookSpecificOutput": {
985                "hookEventName": "PreToolUse",
986                "permissionDecision": "ask",
987                "permissionDecisionReason": reason,
988            }}),
989            Mode::Warn => json!({"hookSpecificOutput": {
990                "hookEventName": "PreToolUse",
991                "additionalContext": reason,
992            }}),
993        }
994    }
995
996    /// A string field of a tool-input object.
997    fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> {
998        input.get(key).and_then(Value::as_str)
999    }
1000
1001    /// An integer field of a tool-input object.
1002    fn int_field(input: &Value, key: &str) -> Option<i64> {
1003        input.get(key).and_then(Value::as_i64)
1004    }
1005
1006    /// Classify one tool call — its `tool_name` and `tool_input` object — into
1007    /// the [`Steer`] that serves it, or [`None`] to allow. The `Bash` command is
1008    /// classified by [`analyze`]; the harness's own `Grep` / `Glob` / `Read`
1009    /// calls are steered from their structured fields. Shared by [`process`] and
1010    /// [`log_record`]; an unhandled tool or a missing field yields [`None`].
1011    pub fn classify(tool: &str, input: &Value) -> Option<Steer> {
1012        match tool {
1013            "Bash" => analyze(str_field(input, "command")?),
1014            "Grep" => Some(grep_steer(
1015                str_field(input, "pattern")?,
1016                str_field(input, "path"),
1017                str_field(input, "glob"),
1018            )),
1019            "Glob" => Some(glob_steer(
1020                str_field(input, "pattern")?,
1021                str_field(input, "path"),
1022            )),
1023            "Read" => read_steer(
1024                str_field(input, "file_path")?,
1025                int_field(input, "offset"),
1026                int_field(input, "limit"),
1027            ),
1028            _ => None,
1029        }
1030    }
1031
1032    /// Process a raw `PreToolUse` stdin envelope. Returns the decision JSON to
1033    /// print, or [`None`] to allow silently. **Fail-open:** any parse error, an
1034    /// unhandled tool, or a missing field all yield [`None`].
1035    pub fn process(envelope: &str, mode: Mode) -> Option<Value> {
1036        let v: Value = serde_json::from_str(envelope).ok()?;
1037        let tool = v.get("tool_name").and_then(Value::as_str)?;
1038        let input = v.get("tool_input")?;
1039        let steer = classify(tool, input)?;
1040        Some(decision(&steer, mode))
1041    }
1042
1043    /// Build a structured log record for one `PreToolUse` envelope: which tool
1044    /// ran, its `Bash` command, the call's `cwd`/`session_id`, and what the hook
1045    /// decided under `mode`. Unlike [`process`] this also records the silent
1046    /// **allows** — the raw material for spotting shell idioms that *should* have
1047    /// been steered to `ct` but currently are not. Lenient: a malformed envelope
1048    /// still yields a record of what could be read. No timestamp is stamped here,
1049    /// so the record stays deterministic; the caller adds the time and appends it
1050    /// as one JSONL line.
1051    pub fn log_record(envelope: &str, mode: Mode) -> Value {
1052        let v: Value = serde_json::from_str(envelope).unwrap_or(Value::Null);
1053        let tool = v.get("tool_name").and_then(Value::as_str).unwrap_or("");
1054        let input = v.get("tool_input").cloned().unwrap_or(Value::Null);
1055        let (decision, rule_id, ct_tool) = match classify(tool, &input) {
1056            Some(s) => (mode.name(), Some(s.rule_id), Some(s.tool)),
1057            None => ("allow", None, None),
1058        };
1059        json!({
1060            "tool": tool,
1061            "command": input.get("command").and_then(Value::as_str),
1062            "cwd": v.get("cwd").and_then(Value::as_str),
1063            "session_id": v.get("session_id").and_then(Value::as_str),
1064            "decision": decision,
1065            "rule_id": rule_id,
1066            "ct_tool": ct_tool,
1067        })
1068    }
1069}
1070
1071// ----- Settings install --------------------------------------------------------
1072
1073/// Merging the steering hook into a Claude Code settings file. The merge runs
1074/// through the comment- and layout-preserving `ct-patch` engine
1075/// ([`crate::patch`]): the existing file is parsed only to *decide* which edits
1076/// to make, and those edits are byte-range splices against the original text,
1077/// so the user's comments and formatting survive.
1078pub mod install {
1079    use super::Mode;
1080    use crate::patch::{self, Op, parse_path};
1081    use serde_json::{Value, json};
1082    use std::path::{Path, PathBuf};
1083
1084    /// Which settings file the hook is written to.
1085    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086    pub enum Scope {
1087        /// `.claude/settings.json` (shared, committed).
1088        Project,
1089        /// `.claude/settings.local.json` (personal, gitignored).
1090        Local,
1091        /// `~/.claude/settings.json` (all projects).
1092        User,
1093    }
1094
1095    impl Scope {
1096        /// Parse the `--scope` value.
1097        pub fn from_name(s: &str) -> Option<Scope> {
1098            match s {
1099                "project" => Some(Scope::Project),
1100                "local" => Some(Scope::Local),
1101                "user" => Some(Scope::User),
1102                _ => None,
1103            }
1104        }
1105
1106        /// The settings file path. `project`/`local` are relative to `root`
1107        /// (the project directory); `user` lives under `home`.
1108        pub fn path(self, root: &Path, home: &Path) -> PathBuf {
1109            match self {
1110                Scope::Project => root.join(".claude").join("settings.json"),
1111                Scope::Local => root.join(".claude").join("settings.local.json"),
1112                Scope::User => home.join(".claude").join("settings.json"),
1113            }
1114        }
1115    }
1116
1117    /// A harness tool the steering hook can be installed to gate. Each becomes
1118    /// its own `PreToolUse` matcher entry; `Bash` is the default.
1119    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1120    pub enum Tool {
1121        /// Shell commands — classified by the full shell-idiom matcher.
1122        Bash,
1123        /// The harness content search → `ct search`.
1124        Grep,
1125        /// The harness file glob → `ct search`.
1126        Glob,
1127        /// The harness file read → `ct view` (images/PDF/notebooks pass through).
1128        Read,
1129        /// Every tool (a `*` matcher) — full-coverage logging; the hook still only
1130        /// steers the recognised idioms and passes everything else through.
1131        All,
1132    }
1133
1134    impl Tool {
1135        /// Parse a `--tools` value.
1136        pub fn from_name(s: &str) -> Option<Tool> {
1137            match s {
1138                "Bash" => Some(Tool::Bash),
1139                "Grep" => Some(Tool::Grep),
1140                "Glob" => Some(Tool::Glob),
1141                "Read" => Some(Tool::Read),
1142                "all" | "*" => Some(Tool::All),
1143                _ => None,
1144            }
1145        }
1146
1147        /// The `matcher` string this tool is written under in settings.
1148        pub fn matcher(self) -> &'static str {
1149            match self {
1150                Tool::Bash => "Bash",
1151                Tool::Grep => "Grep",
1152                Tool::Glob => "Glob",
1153                Tool::Read => "Read",
1154                Tool::All => "*",
1155            }
1156        }
1157    }
1158
1159    /// The hook command string written into settings for `mode`. Tool-call
1160    /// logging is on by default (to `.ct/tclog/`), so the bare command already
1161    /// logs; `no_log` bakes in `--no-log` to disable it, and `log_dir` bakes in a
1162    /// `--log-dir PATH` override. A path with whitespace is double-quoted to
1163    /// survive the shell the hook runs under.
1164    pub fn hook_command(mode: Mode, log_dir: Option<&str>, no_log: bool) -> String {
1165        let mut cmd = match mode {
1166            Mode::Deny => "ct steer hook".to_string(),
1167            other => format!("ct steer hook --mode {}", other.name()),
1168        };
1169        if no_log {
1170            cmd.push_str(" --no-log");
1171        } else if let Some(path) = log_dir {
1172            let quoted = if path.chars().any(char::is_whitespace) {
1173                format!("\"{path}\"")
1174            } else {
1175                path.to_string()
1176            };
1177            cmd.push_str(&format!(" --log-dir {quoted}"));
1178        }
1179        cmd
1180    }
1181
1182    /// Whether a settings hook command is one of ours (any mode).
1183    fn is_steer_command(s: &str) -> bool {
1184        s.contains("steer") && s.contains("hook")
1185    }
1186
1187    /// Parse existing settings text (JSONC tolerated) for read-only inspection.
1188    /// The actual mutation is a byte-splice on the original text via `ct-patch`,
1189    /// so this serde view is only used to *decide* which edits to make.
1190    fn inspect(text: &str) -> Result<Value, String> {
1191        let root = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
1192            .map_err(|e| format!("parse settings: {e}"))?
1193            .unwrap_or_else(|| json!({}));
1194        if !root.is_object() {
1195            return Err("settings root must be a JSON object".to_string());
1196        }
1197        Ok(root)
1198    }
1199
1200    /// The canonical full settings document, used only when there is no existing
1201    /// file to merge into (so there are no comments or layout to preserve). One
1202    /// `PreToolUse` matcher entry per requested tool.
1203    fn canonical(command: &str, tools: &[Tool]) -> String {
1204        let matchers: Vec<Value> = tools
1205            .iter()
1206            .map(|t| {
1207                json!({ "matcher": t.matcher(), "hooks": [ { "type": "command", "command": command } ] })
1208            })
1209            .collect();
1210        let v = json!({ "hooks": { "PreToolUse": matchers } });
1211        serde_json::to_string_pretty(&v).unwrap() + "\n"
1212    }
1213
1214    fn op_set(path: &str, value: String) -> Result<Op, String> {
1215        Ok(Op::Set {
1216            path: parse_path(path)?,
1217            raw: path.to_string(),
1218            value,
1219        })
1220    }
1221    fn op_add(path: &str, value: String) -> Result<Op, String> {
1222        Ok(Op::Add {
1223            path: parse_path(path)?,
1224            raw: path.to_string(),
1225            value,
1226        })
1227    }
1228    fn op_delete(path: &str) -> Result<Op, String> {
1229        Ok(Op::Delete {
1230            path: parse_path(path)?,
1231            raw: path.to_string(),
1232        })
1233    }
1234
1235    /// Apply a computed op sequence to the original text via the comment- and
1236    /// layout-preserving `ct-patch` engine.
1237    fn apply(text: &str, ops: &[Op]) -> Result<(String, bool), String> {
1238        if ops.is_empty() {
1239            return Ok((text.to_string(), false));
1240        }
1241        let (out, changes) =
1242            patch::apply_doc(text, ops).map_err(|e| format!("settings merge: {e}"))?;
1243        Ok((out, changes > 0))
1244    }
1245
1246    /// Install the steering hook into `existing` settings text (or create a
1247    /// fresh document), gating each tool in `tools`. Returns the new text and
1248    /// whether it changed. Idempotent: re-installing the same command/tools is a
1249    /// no-op; a `--mode` change rewrites the command in place; a new tool adds
1250    /// its matcher. Comments and layout in `existing` are preserved.
1251    pub fn install(
1252        existing: Option<&str>,
1253        command: &str,
1254        tools: &[Tool],
1255    ) -> Result<(String, bool), String> {
1256        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1257            return Ok((canonical(command, tools), true));
1258        };
1259        let root = inspect(text)?;
1260        let ops = install_ops(&root, command, tools)?;
1261        apply(text, &ops)
1262    }
1263
1264    /// Remove every steering hook from `existing` settings text, pruning emptied
1265    /// matcher entries (and the `PreToolUse`/`hooks` containers when they end up
1266    /// empty). Comments and layout elsewhere are preserved.
1267    pub fn uninstall(existing: Option<&str>) -> Result<(String, bool), String> {
1268        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1269            return Ok((existing.unwrap_or_default().to_string(), false));
1270        };
1271        let root = inspect(text)?;
1272        let ops = uninstall_ops(&root)?;
1273        apply(text, &ops)
1274    }
1275
1276    /// The `hooks.PreToolUse` array, if present and well-shaped.
1277    fn pre_array(root: &Value) -> Option<&Vec<Value>> {
1278        root.get("hooks")
1279            .and_then(|h| h.get("PreToolUse"))
1280            .and_then(Value::as_array)
1281    }
1282
1283    /// Whether an array element is a `"matcher": <name>` entry.
1284    fn is_matcher(entry: &Value, name: &str) -> bool {
1285        entry.get("matcher").and_then(Value::as_str) == Some(name)
1286    }
1287
1288    /// Whether a matcher entry already carries one of our steer hooks.
1289    fn entry_has_steer(entry: &Value) -> bool {
1290        entry
1291            .get("hooks")
1292            .and_then(Value::as_array)
1293            .is_some_and(|l| {
1294                l.iter().any(|h| {
1295                    h.get("command")
1296                        .and_then(Value::as_str)
1297                        .is_some_and(is_steer_command)
1298                })
1299            })
1300    }
1301
1302    /// Compute the ops that install `command` for every tool in `tools`, given
1303    /// the parsed `root`. Indices come from the original parse and stay valid
1304    /// because every op only adds keys/elements, never reorders entries.
1305    fn install_ops(root: &Value, command: &str, tools: &[Tool]) -> Result<Vec<Op>, String> {
1306        let mut ops = Vec::new();
1307
1308        // Ensure the `hooks` / `hooks.PreToolUse` containers exist.
1309        let hooks = root.get("hooks");
1310        match hooks {
1311            None => ops.push(op_set(".hooks", "{}".to_string())?),
1312            Some(h) if !h.is_object() => {
1313                return Err("settings `hooks` must be an object".to_string());
1314            }
1315            Some(_) => {}
1316        }
1317        let pre = hooks.and_then(|h| h.get("PreToolUse"));
1318        match pre {
1319            None => ops.push(op_set(".hooks.PreToolUse", "[]".to_string())?),
1320            Some(p) if !p.is_array() => {
1321                return Err("settings `hooks.PreToolUse` must be an array".to_string());
1322            }
1323            Some(_) => {}
1324        }
1325        let pre_arr = pre.and_then(Value::as_array);
1326
1327        // Mode change: rewrite the command of any existing steer hook that differs.
1328        if let Some(arr) = pre_arr {
1329            for (ei, entry) in arr.iter().enumerate() {
1330                let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1331                    continue;
1332                };
1333                for (hi, h) in list.iter().enumerate() {
1334                    if let Some(c) = h.get("command").and_then(Value::as_str)
1335                        && is_steer_command(c)
1336                        && c != command
1337                    {
1338                        ops.push(op_set(
1339                            &format!(".hooks.PreToolUse[{ei}].hooks[{hi}].command"),
1340                            json!(command).to_string(),
1341                        )?);
1342                    }
1343                }
1344            }
1345        }
1346
1347        // Per requested tool: ensure a matcher for it carries our command.
1348        let hook_obj = json!({ "type": "command", "command": command }).to_string();
1349        for tool in tools {
1350            let name = tool.matcher();
1351            // Already steered for this tool (mode change handled above)?
1352            if pre_arr.is_some_and(|arr| {
1353                arr.iter()
1354                    .any(|e| is_matcher(e, name) && entry_has_steer(e))
1355            }) {
1356                continue;
1357            }
1358            // Append to an existing matcher entry for this tool, else add one.
1359            let target =
1360                pre_arr.and_then(|arr| arr.iter().enumerate().find(|(_, e)| is_matcher(e, name)));
1361            match target {
1362                Some((ei, e)) if e.get("hooks").and_then(Value::as_array).is_some() => {
1363                    ops.push(op_add(
1364                        &format!(".hooks.PreToolUse[{ei}].hooks"),
1365                        hook_obj.clone(),
1366                    )?);
1367                }
1368                Some((ei, _)) => {
1369                    ops.push(op_set(
1370                        &format!(".hooks.PreToolUse[{ei}].hooks"),
1371                        format!("[{hook_obj}]"),
1372                    )?);
1373                }
1374                None => {
1375                    let matcher = json!({ "matcher": name, "hooks": [ { "type": "command", "command": command } ] })
1376                        .to_string();
1377                    ops.push(op_add(".hooks.PreToolUse", matcher)?);
1378                }
1379            }
1380        }
1381        Ok(ops)
1382    }
1383
1384    /// Compute the ops that remove every steering hook, given the parsed `root`.
1385    fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
1386        let Some(pre) = pre_array(root) else {
1387            return Ok(vec![]);
1388        };
1389        // Per matcher entry: which of its hooks are ours, and whether removing
1390        // them empties the entry.
1391        let mut whole_entries = Vec::new(); // entry indices to delete outright
1392        let mut partial = Vec::new(); // (entry index, our hook indices)
1393        for (ei, entry) in pre.iter().enumerate() {
1394            let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1395                continue;
1396            };
1397            let ours: Vec<usize> = list
1398                .iter()
1399                .enumerate()
1400                .filter(|(_, h)| {
1401                    h.get("command")
1402                        .and_then(Value::as_str)
1403                        .is_some_and(is_steer_command)
1404                })
1405                .map(|(hi, _)| hi)
1406                .collect();
1407            if ours.is_empty() {
1408                continue;
1409            }
1410            if ours.len() == list.len() {
1411                whole_entries.push(ei);
1412            } else {
1413                partial.push((ei, ours));
1414            }
1415        }
1416        if whole_entries.is_empty() && partial.is_empty() {
1417            return Ok(vec![]);
1418        }
1419
1420        // Every entry removed outright and none surviving → the whole
1421        // PreToolUse goes (or `hooks` itself if PreToolUse was its only key).
1422        if partial.is_empty() && whole_entries.len() == pre.len() {
1423            let hooks_solo = root
1424                .get("hooks")
1425                .and_then(Value::as_object)
1426                .is_some_and(|o| o.len() == 1);
1427            let path = if hooks_solo {
1428                ".hooks"
1429            } else {
1430                ".hooks.PreToolUse"
1431            };
1432            return Ok(vec![op_delete(path)?]);
1433        }
1434
1435        let mut ops = Vec::new();
1436        // Inner-hook deletes first (descending index, so earlier indices stay
1437        // valid), then whole-entry deletes (descending, likewise). Inner deletes
1438        // never shift entry indices, and partial vs whole entries are disjoint.
1439        for (ei, his) in &partial {
1440            for hi in his.iter().rev() {
1441                ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}].hooks[{hi}]"))?);
1442            }
1443        }
1444        for ei in whole_entries.iter().rev() {
1445            ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}]"))?);
1446        }
1447        Ok(ops)
1448    }
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453    use super::install::{Scope, Tool, install, uninstall};
1454    use super::*;
1455    use std::path::Path;
1456
1457    /// Bash-only install, the common case in these tests.
1458    fn install_bash(existing: Option<&str>, command: &str) -> Result<(String, bool), String> {
1459        install(existing, command, &[Tool::Bash])
1460    }
1461
1462    fn tool(cmd: &str) -> Option<&'static str> {
1463        analyze(cmd).map(|s| s.tool)
1464    }
1465    fn rule(cmd: &str) -> Option<&'static str> {
1466        analyze(cmd).map(|s| s.rule_id)
1467    }
1468
1469    #[test]
1470    fn steers_high_confidence_idioms() {
1471        assert_eq!(
1472            tool("find . -name '*.rs' | xargs grep TODO"),
1473            Some("ct search")
1474        );
1475        assert_eq!(
1476            rule("find . -name '*.rs' | xargs grep TODO"),
1477            Some("find-grep")
1478        );
1479        assert_eq!(tool("grep -rn TODO src"), Some("ct search"));
1480        assert_eq!(tool("rg TODO src"), Some("ct search"));
1481        assert_eq!(tool("find src -name '*.rs'"), Some("ct search"));
1482        assert_eq!(tool("sed -i 's/foo/bar/g' src/x.rs"), Some("ct edit"));
1483        assert_eq!(tool("head -n 40 src/lib.rs"), Some("ct view"));
1484        assert_eq!(tool("cat src/lib.rs | head -n 20"), Some("ct view"));
1485        assert_eq!(tool("sed -n '10,20p' src/lib.rs"), Some("ct view"));
1486        assert_eq!(tool("ls -R src"), Some("ct tree"));
1487        assert_eq!(tool("wc -l src/lib.rs"), Some("ct tree"));
1488        assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1489        assert_eq!(
1490            rule("for f in a b; do grep -r x $f; done"),
1491            Some("shell-loop")
1492        );
1493    }
1494
1495    #[test]
1496    fn steers_wait_loops_to_await_not_each() {
1497        // a sleep-bearing poll/wait loop is a bounded wait → ct await
1498        assert_eq!(
1499            tool("for i in $(seq 1 900); do cat f; sleep 2; done"),
1500            Some("ct await")
1501        );
1502        assert_eq!(
1503            rule("for i in $(seq 1 900); do cat f; sleep 2; done"),
1504            Some("wait-loop")
1505        );
1506        assert_eq!(
1507            tool("while true; do check; sleep 5; done"),
1508            Some("ct await")
1509        );
1510        assert_eq!(
1511            tool("until curl -sf http://x; do sleep 3; done"),
1512            Some("ct await")
1513        );
1514        // a sleep-free loop stays a per-item map → ct each
1515        assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1516        assert_eq!(
1517            rule("for f in a b; do grep -r x $f; done"),
1518            Some("shell-loop")
1519        );
1520    }
1521
1522    #[test]
1523    fn steers_interpreter_file_reads() {
1524        // jq with a file argument reads a file → ct view
1525        assert_eq!(tool("jq '.note' feedback/x.jsonl"), Some("ct view"));
1526        assert_eq!(
1527            rule("jq '.note' feedback/x.jsonl"),
1528            Some("interpreter-read")
1529        );
1530        // python one-liner that opens a file and prints → ct view
1531        let s = analyze(
1532            "python -c \"rows=[json.loads(l) for l in open('feedback/x.jsonl')]; print(rows[-1])\"",
1533        )
1534        .unwrap();
1535        assert_eq!(s.tool, "ct view");
1536        assert!(
1537            s.suggestion.contains("feedback/x.jsonl"),
1538            "{}",
1539            s.suggestion
1540        );
1541        assert_eq!(
1542            tool("node -e 'const d=require(\"fs\").readFileSync(\"a.json\")'"),
1543            Some("ct view")
1544        );
1545        // pure-compute one-liner (no file read) is left alone
1546        assert!(analyze("python -c 'print(2+2)'").is_none());
1547        // a one-liner that writes is left alone
1548        assert!(analyze("python -c \"open('out.txt','w').write('hi')\"").is_none());
1549        // a jq fed by a pipe (no file) is left alone
1550        assert!(analyze("cat x | jq '.note'").is_none());
1551    }
1552
1553    #[test]
1554    fn steers_count_idioms() {
1555        // grep -c counts matching lines → ct search --summary
1556        assert_eq!(tool("grep -c TODO src/lib.rs"), Some("ct search"));
1557        assert_eq!(rule("grep -c TODO src/lib.rs"), Some("grep-count"));
1558        let s = analyze("grep -c TODO src/lib.rs").unwrap();
1559        assert!(s.suggestion.contains("--grep 'TODO'") && s.suggestion.contains("--summary"));
1560        // cat FILES | wc -l counts lines of real files → ct tree
1561        assert_eq!(tool("cat a.jsonl b.jsonl | wc -l"), Some("ct tree"));
1562        // a bare stream count has no file behind it → left alone
1563        assert!(analyze("ps aux | wc -l").is_none());
1564    }
1565
1566    #[test]
1567    fn extracts_obvious_slots() {
1568        let s = analyze("grep -rn TODO src").unwrap();
1569        assert!(s.suggestion.contains("--grep 'TODO'"), "{}", s.suggestion);
1570        let e = analyze("sed -i 's/foo/bar/g' f.rs").unwrap();
1571        assert!(
1572            e.suggestion.contains("--find 'foo'") && e.suggestion.contains("--replace 'bar'"),
1573            "{}",
1574            e.suggestion
1575        );
1576        let v = analyze("head -n 40 src/lib.rs").unwrap();
1577        assert!(
1578            v.suggestion.contains("src/lib.rs --range 1:40"),
1579            "{}",
1580            v.suggestion
1581        );
1582        // the grep pattern is taken after the `grep` token, not after `xargs`
1583        let fg = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
1584        assert!(fg.suggestion.contains("--grep 'TODO'"), "{}", fg.suggestion);
1585        assert!(fg.suggestion.contains("--name '*.rs'"), "{}", fg.suggestion);
1586    }
1587
1588    #[test]
1589    fn chain_only_when_all_segments_serviceable() {
1590        let s = analyze("grep -r foo src && sed -i 's/a/b/' f.rs").unwrap();
1591        assert_eq!(s.tool, "ct and");
1592        assert!(
1593            s.suggestion.starts_with("ct and search"),
1594            "{}",
1595            s.suggestion
1596        );
1597        assert!(s.suggestion.contains(":::"), "{}", s.suggestion);
1598        // a mixed chain (one non-ct segment) is left alone
1599        assert!(analyze("grep -r foo src && make").is_none());
1600    }
1601
1602    #[test]
1603    fn allows_safe_and_unknown_commands() {
1604        assert!(analyze("git status").is_none());
1605        assert!(analyze("cargo build && cargo test").is_none());
1606        assert!(analyze("ls -la").is_none());
1607        assert!(analyze("cat file.txt").is_none()); // whole-file read, not a range
1608        assert!(analyze("grep TODO file.rs").is_none()); // non-recursive, single file
1609        assert!(analyze("echo 'a | b && c'").is_none()); // operators inside quotes are inert
1610        assert!(analyze("ps aux | head -n 5").is_none()); // piped stream, no file
1611        assert!(analyze("").is_none());
1612    }
1613
1614    #[test]
1615    fn never_resteers_a_ct_command() {
1616        assert!(analyze("ct search --grep TODO").is_none());
1617        assert!(analyze("ct-search --grep TODO").is_none());
1618        assert!(analyze("find . -name '*.rs' | xargs ct-edit").is_none());
1619    }
1620
1621    #[test]
1622    fn hook_decisions_respect_mode() {
1623        let envelope = r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"}}"#;
1624        let deny = hook::process(envelope, Mode::Deny).unwrap();
1625        assert_eq!(deny["hookSpecificOutput"]["permissionDecision"], "deny");
1626        assert!(
1627            deny["hookSpecificOutput"]["permissionDecisionReason"]
1628                .as_str()
1629                .unwrap()
1630                .contains("ct search")
1631        );
1632        let ask = hook::process(envelope, Mode::Ask).unwrap();
1633        assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
1634        let warn = hook::process(envelope, Mode::Warn).unwrap();
1635        assert!(warn["hookSpecificOutput"]["additionalContext"].is_string());
1636        assert!(
1637            warn["hookSpecificOutput"]
1638                .get("permissionDecision")
1639                .is_none()
1640        );
1641    }
1642
1643    #[test]
1644    fn hook_steers_harness_grep_glob_read() {
1645        // Grep → ct search, carrying pattern / path / glob.
1646        let grep = hook::process(
1647            r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO","path":"src","glob":"*.rs"}}"#,
1648            Mode::Deny,
1649        )
1650        .unwrap();
1651        let reason = grep["hookSpecificOutput"]["permissionDecisionReason"]
1652            .as_str()
1653            .unwrap();
1654        assert!(reason.contains("ct search"), "{reason}");
1655        assert!(
1656            reason.contains("--grep 'TODO'") && reason.contains("--base src"),
1657            "{reason}"
1658        );
1659        assert!(reason.contains("--name '*.rs'"), "{reason}");
1660
1661        // Glob → ct search; a `dir/**/*.ext` glob splits into --base / --name.
1662        let s = glob_steer("src/**/*.rs", None);
1663        assert_eq!(s.tool, "ct search");
1664        assert!(s.suggestion.contains("--base src"), "{}", s.suggestion);
1665        assert!(s.suggestion.contains("--name '*.rs'"), "{}", s.suggestion);
1666
1667        // Read → ct view with a range derived from offset/limit.
1668        let read = read_steer("src/lib.rs", Some(10), Some(20)).unwrap();
1669        assert_eq!(read.tool, "ct view");
1670        assert!(
1671            read.suggestion.contains("ct view src/lib.rs --range 10:29"),
1672            "{}",
1673            read.suggestion
1674        );
1675        // a bare read views the whole file
1676        assert_eq!(
1677            read_steer("notes.md", None, None).unwrap().suggestion,
1678            "ct view notes.md"
1679        );
1680        // images / PDFs / notebooks are left for Read (None)
1681        assert!(read_steer("diagram.png", None, None).is_none());
1682        assert!(read_steer("paper.pdf", None, None).is_none());
1683        assert!(read_steer("nb.ipynb", None, None).is_none());
1684    }
1685
1686    #[test]
1687    fn install_covers_multiple_tools() {
1688        let (text, changed) =
1689            install(None, "ct steer hook", &[Tool::Bash, Tool::Grep, Tool::Read]).unwrap();
1690        assert!(changed);
1691        for m in ["\"Bash\"", "\"Grep\"", "\"Read\""] {
1692            assert!(text.contains(m), "missing matcher {m} in {text}");
1693        }
1694        // re-install with the same tools is a no-op
1695        let (_, again) = install(
1696            Some(&text),
1697            "ct steer hook",
1698            &[Tool::Bash, Tool::Grep, Tool::Read],
1699        )
1700        .unwrap();
1701        assert!(!again);
1702        // adding a tool to an existing install only appends its matcher
1703        let (grown, did) = install(Some(&text), "ct steer hook", &[Tool::Glob]).unwrap();
1704        assert!(did);
1705        assert!(grown.contains("\"Glob\""));
1706        assert_eq!(grown.matches("\"matcher\"").count(), 4);
1707        // uninstall clears every steer matcher we added
1708        let (cleared, _) = uninstall(Some(&grown)).unwrap();
1709        assert!(!cleared.contains("steer hook"));
1710    }
1711
1712    #[test]
1713    fn hook_fails_open() {
1714        assert!(hook::process("not json", Mode::Deny).is_none());
1715        assert!(hook::process(r#"{"tool_name":"Read"}"#, Mode::Deny).is_none());
1716        assert!(hook::process(r#"{"tool_name":"Bash","tool_input":{}}"#, Mode::Deny).is_none());
1717        assert!(
1718            hook::process(
1719                r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
1720                Mode::Deny
1721            )
1722            .is_none()
1723        );
1724    }
1725
1726    #[test]
1727    fn log_record_captures_steered_and_allowed_calls() {
1728        // A steered Bash command: the decision reflects the mode and the rule fires.
1729        let steered = hook::log_record(
1730            r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"},"cwd":"/work","session_id":"s1"}"#,
1731            Mode::Deny,
1732        );
1733        assert_eq!(steered["tool"], "Bash");
1734        assert_eq!(steered["command"], "grep -r TODO src");
1735        assert_eq!(steered["decision"], "deny");
1736        assert_eq!(steered["ct_tool"], "ct search");
1737        assert!(steered["rule_id"].is_string());
1738        assert_eq!(steered["cwd"], "/work");
1739        assert_eq!(steered["session_id"], "s1");
1740
1741        // An allowed command is still recorded — the missed-pattern raw material.
1742        let allowed = hook::log_record(
1743            r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
1744            Mode::Deny,
1745        );
1746        assert_eq!(allowed["decision"], "allow");
1747        assert!(allowed["rule_id"].is_null());
1748        assert!(allowed["ct_tool"].is_null());
1749        assert_eq!(allowed["command"], "git status");
1750
1751        // A non-shell tool the hook doesn't steer is logged as an allow.
1752        let other = hook::log_record(
1753            r#"{"tool_name":"Edit","tool_input":{"file_path":"a.rs"}}"#,
1754            Mode::Warn,
1755        );
1756        assert_eq!(other["tool"], "Edit");
1757        assert_eq!(other["decision"], "allow");
1758
1759        // Warn mode labels a steered call as "warn".
1760        let warned = hook::log_record(
1761            r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO"}}"#,
1762            Mode::Warn,
1763        );
1764        assert_eq!(warned["decision"], "warn");
1765    }
1766
1767    #[test]
1768    fn log_record_is_lenient_on_malformed_envelopes() {
1769        // Not JSON at all: an empty-tool allow record, never a panic.
1770        let bad = hook::log_record("not json", Mode::Deny);
1771        assert_eq!(bad["tool"], "");
1772        assert_eq!(bad["decision"], "allow");
1773        assert!(bad["command"].is_null());
1774    }
1775
1776    #[test]
1777    fn hook_command_bakes_logging_flags() {
1778        // Default: logging is on, so the bare command needs no flag.
1779        assert_eq!(
1780            install::hook_command(Mode::Deny, None, false),
1781            "ct steer hook"
1782        );
1783        // --no-log wins over a directory override.
1784        assert_eq!(
1785            install::hook_command(Mode::Warn, Some("/x"), true),
1786            "ct steer hook --mode warn --no-log"
1787        );
1788        // A directory override is baked as --log-dir.
1789        assert_eq!(
1790            install::hook_command(Mode::Deny, Some("/var/log/tc"), false),
1791            "ct steer hook --log-dir /var/log/tc"
1792        );
1793        // A path with a space is quoted so the hook's shell keeps it one argument.
1794        assert_eq!(
1795            install::hook_command(Mode::Deny, Some("/my logs/tc"), false),
1796            "ct steer hook --log-dir \"/my logs/tc\""
1797        );
1798    }
1799
1800    #[test]
1801    fn date_stem_is_utc_civil_date() {
1802        assert_eq!(date_stem(0), "1970-01-01");
1803        assert_eq!(date_stem(86_399), "1970-01-01"); // same day, last second
1804        assert_eq!(date_stem(86_400), "1970-01-02"); // next day
1805        assert_eq!(date_stem(1_600_000_000), "2020-09-13");
1806        // A leap day resolves correctly.
1807        assert_eq!(date_stem(1_582_934_400), "2020-02-29");
1808    }
1809
1810    #[test]
1811    fn gitignore_rule_is_added_once() {
1812        assert_eq!(gitignore_with_log_rule(None).as_deref(), Some("*log\n"));
1813        assert!(gitignore_with_log_rule(Some("*log\n")).is_none());
1814        // Appended to existing rules, and a missing trailing newline is repaired.
1815        assert_eq!(
1816            gitignore_with_log_rule(Some("target")).as_deref(),
1817            Some("target\n*log\n")
1818        );
1819    }
1820
1821    #[test]
1822    fn install_all_tools_writes_a_wildcard_matcher() {
1823        let (text, changed) = install(None, "ct steer hook", &[install::Tool::All]).unwrap();
1824        assert!(changed);
1825        assert!(text.contains("\"matcher\": \"*\""), "{text}");
1826        // uninstall still clears it (it scans by command, not matcher name).
1827        let (cleared, _) = uninstall(Some(&text)).unwrap();
1828        assert!(!cleared.contains("steer hook"));
1829    }
1830
1831    #[test]
1832    fn install_is_idempotent_and_preserves_other_settings() {
1833        // fresh install
1834        let (text, changed) = install_bash(None, "ct steer hook").unwrap();
1835        assert!(changed);
1836        assert!(text.contains("PreToolUse"));
1837        assert!(text.contains("\"matcher\": \"Bash\""));
1838        assert!(text.contains("ct steer hook"));
1839        // re-install is a no-op
1840        let (text2, changed2) = install_bash(Some(&text), "ct steer hook").unwrap();
1841        assert!(!changed2);
1842        assert_eq!(text, text2);
1843        // a mode change rewrites in place (still one hook)
1844        let (text3, changed3) = install_bash(Some(&text), "ct steer hook --mode ask").unwrap();
1845        assert!(changed3);
1846        assert_eq!(text3.matches("steer hook").count(), 1);
1847        // existing unrelated settings survive
1848        let existing = r#"{ "model": "opus", "hooks": { "PreToolUse": [] } }"#;
1849        let (merged, _) = install_bash(Some(existing), "ct steer hook").unwrap();
1850        assert!(merged.contains("\"model\": \"opus\""));
1851    }
1852
1853    #[test]
1854    fn uninstall_removes_only_our_hook() {
1855        let existing = r#"{
1856            "hooks": { "PreToolUse": [
1857                { "matcher": "Bash", "hooks": [
1858                    { "type": "command", "command": "ct steer hook" },
1859                    { "type": "command", "command": "./other.sh" }
1860                ] }
1861            ] }
1862        }"#;
1863        let (text, changed) = uninstall(Some(existing)).unwrap();
1864        assert!(changed);
1865        assert!(!text.contains("steer hook"));
1866        assert!(text.contains("./other.sh")); // the unrelated hook stays
1867        // uninstall on a clean file is a no-op
1868        let (_, changed2) = uninstall(Some("{}")).unwrap();
1869        assert!(!changed2);
1870    }
1871
1872    #[test]
1873    fn install_and_uninstall_preserve_comments() {
1874        // a settings.json with comments the user cares about
1875        let existing = "{\n  \
1876            // pin the model\n  \
1877            \"model\": \"opus\", // do not change\n  \
1878            \"hooks\": {\n    \
1879            \"PreToolUse\": [\n      \
1880            { \"matcher\": \"Bash\", \"hooks\": [ { \"type\": \"command\", \"command\": \"./guard.sh\" } ] }\n    \
1881            ]\n  }\n}\n";
1882        let (installed, changed) = install_bash(Some(existing), "ct steer hook").unwrap();
1883        assert!(changed);
1884        // comments survive the merge
1885        assert!(installed.contains("// pin the model"), "{installed}");
1886        assert!(installed.contains("// do not change"), "{installed}");
1887        // the prior hook is untouched and ours is appended to the same matcher
1888        assert!(installed.contains("./guard.sh"), "{installed}");
1889        assert!(installed.contains("ct steer hook"), "{installed}");
1890
1891        // uninstall removes only our hook, keeps the guard and the comments
1892        let (removed, changed2) = uninstall(Some(&installed)).unwrap();
1893        assert!(changed2);
1894        assert!(removed.contains("// pin the model"), "{removed}");
1895        assert!(removed.contains("./guard.sh"), "{removed}");
1896        assert!(!removed.contains("steer hook"), "{removed}");
1897    }
1898
1899    #[test]
1900    fn scope_paths() {
1901        let root = Path::new("/proj");
1902        let home = Path::new("/home/u");
1903        assert!(
1904            Scope::Project
1905                .path(root, home)
1906                .ends_with(".claude/settings.json")
1907        );
1908        assert!(
1909            Scope::Local
1910                .path(root, home)
1911                .ends_with(".claude/settings.local.json")
1912        );
1913        assert!(Scope::User.path(root, home).starts_with("/home/u"));
1914    }
1915}