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 or multi-line scriptlet. [`None`] means "allow" — no
362/// `ct` tool clearly serves it. A single command runs the high-confidence idiom
363/// matcher ([`analyze_one`]); a **multi-line scriptlet** is classified line by
364/// line ([`analyze_script`]) so a hand-sequenced series of ct-serviceable steps
365/// is steered toward one shell-less `ct and` chain. Never re-steers a command
366/// that already invokes `ct`.
367///
368/// ```
369/// use coding_tools::steer::analyze;
370/// let s = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
371/// assert_eq!(s.tool, "ct search");
372/// assert!(analyze("cargo build && cargo test").is_none());
373/// assert!(analyze("ct search --grep TODO").is_none());
374/// ```
375///
376/// A multi-line scriptlet whose every meaningful step is `ct` or ct-advisable
377/// (and not all are `ct` yet) folds into a single `ct and A ::: B` chain; see the
378/// `folds_all_ct_or_advisable_scriptlet_into_one_chain` test.
379pub fn analyze(command: &str) -> Option<Steer> {
380    // Split into statements (joining bash line-continuations first), dropping
381    // blank and comment lines. One real statement → the single-command matcher;
382    // several → the scriptlet analyzer.
383    let stmts = statements(command);
384    let real: Vec<&str> = stmts
385        .iter()
386        .map(String::as_str)
387        .filter(|s| {
388            let t = s.trim();
389            !t.is_empty() && !t.starts_with('#')
390        })
391        .collect();
392    if real.len() <= 1 {
393        return analyze_one(real.first().copied().unwrap_or(command));
394    }
395    analyze_script(&real)
396}
397
398/// Split a command into statements: join bash line-continuations (`\` + newline),
399/// then break on newlines. Statement-internal `;`/`&&`/`|` are left for
400/// [`analyze_one`] to interpret.
401fn statements(command: &str) -> Vec<String> {
402    command
403        .replace("\\\r\n", "")
404        .replace("\\\n", "")
405        .lines()
406        .map(str::to_string)
407        .collect()
408}
409
410/// The role a single scriptlet line plays in [`analyze_script`].
411enum LineKind {
412    /// Blank, a comment, or shell scaffolding (`cd`, an assignment, `echo`, …).
413    Skip,
414    /// Already a `ct` call; the string is its `ct and` segment form (no `ct ` head).
415    Ct(String),
416    /// Raw shell with a `ct` equivalent (its steer).
417    Advisable(Steer),
418    /// A real command with no `ct` analogue.
419    Opaque,
420}
421
422/// Whether a leading word is a shell variable assignment (`NAME=value`).
423fn is_assignment(word: &str) -> bool {
424    match word.split_once('=') {
425        Some((name, _)) => {
426            !name.is_empty()
427                && name
428                    .chars()
429                    .next()
430                    .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
431                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
432        }
433        None => false,
434    }
435}
436
437/// The `ct and` segment form of an already-`ct` line: drop the leading `ct ` /
438/// `ct-` so it slots in after `ct and … :::`.
439fn ct_segment(line: &str) -> String {
440    let t = line.trim();
441    if let Some(rest) = t.strip_prefix("ct ") {
442        rest.trim_start().to_string()
443    } else if let Some(rest) = t.strip_prefix("ct-") {
444        rest.to_string()
445    } else {
446        t.to_string()
447    }
448}
449
450/// Classify one scriptlet line. Scaffolding (comments, `cd`, assignments, `echo`)
451/// is [`LineKind::Skip`]; an existing `ct` call is [`LineKind::Ct`]; otherwise the
452/// single-command matcher decides advisable vs. opaque.
453fn line_kind(line: &str) -> LineKind {
454    let t = line.trim();
455    if t.is_empty() || t.starts_with('#') {
456        return LineKind::Skip;
457    }
458    let toks = lex(t);
459    let (segs, _) = control_segments(&toks);
460    let first_word = segs
461        .first()
462        .map(|s| pipe_stages(s))
463        .and_then(|stages| stages.into_iter().next())
464        .and_then(|stage| stage.into_iter().next());
465    let Some(raw) = first_word else {
466        return LineKind::Skip;
467    };
468    if is_assignment(&raw) {
469        return LineKind::Skip;
470    }
471    let cmd = base_name(&raw);
472    if matches!(
473        cmd,
474        "cd" | "export" | "echo" | "pushd" | "popd" | "set" | "true" | ":"
475    ) {
476        return LineKind::Skip;
477    }
478    if cmd == "ct" || cmd.starts_with("ct-") {
479        return LineKind::Ct(ct_segment(t));
480    }
481    match analyze(t) {
482        Some(s) => LineKind::Advisable(s),
483        None => LineKind::Opaque,
484    }
485}
486
487/// Classify a multi-statement scriptlet. Feedback is tiered on how ct-ready the
488/// steps are:
489///
490/// * **compound** — every meaningful step is already `ct` or ct-advisable, and at
491///   least one is not yet `ct`: fold the whole thing into one `ct and` chain.
492/// * **per-line** — some steps are ct-advisable but others have no ct analogue:
493///   advise the ct forms individually (it can't fold whole).
494/// * a lone real step among scaffolding is steered on its own; anything else is
495///   left alone (all-`ct` already, or nothing serviceable).
496fn analyze_script(stmts: &[&str]) -> Option<Steer> {
497    let kinds: Vec<LineKind> = stmts.iter().map(|s| line_kind(s)).collect();
498    let meaningful = kinds
499        .iter()
500        .filter(|k| !matches!(k, LineKind::Skip))
501        .count();
502    if meaningful < 2 {
503        // A single real operation among setup lines: steer it if advisable.
504        return kinds.into_iter().find_map(|k| match k {
505            LineKind::Advisable(s) => Some(s),
506            _ => None,
507        });
508    }
509
510    let mut segments: Vec<String> = Vec::new();
511    let mut advisable: Vec<String> = Vec::new();
512    let mut opaque = 0usize;
513    for k in &kinds {
514        match k {
515            LineKind::Skip => {}
516            LineKind::Ct(seg) => segments.push(seg.clone()),
517            LineKind::Advisable(s) => {
518                segments.push(s.suggestion.trim_start_matches("ct ").to_string());
519                advisable.push(s.suggestion.clone());
520            }
521            LineKind::Opaque => opaque += 1,
522        }
523    }
524    if advisable.is_empty() {
525        return None; // nothing to steer (all already `ct`, or all opaque)
526    }
527    if opaque == 0 {
528        // Every step is ct or ct-advisable, and not all are ct yet → one chain.
529        return Some(Steer {
530            rule_id: "script-compound",
531            tool: "ct and",
532            suggestion: format!("ct and {}", segments.join(" ::: ")),
533            note: "these steps are one compound operation — run them as a single shell-less `ct and` chain (::: between segments): one atomic, verdict-gated call instead of a hand-sequenced multi-line script",
534        });
535    }
536    // Mixed: some steps have ct forms, others have no ct analogue.
537    Some(Steer {
538        rule_id: "script-lines",
539        tool: "ct",
540        suggestion: advisable.join("\n  "),
541        note: "several steps here have direct ct equivalents — use them instead of raw shell (other steps have no ct analogue, so the whole script can't fold into one `ct and`)",
542    })
543}
544
545/// A generic nudge against *any* shell pipeline that we could not map to a
546/// specific `ct` tool: prompt the agent to try harder to express it with `ct`,
547/// without a concrete rewrite. [`None`] unless the command contains a pipe, is
548/// not already a `ct` call, and [`analyze`] found no specific steer (so the two
549/// never both fire). Meant to be shown **warn-only** — it never denies.
550///
551/// ```
552/// use coding_tools::steer::pipeline_nudge;
553/// assert!(pipeline_nudge("ps aux | grep server").is_some()); // an unmapped pipe
554/// assert!(pipeline_nudge("git status").is_none());           // no pipe
555/// assert!(pipeline_nudge("ct search --grep x | head").is_none()); // already ct
556/// // A pipe with a specific steer is left to that rule, not the generic nudge.
557/// assert!(pipeline_nudge("find . -name '*.rs' | xargs grep TODO").is_none());
558/// ```
559pub fn pipeline_nudge(command: &str) -> Option<Steer> {
560    if analyze(command).is_some() {
561        return None; // a specific rule already serves it
562    }
563    let toks = lex(command);
564    let (segs, _) = control_segments(&toks);
565    let seg_stages: Vec<Vec<Vec<String>>> = segs.iter().map(|s| pipe_stages(s)).collect();
566    let touches_ct = seg_stages.iter().flatten().flatten().any(|w| {
567        let b = base_name(w);
568        b == "ct" || b.starts_with("ct-")
569    });
570    if touches_ct {
571        return None;
572    }
573    let has_pipe = seg_stages.iter().any(|stages| stages.len() > 1);
574    if !has_pipe {
575        return None;
576    }
577    Some(Steer {
578        rule_id: "pipeline",
579        tool: "ct",
580        suggestion: "reach for a single ct call (or a `ct and A ::: B` chain) instead of piping shell commands together".to_string(),
581        note: "shell pipelines are unbounded and silent on failure; try harder to express this with the ct tools (search/view/tree/edit/…) before falling back to a pipe",
582    })
583}
584
585/// Classify a single shell command (its pipes and `&&`/`||`/`;` control). The
586/// idiom matcher behind [`analyze`]; never re-steers a command already using `ct`.
587fn analyze_one(command: &str) -> Option<Steer> {
588    let toks = lex(command);
589    if toks.is_empty() {
590        return None;
591    }
592    let (segs, joiners) = control_segments(&toks);
593    let seg_stages: Vec<Vec<Vec<String>>> = segs.iter().map(|s| pipe_stages(s)).collect();
594
595    // Never re-steer a command that already involves `ct` / `ct-*` anywhere
596    // (as a command, or behind `xargs`/`env`/…). Erring toward allow here is
597    // safe — at worst we decline to steer a grep that merely mentions `ct-…`.
598    let touches_ct = seg_stages.iter().flatten().flatten().any(|w| {
599        let b = base_name(w);
600        b == "ct" || b.starts_with("ct-")
601    });
602    if touches_ct {
603        return None;
604    }
605
606    // Shell loops (`for`/`while`/`until`) — a control word starting the first
607    // segment. A loop whose body `sleep`s and re-probes is a bounded *wait*
608    // (steer to `ct await`); any other loop is a per-item map (`ct each`).
609    if let Some(first) = seg_stages
610        .first()
611        .and_then(|s| s.first())
612        .and_then(|s| cmd_of(s))
613        && matches!(first, "for" | "while" | "until")
614    {
615        let waits = seg_stages
616            .iter()
617            .flatten()
618            .flatten()
619            .any(|w| matches!(base_name(w), "sleep" | "usleep" | "Start-Sleep"));
620        return Some(if waits {
621            Steer {
622                rule_id: "wait-loop",
623                tool: "ct await",
624                suggestion: "ct await --timeout <SECS> --every <N> -- <probe-argv>".to_string(),
625                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`",
626            }
627        } else {
628            Steer {
629                rule_id: "shell-loop",
630                tool: "ct each",
631                suggestion: "ct each --items <a> <b> -- <cmd-template-with-{ITEM}>".to_string(),
632                note: "ct each runs a command template once per item with no shell and an aggregate --expect verdict",
633            }
634        });
635    }
636
637    // A single command (possibly with pipes): the common, high-value case.
638    if segs.len() == 1 {
639        return analyze_segment(&seg_stages[0]);
640    }
641
642    // A chain (`&&` / `||`): only steer when *every* segment is itself
643    // ct-serviceable and the joiners are uniform, so `ct and`/`ct or`
644    // reproduces it faithfully. A mixed chain (e.g. `grep -r x && make`) is
645    // left alone.
646    let matches: Vec<Steer> = seg_stages
647        .iter()
648        .filter_map(|st| analyze_segment(st))
649        .collect();
650    if matches.len() == segs.len() && !joiners.is_empty() {
651        if joiners.iter().all(|j| *j == Tok::And) {
652            return Some(chain_steer("ct and", &matches));
653        }
654        if joiners.iter().all(|j| *j == Tok::Or) {
655            return Some(chain_steer("ct or", &matches));
656        }
657    }
658    None
659}
660
661/// Build the chain suggestion from each segment's own `ct` suggestion, joined
662/// with the suite's shell-less `:::` separator.
663fn chain_steer(head: &'static str, parts: &[Steer]) -> Steer {
664    let body = parts
665        .iter()
666        .map(|p| p.suggestion.trim_start_matches("ct ").to_string())
667        .collect::<Vec<_>>()
668        .join(" ::: ");
669    let (rule_id, note) = if head == "ct and" {
670        (
671            "and-chain",
672            "ct and runs each step in turn, stopping at the first failure — a shell-less && with no quoting",
673        )
674    } else {
675        (
676            "or-chain",
677            "ct or runs each step in turn, stopping at the first success — a shell-less || with no quoting",
678        )
679    };
680    Steer {
681        rule_id,
682        tool: head,
683        suggestion: format!("{head} {body}"),
684        note,
685    }
686}
687
688/// Classify a single control segment (its pipeline stages). Rule order encodes
689/// priority: the most specific idiom wins.
690fn analyze_segment(stages: &[Vec<String>]) -> Option<Steer> {
691    rule_find_grep(stages)
692        .or_else(|| rule_grep_recursive(stages))
693        .or_else(|| rule_grep_count(stages))
694        .or_else(|| rule_sed_inplace(stages))
695        .or_else(|| rule_read_range(stages))
696        .or_else(|| rule_interpreter_read(stages))
697        .or_else(|| rule_find_files(stages))
698        .or_else(|| rule_list_recursive(stages))
699        .or_else(|| rule_count_lines(stages))
700}
701
702/// `find … | xargs grep` / `find … -exec grep` → `ct search`.
703fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
704    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
705    // grep appearing anywhere (its own stage, after xargs, or after -exec).
706    let grep_stage = stages
707        .iter()
708        .find(|s| s.iter().any(|w| base_name(w) == "grep"))?;
709    let glob = flag_value(find, &["-name", "-iname"]);
710    let pat = grep_pattern(grep_stage);
711    Some(Steer {
712        rule_id: "find-grep",
713        tool: "ct search",
714        suggestion: search_suggestion(find_base(find), glob, pat),
715        note: "ct search recurses, filters by name/type/size, and greps in one declarative pass — find | xargs grep in a single command",
716    })
717}
718
719/// `grep -r` / `rg` / `ag` → `ct search`.
720fn rule_grep_recursive(stages: &[Vec<String>]) -> Option<Steer> {
721    for s in stages {
722        let Some(cmd) = cmd_of(s) else { continue };
723        let recursive_grep =
724            cmd == "grep" && (has_short(s, 'r') || has_short(s, 'R') || has_flag(s, "--recursive"));
725        if recursive_grep || matches!(cmd, "rg" | "ripgrep" | "ag") {
726            let pat = grep_pattern(s);
727            // `grep -r PAT PATH` / `rg PAT PATH`: the second positional is the path.
728            let base = positionals(s).get(1).copied();
729            return Some(Steer {
730                rule_id: "grep-recursive",
731                tool: "ct search",
732                suggestion: search_suggestion(base, None, pat),
733                note: "ct search is the suite's recursive content search, with a framed --expect verdict (e.g. --expect none asserts absence)",
734            });
735        }
736    }
737    None
738}
739
740/// `grep -c PATTERN FILE` (count matching lines) → `ct search … --summary`.
741fn rule_grep_count(stages: &[Vec<String>]) -> Option<Steer> {
742    for s in stages {
743        let Some(cmd) = cmd_of(s) else { continue };
744        if matches!(cmd, "grep" | "egrep" | "fgrep") && has_short(s, 'c') {
745            // `grep -c PATTERN FILE`: the second positional is the path.
746            let base = positionals(s).get(1).copied();
747            return Some(Steer {
748                rule_id: "grep-count",
749                tool: "ct search",
750                suggestion: format!(
751                    "{} --summary",
752                    search_suggestion(base, None, grep_pattern(s))
753                ),
754                note: "ct search --summary reports the match count directly (and --expect +N|=N turns it into a pass/fail assertion), replacing grep -c",
755            });
756        }
757    }
758    None
759}
760
761/// An interpreter one-liner that READS a file — `jq EXPR FILE`,
762/// `python -c '…open("x")…'`, `node -e`, `perl -e`, `ruby -e` — with no write
763/// signal → `ct view` / `ct search`. Pure-compute one-liners (no file read) and
764/// anything that looks like it writes are left alone.
765fn rule_interpreter_read(stages: &[Vec<String>]) -> Option<Steer> {
766    for s in stages {
767        let Some(cmd) = cmd_of(s) else { continue };
768        // `jq EXPR FILE…`: a file argument means it reads a file, not a stream.
769        if cmd == "jq" {
770            if let Some(&file) = positionals(s).get(1) {
771                return Some(interpreter_steer(Some(file)));
772            }
773            continue;
774        }
775        // `python/node/perl/ruby -c|-e '<body>'`: inspect the inline script.
776        let interp = matches!(
777            cmd,
778            "python" | "python3" | "node" | "nodejs" | "perl" | "ruby"
779        );
780        if interp
781            && let Some(body) = flag_value(s, &["-c", "-e"])
782            && reads_file(body)
783            && !writes_file(body)
784        {
785            return Some(interpreter_steer(quoted_path(body)));
786        }
787    }
788    None
789}
790
791/// `find … -name` with no grep → `ct search` (name filter only).
792fn rule_find_files(stages: &[Vec<String>]) -> Option<Steer> {
793    let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
794    let glob = flag_value(find, &["-name", "-iname"])?;
795    let base = find_base(find);
796    Some(Steer {
797        rule_id: "find-files",
798        tool: "ct search",
799        suggestion: search_suggestion(base, Some(glob), None),
800        note: "ct search selects files by --name/--type/--size and reports them, replacing a bare find",
801    })
802}
803
804/// `sed -i` / `perl -i` → `ct edit`.
805fn rule_sed_inplace(stages: &[Vec<String>]) -> Option<Steer> {
806    let stage = stages.iter().find(|s| {
807        let cmd = cmd_of(s);
808        let sed_i =
809            cmd == Some("sed") && s.iter().any(|w| w.starts_with("-i") || w == "--in-place");
810        let perl_i = cmd == Some("perl") && s.iter().any(|w| w.starts_with("-i"));
811        sed_i || perl_i
812    })?;
813    let (find, replace) = sed_subst(stage);
814    let suggestion = match (find, replace) {
815        (Some(f), Some(r)) => format!(
816            "ct edit --base . --find {} --replace {} --expect =1 --dry-run",
817            q(f),
818            q(r)
819        ),
820        _ => "ct edit --base . --find <text> --replace <text> --expect =1 --dry-run".to_string(),
821    };
822    Some(Steer {
823        rule_id: "sed-inplace",
824        tool: "ct edit",
825        suggestion,
826        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",
827    })
828}
829
830/// `head`/`tail`/`sed -n 'A,Bp'` on a file → `ct view --range`.
831fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
832    // sed -n 'A,Bp'
833    for s in stages {
834        if cmd_of(s) == Some("sed")
835            && has_flag(s, "-n")
836            && let Some((a, b)) = positionals(s).into_iter().find_map(parse_sed_range)
837        {
838            let file = positionals(s).into_iter().find(|&w| !is_sed_script(w));
839            return Some(view_steer(file, Some((a, b))));
840        }
841    }
842    // head / tail, reading a named file or fed by `cat FILE`.
843    for (i, s) in stages.iter().enumerate() {
844        let cmd = cmd_of(s);
845        if cmd != Some("head") && cmd != Some("tail") {
846            continue;
847        }
848        let n = head_count(s);
849        // The file is head/tail's own positional (not the numeric `-n` value),
850        // or an upstream `cat FILE`.
851        let own = positionals(s)
852            .into_iter()
853            .find(|w| w.parse::<u64>().is_err());
854        let upstream = (i > 0 && cmd_of(&stages[i - 1]) == Some("cat"))
855            .then(|| positionals(&stages[i - 1]).into_iter().next())
856            .flatten();
857        let file = own.or(upstream)?; // no concrete file → not a file read; skip
858        let range = match (cmd, n) {
859            (Some("head"), Some(n)) => Some((1, n)),
860            _ => None, // tail = last-N lines; leave the range to the agent
861        };
862        return Some(view_steer(Some(file), range));
863    }
864    None
865}
866
867/// `ls -R` / `tree` → `ct tree`.
868fn rule_list_recursive(stages: &[Vec<String>]) -> Option<Steer> {
869    let stage = stages
870        .iter()
871        .find(|s| cmd_of(s) == Some("tree") || (cmd_of(s) == Some("ls") && has_short(s, 'R')))?;
872    let base = positionals(stage).first().copied();
873    let suggestion = match base {
874        Some(b) => format!("ct tree --base {b}"),
875        None => "ct tree".to_string(),
876    };
877    Some(Steer {
878        rule_id: "list-recursive",
879        tool: "ct tree",
880        suggestion,
881        note: "ct tree reports the file tree with per-file line/word/char counts, filtering and sorting — a richer, bounded ls -R / tree",
882    })
883}
884
885/// `wc` over files (not a bare piped stream) → `ct tree`. Counts files named
886/// directly, fed by `find`/`ls`, or read from a `cat FILE…` upstream; a stream
887/// with no file behind it (e.g. `ps aux | wc -l`) is left alone.
888fn rule_count_lines(stages: &[Vec<String>]) -> Option<Steer> {
889    for (i, s) in stages.iter().enumerate() {
890        if cmd_of(s) != Some("wc") {
891            continue;
892        }
893        let has_files = !positionals(s).is_empty();
894        let upstream = i.checked_sub(1).map(|j| &stages[j]);
895        let from_find = upstream.is_some_and(|u| matches!(cmd_of(u), Some("find") | Some("ls")));
896        let from_cat =
897            upstream.is_some_and(|u| cmd_of(u) == Some("cat") && !positionals(u).is_empty());
898        if has_files || from_find || from_cat {
899            return Some(Steer {
900                rule_id: "count-lines",
901                tool: "ct tree",
902                suggestion: "ct tree --summary".to_string(),
903                note: "ct tree reports per-file and total line/word/char counts directly, replacing wc -l over a file set",
904            });
905        }
906    }
907    None
908}
909
910// ----- Extraction helpers ------------------------------------------------------
911
912/// Assemble a `ct search` suggestion from optional base/name/grep parts.
913fn search_suggestion(base: Option<&str>, name: Option<&str>, grep: Option<&str>) -> String {
914    let mut out = String::from("ct search");
915    if let Some(b) = base {
916        out.push_str(&format!(" --base {b}"));
917    }
918    if let Some(n) = name {
919        out.push_str(&format!(" --name {}", q(n)));
920    }
921    match grep {
922        Some(g) => out.push_str(&format!(" --grep {}", q(g))),
923        None => out.push_str(" --grep <pattern>"),
924    }
925    out
926}
927
928/// Build a `ct view` suggestion for a file and optional line range.
929fn view_steer(file: Option<&str>, range: Option<(u32, u32)>) -> Steer {
930    let f = file.unwrap_or("<file>");
931    let suggestion = match range {
932        Some((a, b)) => format!("ct view {f} --range {a}:{b}"),
933        None => format!("ct view {f} --range <start>:<end>"),
934    };
935    Steer {
936        rule_id: "read-range",
937        tool: "ct view",
938        suggestion,
939        note: "ct view shows a file's lines by range (or the regions around a pattern with context) — a precise, bounded read",
940    }
941}
942
943/// Build a `ct view` suggestion for an interpreter one-liner that reads `file`.
944fn interpreter_steer(file: Option<&str>) -> Steer {
945    let f = file.unwrap_or("<file>");
946    Steer {
947        rule_id: "interpreter-read",
948        tool: "ct view",
949        suggestion: format!("ct view {f} --range <start>:<end>"),
950        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",
951    }
952}
953
954/// Whether an inline interpreter script reads a file.
955fn reads_file(body: &str) -> bool {
956    const READS: &[&str] = &[
957        "open(",
958        "json.load",
959        "readlines",
960        "read_text",
961        "readFileSync",
962        "JSON.parse",
963        "File.read",
964        "IO.read",
965        "Get-Content",
966    ];
967    READS.iter().any(|m| body.contains(m))
968}
969
970/// Whether an inline interpreter script appears to write/produce a file — used
971/// to leave read+write one-liners alone (only pure reads are steered).
972fn writes_file(body: &str) -> bool {
973    const WRITES: &[&str] = &[
974        ",'w'",
975        ", 'w'",
976        ",\"w\"",
977        ", \"w\"",
978        ",'a'",
979        ", 'a'",
980        ",\"a\"",
981        "'r+'",
982        "\"r+\"",
983        "'wb'",
984        "\"wb\"",
985        ".write(",
986        "writeFile",
987        "json.dump",
988        "to_csv",
989        "to_json(",
990        "File.write",
991    ];
992    WRITES.iter().any(|m| body.contains(m))
993}
994
995/// The first quoted token in an interpreter body that looks like a file path
996/// (contains `.` or `/`), for use in the suggested `ct view` command.
997fn quoted_path(body: &str) -> Option<&str> {
998    let bytes = body.as_bytes();
999    let mut i = 0;
1000    while i < bytes.len() {
1001        let c = bytes[i];
1002        if (c == b'\'' || c == b'"')
1003            && let Some(rel) = body[i + 1..].find(c as char)
1004        {
1005            let inner = &body[i + 1..i + 1 + rel];
1006            if inner.contains('.') || inner.contains('/') {
1007                return Some(inner);
1008            }
1009            i += 1 + rel + 1;
1010            continue;
1011        }
1012        i += 1;
1013    }
1014    None
1015}
1016
1017/// The PATTERN of a grep-family stage: an explicit `-e VALUE`, else the first
1018/// bare word *after the grep token* (which may follow `xargs`, `-exec`, …, so
1019/// keying off the stage's own command word would pick up the wrong thing).
1020fn grep_pattern(stage: &[String]) -> Option<&str> {
1021    if let Some(v) = flag_value(stage, &["-e", "--regexp"]) {
1022        return Some(v);
1023    }
1024    let start = stage
1025        .iter()
1026        .position(|w| {
1027            matches!(
1028                base_name(w),
1029                "grep" | "egrep" | "fgrep" | "rg" | "ripgrep" | "ag"
1030            )
1031        })
1032        .map_or(1, |i| i + 1);
1033    stage[start..]
1034        .iter()
1035        .find(|w| !w.starts_with('-'))
1036        .map(String::as_str)
1037}
1038
1039/// Parse `s/FIND/REPLACE/flags` (any single-char delimiter) → (find, replace).
1040fn sed_subst(stage: &[String]) -> (Option<&str>, Option<&str>) {
1041    for w in stage.iter().skip(1) {
1042        if let Some(rest) = w.strip_prefix('s')
1043            && let Some(delim) = rest.chars().next()
1044            && !delim.is_alphanumeric()
1045        {
1046            let parts: Vec<&str> = rest[delim.len_utf8()..].split(delim).collect();
1047            if parts.len() >= 2 {
1048                return (Some(parts[0]), Some(parts[1]));
1049            }
1050        }
1051    }
1052    (None, None)
1053}
1054
1055/// The N from a `head`/`tail` count flag: `-n N`, `-nN`, or `-N`.
1056fn head_count(stage: &[String]) -> Option<u32> {
1057    if let Some(v) = flag_value(stage, &["-n", "--lines"])
1058        && let Ok(n) = v.parse::<u32>()
1059    {
1060        return Some(n);
1061    }
1062    stage
1063        .iter()
1064        .skip(1)
1065        .find_map(|w| w.strip_prefix('-').and_then(|d| d.parse::<u32>().ok()))
1066}
1067
1068/// Whether a word is a `sed` script (`A,Bp`, `Np`, or an `s<delim>…` subst)
1069/// rather than a file. Deliberately narrow so filenames like `src/lib.rs`
1070/// (which begin with `s`) are not misread as scripts.
1071fn is_sed_script(w: &str) -> bool {
1072    if parse_sed_range(w).is_some() {
1073        return true;
1074    }
1075    let mut ch = w.chars();
1076    ch.next() == Some('s') && ch.next().is_some_and(|d| !d.is_alphanumeric())
1077}
1078
1079/// Parse a `sed -n` line range like `10,20p` or `10p` → `(start, end)`.
1080fn parse_sed_range(w: &str) -> Option<(u32, u32)> {
1081    let body = w.strip_suffix('p').unwrap_or(w);
1082    match body.split_once(',') {
1083        Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
1084        None => {
1085            let n = body.parse().ok()?;
1086            Some((n, n))
1087        }
1088    }
1089}
1090
1091// ----- Harness tool-envelope steers --------------------------------------------
1092//
1093// The hook can gate not just `Bash` but the harness's own `Grep` / `Glob` /
1094// `Read` tools — the *other* channel by which an agent reaches around `ct`.
1095// Those calls carry structured fields (a `pattern`, a `path`, a `file_path`)
1096// rather than a shell line, so each gets its own builder rather than going
1097// through [`analyze`].
1098
1099/// Steer a harness `Grep` call to `ct search` (the suite's content search).
1100pub fn grep_steer(pattern: &str, path: Option<&str>, glob: Option<&str>) -> Steer {
1101    Steer {
1102        rule_id: "harness-grep",
1103        tool: "ct search",
1104        suggestion: search_suggestion(path, glob, Some(pattern)),
1105        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",
1106    }
1107}
1108
1109/// Steer a harness `Glob` call to `ct search` (name filter from a root).
1110pub fn glob_steer(pattern: &str, path: Option<&str>) -> Steer {
1111    let (glob_base, name) = split_glob(pattern);
1112    let base = path.map(str::to_string).or(glob_base);
1113    let mut out = String::from("ct search");
1114    if let Some(b) = base {
1115        out.push_str(&format!(" --base {b}"));
1116    }
1117    out.push_str(&format!(" --name {} --type f", q(&name)));
1118    Steer {
1119        rule_id: "harness-glob",
1120        tool: "ct search",
1121        suggestion: out,
1122        note: "ct search selects files by --name/--type/--size from a chosen root and reports them — the suite's glob, recursive by default",
1123    }
1124}
1125
1126/// Split a glob into a literal directory prefix (its leading wildcard-free
1127/// segments) and the file-name segment (its last component): `src/**/*.rs` →
1128/// `(Some("src"), "*.rs")`; `**/*.rs` → `(None, "*.rs")`.
1129fn split_glob(pattern: &str) -> (Option<String>, String) {
1130    let segs: Vec<&str> = pattern.split('/').collect();
1131    let name = segs.last().copied().unwrap_or(pattern).to_string();
1132    let is_wild = |s: &str| s.contains(['*', '?', '[', '{']);
1133    let literal: Vec<&str> = segs
1134        .iter()
1135        .take(segs.len().saturating_sub(1))
1136        .take_while(|s| !is_wild(s) && !s.is_empty())
1137        .copied()
1138        .collect();
1139    ((!literal.is_empty()).then(|| literal.join("/")), name)
1140}
1141
1142/// Steer a harness `Read` call to `ct view` — unless the path is something
1143/// `ct view` (a line reader) cannot render (an image, PDF, or notebook), where
1144/// `Read` is the right tool and the call is left alone ([`None`]).
1145pub fn read_steer(file_path: &str, offset: Option<i64>, limit: Option<i64>) -> Option<Steer> {
1146    if is_unrenderable(file_path) {
1147        return None;
1148    }
1149    // Read's offset is a 1-based start line and limit a line count; map both to
1150    // `ct view --range`. A bare read (neither) views the whole file.
1151    let range = match (offset, limit) {
1152        (Some(o), Some(l)) => {
1153            let start = o.max(1);
1154            Some(format!("{start}:{}", (start + l - 1).max(start)))
1155        }
1156        (Some(o), None) => Some(format!("{}:", o.max(1))),
1157        (None, Some(l)) => Some(format!("1:{}", l.max(1))),
1158        (None, None) => None,
1159    };
1160    let suggestion = match range {
1161        Some(r) => format!("ct view {file_path} --range {r}"),
1162        None => format!("ct view {file_path}"),
1163    };
1164    Some(Steer {
1165        rule_id: "harness-read",
1166        tool: "ct view",
1167        suggestion,
1168        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)",
1169    })
1170}
1171
1172/// Whether a path's extension is a binary/rendered format `ct view` cannot
1173/// usefully show as text — so a `Read` of it is left alone.
1174fn is_unrenderable(path: &str) -> bool {
1175    const EXTS: &[&str] = &[
1176        "png", "jpg", "jpeg", "gif", "bmp", "webp", "ico", "tif", "tiff", "pdf", "ipynb",
1177    ];
1178    let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
1179    path.contains('.') && EXTS.contains(&ext.as_str())
1180}
1181
1182// ----- Hook protocol -----------------------------------------------------------
1183
1184/// The Claude Code `PreToolUse` hook protocol: turn a stdin envelope into a
1185/// steering decision.
1186pub mod hook {
1187    use super::{Mode, Steer, analyze, glob_steer, grep_steer, read_steer};
1188    use serde_json::{Value, json};
1189
1190    /// Build the `PreToolUse` decision JSON for a [`Steer`] under `mode`.
1191    pub fn decision(steer: &Steer, mode: Mode) -> Value {
1192        let reason = steer.reason();
1193        match mode {
1194            Mode::Deny => json!({"hookSpecificOutput": {
1195                "hookEventName": "PreToolUse",
1196                "permissionDecision": "deny",
1197                "permissionDecisionReason": reason,
1198            }}),
1199            Mode::Ask => json!({"hookSpecificOutput": {
1200                "hookEventName": "PreToolUse",
1201                "permissionDecision": "ask",
1202                "permissionDecisionReason": reason,
1203            }}),
1204            Mode::Warn => json!({"hookSpecificOutput": {
1205                "hookEventName": "PreToolUse",
1206                "additionalContext": reason,
1207            }}),
1208        }
1209    }
1210
1211    /// A string field of a tool-input object.
1212    fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> {
1213        input.get(key).and_then(Value::as_str)
1214    }
1215
1216    /// An integer field of a tool-input object.
1217    fn int_field(input: &Value, key: &str) -> Option<i64> {
1218        input.get(key).and_then(Value::as_i64)
1219    }
1220
1221    /// Classify one tool call — its `tool_name` and `tool_input` object — into
1222    /// the [`Steer`] that serves it, or [`None`] to allow. The `Bash` command is
1223    /// classified by [`analyze`]; the harness's own `Grep` / `Glob` / `Read`
1224    /// calls are steered from their structured fields. Shared by [`process`] and
1225    /// [`log_record`]; an unhandled tool or a missing field yields [`None`].
1226    pub fn classify(tool: &str, input: &Value) -> Option<Steer> {
1227        match tool {
1228            "Bash" => analyze(str_field(input, "command")?),
1229            "Grep" => Some(grep_steer(
1230                str_field(input, "pattern")?,
1231                str_field(input, "path"),
1232                str_field(input, "glob"),
1233            )),
1234            "Glob" => Some(glob_steer(
1235                str_field(input, "pattern")?,
1236                str_field(input, "path"),
1237            )),
1238            "Read" => read_steer(
1239                str_field(input, "file_path")?,
1240                int_field(input, "offset"),
1241                int_field(input, "limit"),
1242            ),
1243            _ => None,
1244        }
1245    }
1246
1247    /// Process a raw `PreToolUse` stdin envelope. Returns the decision JSON to
1248    /// print, or [`None`] to allow silently. **Fail-open:** any parse error, an
1249    /// unhandled tool, or a missing field all yield [`None`].
1250    pub fn process(envelope: &str, mode: Mode) -> Option<Value> {
1251        let v: Value = serde_json::from_str(envelope).ok()?;
1252        let tool = v.get("tool_name").and_then(Value::as_str)?;
1253        let input = v.get("tool_input")?;
1254        let steer = classify(tool, input)?;
1255        Some(decision(&steer, mode))
1256    }
1257
1258    /// The generic pipeline nudge for a `Bash` envelope [`process`] did not steer:
1259    /// a **warn-only** decision (never a deny) prompting the agent to reach for a
1260    /// `ct` call instead of a shell pipeline. [`None`] unless the command is a
1261    /// pipe with no specific steer (see [`super::pipeline_nudge`]).
1262    pub fn pipeline_nudge_decision(envelope: &str) -> Option<Value> {
1263        let v: Value = serde_json::from_str(envelope).ok()?;
1264        if v.get("tool_name").and_then(Value::as_str)? != "Bash" {
1265            return None;
1266        }
1267        let cmd = v
1268            .get("tool_input")?
1269            .get("command")
1270            .and_then(Value::as_str)?;
1271        let steer = super::pipeline_nudge(cmd)?;
1272        Some(decision(&steer, Mode::Warn))
1273    }
1274
1275    /// Build a structured log record for one `PreToolUse` envelope: which tool
1276    /// ran, its `Bash` command, the call's `cwd`/`session_id`, and what the hook
1277    /// decided under `mode`. Unlike [`process`] this also records the silent
1278    /// **allows** — the raw material for spotting shell idioms that *should* have
1279    /// been steered to `ct` but currently are not. Lenient: a malformed envelope
1280    /// still yields a record of what could be read. No timestamp is stamped here,
1281    /// so the record stays deterministic; the caller adds the time and appends it
1282    /// as one JSONL line.
1283    pub fn log_record(envelope: &str, mode: Mode) -> Value {
1284        let v: Value = serde_json::from_str(envelope).unwrap_or(Value::Null);
1285        let tool = v.get("tool_name").and_then(Value::as_str).unwrap_or("");
1286        let input = v.get("tool_input").cloned().unwrap_or(Value::Null);
1287        let (decision, rule_id, ct_tool) = match classify(tool, &input) {
1288            Some(s) => (mode.name(), Some(s.rule_id), Some(s.tool)),
1289            None => ("allow", None, None),
1290        };
1291        json!({
1292            "event": "pre",
1293            "tool": tool,
1294            "command": input.get("command").and_then(Value::as_str),
1295            "cwd": v.get("cwd").and_then(Value::as_str),
1296            "session_id": v.get("session_id").and_then(Value::as_str),
1297            "decision": decision,
1298            "rule_id": rule_id,
1299            "ct_tool": ct_tool,
1300        })
1301    }
1302
1303    /// Whether an executed `Bash` command is itself a `ct` call — the follow-the-
1304    /// guidance signal: after a steered/nudged call, did the agent's next command
1305    /// actually reach for `ct`?
1306    fn is_ct_command(command: &str) -> bool {
1307        command
1308            .split_whitespace()
1309            .next()
1310            .map(super::base_name)
1311            .is_some_and(|b| b == "ct" || b.starts_with("ct-"))
1312    }
1313
1314    /// Build a structured log record for one `PostToolUse` envelope — the call as
1315    /// it actually **executed** (`event: "post"`), paired with whether it used
1316    /// `ct`. Logged alongside the `pre` records in the same daily file so an
1317    /// analysis can correlate a steer decision with the follow-up call by
1318    /// `session_id` and time. Lenient, and stamps no time (the caller does).
1319    pub fn post_record(envelope: &str) -> Value {
1320        let v: Value = serde_json::from_str(envelope).unwrap_or(Value::Null);
1321        let tool = v.get("tool_name").and_then(Value::as_str).unwrap_or("");
1322        let command = v
1323            .get("tool_input")
1324            .and_then(|i| i.get("command"))
1325            .and_then(Value::as_str);
1326        json!({
1327            "event": "post",
1328            "tool": tool,
1329            "command": command,
1330            "ct": command.is_some_and(is_ct_command),
1331            "cwd": v.get("cwd").and_then(Value::as_str),
1332            "session_id": v.get("session_id").and_then(Value::as_str),
1333        })
1334    }
1335}
1336
1337// ----- Settings install --------------------------------------------------------
1338
1339/// Merging the steering hook into a Claude Code settings file. The merge runs
1340/// through the comment- and layout-preserving `ct-patch` engine
1341/// ([`crate::patch`]): the existing file is parsed only to *decide* which edits
1342/// to make, and those edits are byte-range splices against the original text,
1343/// so the user's comments and formatting survive.
1344pub mod install {
1345    use super::Mode;
1346    use crate::patch::{self, Op, parse_path};
1347    use serde_json::{Value, json};
1348    use std::path::{Path, PathBuf};
1349
1350    /// Which settings file the hook is written to.
1351    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1352    pub enum Scope {
1353        /// `.claude/settings.json` (shared, committed).
1354        Project,
1355        /// `.claude/settings.local.json` (personal, gitignored).
1356        Local,
1357        /// `~/.claude/settings.json` (all projects).
1358        User,
1359    }
1360
1361    impl Scope {
1362        /// Parse the `--scope` value.
1363        pub fn from_name(s: &str) -> Option<Scope> {
1364            match s {
1365                "project" => Some(Scope::Project),
1366                "local" => Some(Scope::Local),
1367                "user" => Some(Scope::User),
1368                _ => None,
1369            }
1370        }
1371
1372        /// The settings file path. `project`/`local` are relative to `root`
1373        /// (the project directory); `user` lives under `home`.
1374        pub fn path(self, root: &Path, home: &Path) -> PathBuf {
1375            match self {
1376                Scope::Project => root.join(".claude").join("settings.json"),
1377                Scope::Local => root.join(".claude").join("settings.local.json"),
1378                Scope::User => home.join(".claude").join("settings.json"),
1379            }
1380        }
1381    }
1382
1383    /// A harness tool the steering hook can be installed to gate. Each becomes
1384    /// its own `PreToolUse` matcher entry; `Bash` is the default.
1385    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1386    pub enum Tool {
1387        /// Shell commands — classified by the full shell-idiom matcher.
1388        Bash,
1389        /// The harness content search → `ct search`.
1390        Grep,
1391        /// The harness file glob → `ct search`.
1392        Glob,
1393        /// The harness file read → `ct view` (images/PDF/notebooks pass through).
1394        Read,
1395        /// Every tool (a `*` matcher) — full-coverage logging; the hook still only
1396        /// steers the recognised idioms and passes everything else through.
1397        All,
1398    }
1399
1400    impl Tool {
1401        /// Parse a `--tools` value.
1402        pub fn from_name(s: &str) -> Option<Tool> {
1403            match s {
1404                "Bash" => Some(Tool::Bash),
1405                "Grep" => Some(Tool::Grep),
1406                "Glob" => Some(Tool::Glob),
1407                "Read" => Some(Tool::Read),
1408                "all" | "*" => Some(Tool::All),
1409                _ => None,
1410            }
1411        }
1412
1413        /// The `matcher` string this tool is written under in settings.
1414        pub fn matcher(self) -> &'static str {
1415            match self {
1416                Tool::Bash => "Bash",
1417                Tool::Grep => "Grep",
1418                Tool::Glob => "Glob",
1419                Tool::Read => "Read",
1420                Tool::All => "*",
1421            }
1422        }
1423    }
1424
1425    /// The `--log-dir`/`--no-log` suffix baked into an installed command. A path
1426    /// with whitespace is double-quoted to survive the shell the hook runs under.
1427    fn log_flags(log_dir: Option<&str>, no_log: bool) -> String {
1428        if no_log {
1429            return " --no-log".to_string();
1430        }
1431        match log_dir {
1432            Some(path) if path.chars().any(char::is_whitespace) => format!(" --log-dir \"{path}\""),
1433            Some(path) => format!(" --log-dir {path}"),
1434            None => String::new(),
1435        }
1436    }
1437
1438    /// The `PreToolUse` hook command written into settings, built on `head` — the
1439    /// invocation prefix, `"ct steer hook"` by default or a pinned
1440    /// `"<abs-path> hook"` (see `--pin`). Tool-call logging is on by default (to
1441    /// `.ct/tclog/`), so the bare command already logs; `no_log`/`log_dir` bake in
1442    /// the logging override, and `nudge_pipelines` bakes in `--nudge-pipelines`.
1443    pub fn hook_command(
1444        head: &str,
1445        mode: Mode,
1446        log_dir: Option<&str>,
1447        no_log: bool,
1448        nudge_pipelines: bool,
1449    ) -> String {
1450        let mut cmd = head.to_string();
1451        if !matches!(mode, Mode::Deny) {
1452            cmd.push_str(&format!(" --mode {}", mode.name()));
1453        }
1454        if nudge_pipelines {
1455            cmd.push_str(" --nudge-pipelines");
1456        }
1457        cmd.push_str(&log_flags(log_dir, no_log));
1458        cmd
1459    }
1460
1461    /// The `PostToolUse` command written into settings by `--measure` (records
1462    /// each executed call), built on `head` (`"ct steer post"` or a pinned form).
1463    pub fn post_command(head: &str, log_dir: Option<&str>, no_log: bool) -> String {
1464        format!("{head}{}", log_flags(log_dir, no_log))
1465    }
1466
1467    /// Whether a settings hook command is one of ours — the `hook` (any mode) or
1468    /// the `post` recorder.
1469    fn is_steer_command(s: &str) -> bool {
1470        s.contains("steer") && (s.contains("hook") || s.contains("post"))
1471    }
1472
1473    /// Parse existing settings text (JSONC tolerated) for read-only inspection.
1474    /// The actual mutation is a byte-splice on the original text via `ct-patch`,
1475    /// so this serde view is only used to *decide* which edits to make.
1476    fn inspect(text: &str) -> Result<Value, String> {
1477        let root = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
1478            .map_err(|e| format!("parse settings: {e}"))?
1479            .unwrap_or_else(|| json!({}));
1480        if !root.is_object() {
1481            return Err("settings root must be a JSON object".to_string());
1482        }
1483        Ok(root)
1484    }
1485
1486    /// The canonical full settings document, used only when there is no existing
1487    /// file to merge into (so there are no comments or layout to preserve). One
1488    /// `PreToolUse` matcher entry per requested tool.
1489    fn canonical(command: &str, tools: &[Tool]) -> String {
1490        let matchers: Vec<Value> = tools
1491            .iter()
1492            .map(|t| {
1493                json!({ "matcher": t.matcher(), "hooks": [ { "type": "command", "command": command } ] })
1494            })
1495            .collect();
1496        let v = json!({ "hooks": { "PreToolUse": matchers } });
1497        serde_json::to_string_pretty(&v).unwrap() + "\n"
1498    }
1499
1500    fn op_set(path: &str, value: String) -> Result<Op, String> {
1501        Ok(Op::Set {
1502            path: parse_path(path)?,
1503            raw: path.to_string(),
1504            value,
1505        })
1506    }
1507    fn op_add(path: &str, value: String) -> Result<Op, String> {
1508        Ok(Op::Add {
1509            path: parse_path(path)?,
1510            raw: path.to_string(),
1511            value,
1512        })
1513    }
1514    fn op_delete(path: &str) -> Result<Op, String> {
1515        Ok(Op::Delete {
1516            path: parse_path(path)?,
1517            raw: path.to_string(),
1518        })
1519    }
1520
1521    /// Apply a computed op sequence to the original text via the comment- and
1522    /// layout-preserving `ct-patch` engine.
1523    fn apply(text: &str, ops: &[Op]) -> Result<(String, bool), String> {
1524        if ops.is_empty() {
1525            return Ok((text.to_string(), false));
1526        }
1527        let (out, changes) =
1528            patch::apply_doc(text, ops).map_err(|e| format!("settings merge: {e}"))?;
1529        Ok((out, changes > 0))
1530    }
1531
1532    /// Install the steering hook into `existing` settings text (or create a
1533    /// fresh document), gating each tool in `tools`. Returns the new text and
1534    /// whether it changed. Idempotent: re-installing the same command/tools is a
1535    /// no-op; a `--mode` change rewrites the command in place; a new tool adds
1536    /// its matcher. Comments and layout in `existing` are preserved.
1537    pub fn install(
1538        existing: Option<&str>,
1539        command: &str,
1540        tools: &[Tool],
1541    ) -> Result<(String, bool), String> {
1542        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1543            return Ok((canonical(command, tools), true));
1544        };
1545        let root = inspect(text)?;
1546        let ops = install_ops(&root, command, tools)?;
1547        apply(text, &ops)
1548    }
1549
1550    /// Install the `PostToolUse` recorder (`--measure`) as a single `*` matcher
1551    /// running `command`, so every executed call is logged for effectiveness
1552    /// analysis. Idempotent, comment-preserving, and independent of the
1553    /// `PreToolUse` steering hook.
1554    pub fn install_post(existing: Option<&str>, command: &str) -> Result<(String, bool), String> {
1555        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1556            let v = json!({ "hooks": { "PostToolUse": [
1557                { "matcher": "*", "hooks": [ { "type": "command", "command": command } ] }
1558            ] } });
1559            return Ok((serde_json::to_string_pretty(&v).unwrap() + "\n", true));
1560        };
1561        let root = inspect(text)?;
1562        let ops = post_install_ops(&root, command)?;
1563        apply(text, &ops)
1564    }
1565
1566    /// Remove every steering hook (`PreToolUse` steer and `PostToolUse` recorder)
1567    /// from `existing` settings text, pruning emptied matcher entries and the
1568    /// `hooks` containers when they end up empty. Comments and layout elsewhere
1569    /// are preserved.
1570    pub fn uninstall(existing: Option<&str>) -> Result<(String, bool), String> {
1571        let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1572            return Ok((existing.unwrap_or_default().to_string(), false));
1573        };
1574        let root = inspect(text)?;
1575        let ops = uninstall_ops(&root)?;
1576        apply(text, &ops)
1577    }
1578
1579    /// Whether an array element is a `"matcher": <name>` entry.
1580    fn is_matcher(entry: &Value, name: &str) -> bool {
1581        entry.get("matcher").and_then(Value::as_str) == Some(name)
1582    }
1583
1584    /// Whether a matcher entry already carries one of our steer hooks.
1585    fn entry_has_steer(entry: &Value) -> bool {
1586        entry
1587            .get("hooks")
1588            .and_then(Value::as_array)
1589            .is_some_and(|l| {
1590                l.iter().any(|h| {
1591                    h.get("command")
1592                        .and_then(Value::as_str)
1593                        .is_some_and(is_steer_command)
1594                })
1595            })
1596    }
1597
1598    /// Compute the ops that install `command` for every tool in `tools`, given
1599    /// the parsed `root`. Indices come from the original parse and stay valid
1600    /// because every op only adds keys/elements, never reorders entries.
1601    fn install_ops(root: &Value, command: &str, tools: &[Tool]) -> Result<Vec<Op>, String> {
1602        let mut ops = Vec::new();
1603
1604        // Ensure the `hooks` / `hooks.PreToolUse` containers exist.
1605        let hooks = root.get("hooks");
1606        match hooks {
1607            None => ops.push(op_set(".hooks", "{}".to_string())?),
1608            Some(h) if !h.is_object() => {
1609                return Err("settings `hooks` must be an object".to_string());
1610            }
1611            Some(_) => {}
1612        }
1613        let pre = hooks.and_then(|h| h.get("PreToolUse"));
1614        match pre {
1615            None => ops.push(op_set(".hooks.PreToolUse", "[]".to_string())?),
1616            Some(p) if !p.is_array() => {
1617                return Err("settings `hooks.PreToolUse` must be an array".to_string());
1618            }
1619            Some(_) => {}
1620        }
1621        let pre_arr = pre.and_then(Value::as_array);
1622
1623        // Mode change: rewrite the command of any existing steer hook that differs.
1624        if let Some(arr) = pre_arr {
1625            for (ei, entry) in arr.iter().enumerate() {
1626                let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1627                    continue;
1628                };
1629                for (hi, h) in list.iter().enumerate() {
1630                    if let Some(c) = h.get("command").and_then(Value::as_str)
1631                        && is_steer_command(c)
1632                        && c != command
1633                    {
1634                        ops.push(op_set(
1635                            &format!(".hooks.PreToolUse[{ei}].hooks[{hi}].command"),
1636                            json!(command).to_string(),
1637                        )?);
1638                    }
1639                }
1640            }
1641        }
1642
1643        // Per requested tool: ensure a matcher for it carries our command.
1644        let hook_obj = json!({ "type": "command", "command": command }).to_string();
1645        for tool in tools {
1646            let name = tool.matcher();
1647            // Already steered for this tool (mode change handled above)?
1648            if pre_arr.is_some_and(|arr| {
1649                arr.iter()
1650                    .any(|e| is_matcher(e, name) && entry_has_steer(e))
1651            }) {
1652                continue;
1653            }
1654            // Append to an existing matcher entry for this tool, else add one.
1655            let target =
1656                pre_arr.and_then(|arr| arr.iter().enumerate().find(|(_, e)| is_matcher(e, name)));
1657            match target {
1658                Some((ei, e)) if e.get("hooks").and_then(Value::as_array).is_some() => {
1659                    ops.push(op_add(
1660                        &format!(".hooks.PreToolUse[{ei}].hooks"),
1661                        hook_obj.clone(),
1662                    )?);
1663                }
1664                Some((ei, _)) => {
1665                    ops.push(op_set(
1666                        &format!(".hooks.PreToolUse[{ei}].hooks"),
1667                        format!("[{hook_obj}]"),
1668                    )?);
1669                }
1670                None => {
1671                    let matcher = json!({ "matcher": name, "hooks": [ { "type": "command", "command": command } ] })
1672                        .to_string();
1673                    ops.push(op_add(".hooks.PreToolUse", matcher)?);
1674                }
1675            }
1676        }
1677        Ok(ops)
1678    }
1679
1680    /// The ops that install the `PostToolUse` recorder under a single `*` matcher,
1681    /// given the parsed `root`. Idempotent, and rewrites a differing post command
1682    /// in place — the `PostToolUse` analogue of [`install_ops`].
1683    fn post_install_ops(root: &Value, command: &str) -> Result<Vec<Op>, String> {
1684        let mut ops = Vec::new();
1685        let hooks = root.get("hooks");
1686        match hooks {
1687            None => ops.push(op_set(".hooks", "{}".to_string())?),
1688            Some(h) if !h.is_object() => {
1689                return Err("settings `hooks` must be an object".to_string());
1690            }
1691            Some(_) => {}
1692        }
1693        let post = hooks.and_then(|h| h.get("PostToolUse"));
1694        match post {
1695            None => ops.push(op_set(".hooks.PostToolUse", "[]".to_string())?),
1696            Some(p) if !p.is_array() => {
1697                return Err("settings `hooks.PostToolUse` must be an array".to_string());
1698            }
1699            Some(_) => {}
1700        }
1701        let post_arr = post.and_then(Value::as_array);
1702
1703        // Rewrite an existing post recorder whose command differs.
1704        if let Some(arr) = post_arr {
1705            for (ei, entry) in arr.iter().enumerate() {
1706                let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1707                    continue;
1708                };
1709                for (hi, h) in list.iter().enumerate() {
1710                    if let Some(c) = h.get("command").and_then(Value::as_str)
1711                        && is_steer_command(c)
1712                        && c != command
1713                    {
1714                        ops.push(op_set(
1715                            &format!(".hooks.PostToolUse[{ei}].hooks[{hi}].command"),
1716                            json!(command).to_string(),
1717                        )?);
1718                    }
1719                }
1720            }
1721        }
1722        // Already have a `*` matcher carrying our recorder?
1723        if post_arr.is_some_and(|arr| arr.iter().any(|e| is_matcher(e, "*") && entry_has_steer(e)))
1724        {
1725            return Ok(ops);
1726        }
1727        let hook_obj = json!({ "type": "command", "command": command }).to_string();
1728        let target =
1729            post_arr.and_then(|arr| arr.iter().enumerate().find(|(_, e)| is_matcher(e, "*")));
1730        match target {
1731            Some((ei, e)) if e.get("hooks").and_then(Value::as_array).is_some() => {
1732                ops.push(op_add(
1733                    &format!(".hooks.PostToolUse[{ei}].hooks"),
1734                    hook_obj,
1735                )?);
1736            }
1737            Some((ei, _)) => {
1738                ops.push(op_set(
1739                    &format!(".hooks.PostToolUse[{ei}].hooks"),
1740                    format!("[{hook_obj}]"),
1741                )?);
1742            }
1743            None => {
1744                let matcher =
1745                    json!({ "matcher": "*", "hooks": [ { "type": "command", "command": command } ] })
1746                        .to_string();
1747                ops.push(op_add(".hooks.PostToolUse", matcher)?);
1748            }
1749        }
1750        Ok(ops)
1751    }
1752
1753    /// The ops that remove our hooks from one `hooks.<event>` array. Deletes the
1754    /// event array whole (or `hooks` itself, when this event is its only key) when
1755    /// every entry is ours; otherwise prunes just our hooks/entries.
1756    fn removal_ops_for_event(root: &Value, event: &str) -> Result<Vec<Op>, String> {
1757        let Some(arr) = root
1758            .get("hooks")
1759            .and_then(|h| h.get(event))
1760            .and_then(Value::as_array)
1761        else {
1762            return Ok(vec![]);
1763        };
1764        let mut whole_entries = Vec::new(); // entry indices to delete outright
1765        let mut partial = Vec::new(); // (entry index, our hook indices)
1766        for (ei, entry) in arr.iter().enumerate() {
1767            let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1768                continue;
1769            };
1770            let ours: Vec<usize> = list
1771                .iter()
1772                .enumerate()
1773                .filter(|(_, h)| {
1774                    h.get("command")
1775                        .and_then(Value::as_str)
1776                        .is_some_and(is_steer_command)
1777                })
1778                .map(|(hi, _)| hi)
1779                .collect();
1780            if ours.is_empty() {
1781                continue;
1782            }
1783            if ours.len() == list.len() {
1784                whole_entries.push(ei);
1785            } else {
1786                partial.push((ei, ours));
1787            }
1788        }
1789        if whole_entries.is_empty() && partial.is_empty() {
1790            return Ok(vec![]);
1791        }
1792
1793        // Every entry ours → the whole event array goes (or `hooks` itself when
1794        // this event is its only key).
1795        if partial.is_empty() && whole_entries.len() == arr.len() {
1796            let hooks_solo = root
1797                .get("hooks")
1798                .and_then(Value::as_object)
1799                .is_some_and(|o| o.len() == 1);
1800            let path = if hooks_solo {
1801                ".hooks".to_string()
1802            } else {
1803                format!(".hooks.{event}")
1804            };
1805            return Ok(vec![op_delete(&path)?]);
1806        }
1807
1808        let mut ops = Vec::new();
1809        // Inner-hook deletes first (descending index, so earlier indices stay
1810        // valid), then whole-entry deletes (descending, likewise).
1811        for (ei, his) in &partial {
1812            for hi in his.iter().rev() {
1813                ops.push(op_delete(&format!(".hooks.{event}[{ei}].hooks[{hi}]"))?);
1814            }
1815        }
1816        for ei in whole_entries.iter().rev() {
1817            ops.push(op_delete(&format!(".hooks.{event}[{ei}]"))?);
1818        }
1819        Ok(ops)
1820    }
1821
1822    /// Compute the ops that remove every steering hook, across both the
1823    /// `PreToolUse` steer and the `PostToolUse` recorder.
1824    fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
1825        let mut ops = removal_ops_for_event(root, "PreToolUse")?;
1826        ops.extend(removal_ops_for_event(root, "PostToolUse")?);
1827        Ok(ops)
1828    }
1829}
1830
1831#[cfg(test)]
1832mod tests {
1833    use super::install::{Scope, Tool, install, uninstall};
1834    use super::*;
1835    use std::path::Path;
1836
1837    /// Bash-only install, the common case in these tests.
1838    fn install_bash(existing: Option<&str>, command: &str) -> Result<(String, bool), String> {
1839        install(existing, command, &[Tool::Bash])
1840    }
1841
1842    fn tool(cmd: &str) -> Option<&'static str> {
1843        analyze(cmd).map(|s| s.tool)
1844    }
1845    fn rule(cmd: &str) -> Option<&'static str> {
1846        analyze(cmd).map(|s| s.rule_id)
1847    }
1848
1849    #[test]
1850    fn steers_high_confidence_idioms() {
1851        assert_eq!(
1852            tool("find . -name '*.rs' | xargs grep TODO"),
1853            Some("ct search")
1854        );
1855        assert_eq!(
1856            rule("find . -name '*.rs' | xargs grep TODO"),
1857            Some("find-grep")
1858        );
1859        assert_eq!(tool("grep -rn TODO src"), Some("ct search"));
1860        assert_eq!(tool("rg TODO src"), Some("ct search"));
1861        assert_eq!(tool("find src -name '*.rs'"), Some("ct search"));
1862        assert_eq!(tool("sed -i 's/foo/bar/g' src/x.rs"), Some("ct edit"));
1863        assert_eq!(tool("head -n 40 src/lib.rs"), Some("ct view"));
1864        assert_eq!(tool("cat src/lib.rs | head -n 20"), Some("ct view"));
1865        assert_eq!(tool("sed -n '10,20p' src/lib.rs"), Some("ct view"));
1866        assert_eq!(tool("ls -R src"), Some("ct tree"));
1867        assert_eq!(tool("wc -l src/lib.rs"), Some("ct tree"));
1868        assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1869        assert_eq!(
1870            rule("for f in a b; do grep -r x $f; done"),
1871            Some("shell-loop")
1872        );
1873    }
1874
1875    #[test]
1876    fn steers_wait_loops_to_await_not_each() {
1877        // a sleep-bearing poll/wait loop is a bounded wait → ct await
1878        assert_eq!(
1879            tool("for i in $(seq 1 900); do cat f; sleep 2; done"),
1880            Some("ct await")
1881        );
1882        assert_eq!(
1883            rule("for i in $(seq 1 900); do cat f; sleep 2; done"),
1884            Some("wait-loop")
1885        );
1886        assert_eq!(
1887            tool("while true; do check; sleep 5; done"),
1888            Some("ct await")
1889        );
1890        assert_eq!(
1891            tool("until curl -sf http://x; do sleep 3; done"),
1892            Some("ct await")
1893        );
1894        // a sleep-free loop stays a per-item map → ct each
1895        assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1896        assert_eq!(
1897            rule("for f in a b; do grep -r x $f; done"),
1898            Some("shell-loop")
1899        );
1900    }
1901
1902    #[test]
1903    fn steers_interpreter_file_reads() {
1904        // jq with a file argument reads a file → ct view
1905        assert_eq!(tool("jq '.note' feedback/x.jsonl"), Some("ct view"));
1906        assert_eq!(
1907            rule("jq '.note' feedback/x.jsonl"),
1908            Some("interpreter-read")
1909        );
1910        // python one-liner that opens a file and prints → ct view
1911        let s = analyze(
1912            "python -c \"rows=[json.loads(l) for l in open('feedback/x.jsonl')]; print(rows[-1])\"",
1913        )
1914        .unwrap();
1915        assert_eq!(s.tool, "ct view");
1916        assert!(
1917            s.suggestion.contains("feedback/x.jsonl"),
1918            "{}",
1919            s.suggestion
1920        );
1921        assert_eq!(
1922            tool("node -e 'const d=require(\"fs\").readFileSync(\"a.json\")'"),
1923            Some("ct view")
1924        );
1925        // pure-compute one-liner (no file read) is left alone
1926        assert!(analyze("python -c 'print(2+2)'").is_none());
1927        // a one-liner that writes is left alone
1928        assert!(analyze("python -c \"open('out.txt','w').write('hi')\"").is_none());
1929        // a jq fed by a pipe (no file) is left alone
1930        assert!(analyze("cat x | jq '.note'").is_none());
1931    }
1932
1933    #[test]
1934    fn steers_count_idioms() {
1935        // grep -c counts matching lines → ct search --summary
1936        assert_eq!(tool("grep -c TODO src/lib.rs"), Some("ct search"));
1937        assert_eq!(rule("grep -c TODO src/lib.rs"), Some("grep-count"));
1938        let s = analyze("grep -c TODO src/lib.rs").unwrap();
1939        assert!(s.suggestion.contains("--grep 'TODO'") && s.suggestion.contains("--summary"));
1940        // cat FILES | wc -l counts lines of real files → ct tree
1941        assert_eq!(tool("cat a.jsonl b.jsonl | wc -l"), Some("ct tree"));
1942        // a bare stream count has no file behind it → left alone
1943        assert!(analyze("ps aux | wc -l").is_none());
1944    }
1945
1946    #[test]
1947    fn extracts_obvious_slots() {
1948        let s = analyze("grep -rn TODO src").unwrap();
1949        assert!(s.suggestion.contains("--grep 'TODO'"), "{}", s.suggestion);
1950        let e = analyze("sed -i 's/foo/bar/g' f.rs").unwrap();
1951        assert!(
1952            e.suggestion.contains("--find 'foo'") && e.suggestion.contains("--replace 'bar'"),
1953            "{}",
1954            e.suggestion
1955        );
1956        let v = analyze("head -n 40 src/lib.rs").unwrap();
1957        assert!(
1958            v.suggestion.contains("src/lib.rs --range 1:40"),
1959            "{}",
1960            v.suggestion
1961        );
1962        // the grep pattern is taken after the `grep` token, not after `xargs`
1963        let fg = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
1964        assert!(fg.suggestion.contains("--grep 'TODO'"), "{}", fg.suggestion);
1965        assert!(fg.suggestion.contains("--name '*.rs'"), "{}", fg.suggestion);
1966    }
1967
1968    #[test]
1969    fn chain_only_when_all_segments_serviceable() {
1970        let s = analyze("grep -r foo src && sed -i 's/a/b/' f.rs").unwrap();
1971        assert_eq!(s.tool, "ct and");
1972        assert!(
1973            s.suggestion.starts_with("ct and search"),
1974            "{}",
1975            s.suggestion
1976        );
1977        assert!(s.suggestion.contains(":::"), "{}", s.suggestion);
1978        // a mixed chain (one non-ct segment) is left alone
1979        assert!(analyze("grep -r foo src && make").is_none());
1980    }
1981
1982    #[test]
1983    fn folds_all_ct_or_advisable_scriptlet_into_one_chain() {
1984        // The motivating gambit: cd + assignments + two `ct edit` + an echo +
1985        // two `grep -cE` verifications. Every real step is ct or ct-advisable
1986        // (and not all are ct), so it folds into a single `ct and` chain.
1987        let script = "cd /repo\n\
1988             G=crates/a/src/game.rs\n\
1989             S=/tmp/scratch\n\
1990             ct edit --base \"$G\" --find file:$S/a.txt --replace file:$S/b.txt --mode literal --expect =1 --quiet\n\
1991             ct edit --base \"$G\" --find file:$S/c.txt --replace file:$S/d.txt --mode literal --expect =1 --quiet\n\
1992             echo \"--- verify ---\"\n\
1993             grep -cE \"submit_request|UserRequest\" \"$G\"\n\
1994             grep -cE \"submit_agent_request\" \"$G\"";
1995        let s = analyze(script).expect("a foldable scriptlet");
1996        assert_eq!(s.rule_id, "script-compound");
1997        assert_eq!(s.tool, "ct and");
1998        // Two ct edits (kept) plus two greps (→ ct search): four segments, three
1999        // separators, and it leads with the first already-ct step.
2000        assert!(s.suggestion.starts_with("ct and edit "), "{}", s.suggestion);
2001        assert!(s.suggestion.contains(" ::: search "), "{}", s.suggestion);
2002        assert_eq!(s.suggestion.matches(" ::: ").count(), 3, "{}", s.suggestion);
2003    }
2004
2005    #[test]
2006    fn scriptlet_with_an_opaque_step_advises_lines_not_a_fold() {
2007        // grep -r (advisable) + cargo build (no ct analogue) + sed -i (advisable):
2008        // it can't fold whole, so advise the ct forms individually.
2009        let script = "grep -r TODO src\ncargo build\nsed -i 's/a/b/' x.rs";
2010        let s = analyze(script).expect("some steps are advisable");
2011        assert_eq!(s.rule_id, "script-lines");
2012        assert_eq!(s.tool, "ct");
2013        assert!(s.suggestion.contains("ct search"), "{}", s.suggestion);
2014        assert!(s.suggestion.contains("ct edit"), "{}", s.suggestion);
2015        // the opaque `cargo build` is not dressed up as a ct command
2016        assert!(!s.suggestion.contains("cargo"), "{}", s.suggestion);
2017    }
2018
2019    #[test]
2020    fn scriptlet_of_only_ct_calls_is_left_alone() {
2021        // Already all ct (just in separate calls): not our business to nag.
2022        assert!(
2023            analyze("ct search --grep A --quiet\nct edit --find a --replace b --base x.rs")
2024                .is_none()
2025        );
2026        // All-opaque multi-line is likewise allowed.
2027        assert!(analyze("cargo build\ncargo test\ngit status").is_none());
2028    }
2029
2030    #[test]
2031    fn lone_real_step_among_setup_is_still_steered() {
2032        // One advisable operation wrapped in scaffolding lines.
2033        let s = analyze("cd /repo\nG=src\ngrep -r TODO \"$G\"").expect("the grep is advisable");
2034        assert_eq!(s.tool, "ct search");
2035    }
2036
2037    #[test]
2038    fn line_continuations_are_one_command() {
2039        // A backslash-continued pipeline is a single command, not a scriptlet.
2040        let s = analyze("find . -name '*.rs' \\\n  | xargs grep TODO").expect("joined command");
2041        assert_eq!(s.tool, "ct search");
2042        assert_eq!(s.rule_id, "find-grep");
2043    }
2044
2045    #[test]
2046    fn allows_safe_and_unknown_commands() {
2047        assert!(analyze("git status").is_none());
2048        assert!(analyze("cargo build && cargo test").is_none());
2049        assert!(analyze("ls -la").is_none());
2050        assert!(analyze("cat file.txt").is_none()); // whole-file read, not a range
2051        assert!(analyze("grep TODO file.rs").is_none()); // non-recursive, single file
2052        assert!(analyze("echo 'a | b && c'").is_none()); // operators inside quotes are inert
2053        assert!(analyze("ps aux | head -n 5").is_none()); // piped stream, no file
2054        assert!(analyze("").is_none());
2055    }
2056
2057    #[test]
2058    fn never_resteers_a_ct_command() {
2059        assert!(analyze("ct search --grep TODO").is_none());
2060        assert!(analyze("ct-search --grep TODO").is_none());
2061        assert!(analyze("find . -name '*.rs' | xargs ct-edit").is_none());
2062    }
2063
2064    #[test]
2065    fn hook_decisions_respect_mode() {
2066        let envelope = r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"}}"#;
2067        let deny = hook::process(envelope, Mode::Deny).unwrap();
2068        assert_eq!(deny["hookSpecificOutput"]["permissionDecision"], "deny");
2069        assert!(
2070            deny["hookSpecificOutput"]["permissionDecisionReason"]
2071                .as_str()
2072                .unwrap()
2073                .contains("ct search")
2074        );
2075        let ask = hook::process(envelope, Mode::Ask).unwrap();
2076        assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
2077        let warn = hook::process(envelope, Mode::Warn).unwrap();
2078        assert!(warn["hookSpecificOutput"]["additionalContext"].is_string());
2079        assert!(
2080            warn["hookSpecificOutput"]
2081                .get("permissionDecision")
2082                .is_none()
2083        );
2084    }
2085
2086    #[test]
2087    fn hook_steers_harness_grep_glob_read() {
2088        // Grep → ct search, carrying pattern / path / glob.
2089        let grep = hook::process(
2090            r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO","path":"src","glob":"*.rs"}}"#,
2091            Mode::Deny,
2092        )
2093        .unwrap();
2094        let reason = grep["hookSpecificOutput"]["permissionDecisionReason"]
2095            .as_str()
2096            .unwrap();
2097        assert!(reason.contains("ct search"), "{reason}");
2098        assert!(
2099            reason.contains("--grep 'TODO'") && reason.contains("--base src"),
2100            "{reason}"
2101        );
2102        assert!(reason.contains("--name '*.rs'"), "{reason}");
2103
2104        // Glob → ct search; a `dir/**/*.ext` glob splits into --base / --name.
2105        let s = glob_steer("src/**/*.rs", None);
2106        assert_eq!(s.tool, "ct search");
2107        assert!(s.suggestion.contains("--base src"), "{}", s.suggestion);
2108        assert!(s.suggestion.contains("--name '*.rs'"), "{}", s.suggestion);
2109
2110        // Read → ct view with a range derived from offset/limit.
2111        let read = read_steer("src/lib.rs", Some(10), Some(20)).unwrap();
2112        assert_eq!(read.tool, "ct view");
2113        assert!(
2114            read.suggestion.contains("ct view src/lib.rs --range 10:29"),
2115            "{}",
2116            read.suggestion
2117        );
2118        // a bare read views the whole file
2119        assert_eq!(
2120            read_steer("notes.md", None, None).unwrap().suggestion,
2121            "ct view notes.md"
2122        );
2123        // images / PDFs / notebooks are left for Read (None)
2124        assert!(read_steer("diagram.png", None, None).is_none());
2125        assert!(read_steer("paper.pdf", None, None).is_none());
2126        assert!(read_steer("nb.ipynb", None, None).is_none());
2127    }
2128
2129    #[test]
2130    fn install_covers_multiple_tools() {
2131        let (text, changed) =
2132            install(None, "ct steer hook", &[Tool::Bash, Tool::Grep, Tool::Read]).unwrap();
2133        assert!(changed);
2134        for m in ["\"Bash\"", "\"Grep\"", "\"Read\""] {
2135            assert!(text.contains(m), "missing matcher {m} in {text}");
2136        }
2137        // re-install with the same tools is a no-op
2138        let (_, again) = install(
2139            Some(&text),
2140            "ct steer hook",
2141            &[Tool::Bash, Tool::Grep, Tool::Read],
2142        )
2143        .unwrap();
2144        assert!(!again);
2145        // adding a tool to an existing install only appends its matcher
2146        let (grown, did) = install(Some(&text), "ct steer hook", &[Tool::Glob]).unwrap();
2147        assert!(did);
2148        assert!(grown.contains("\"Glob\""));
2149        assert_eq!(grown.matches("\"matcher\"").count(), 4);
2150        // uninstall clears every steer matcher we added
2151        let (cleared, _) = uninstall(Some(&grown)).unwrap();
2152        assert!(!cleared.contains("steer hook"));
2153    }
2154
2155    #[test]
2156    fn hook_fails_open() {
2157        assert!(hook::process("not json", Mode::Deny).is_none());
2158        assert!(hook::process(r#"{"tool_name":"Read"}"#, Mode::Deny).is_none());
2159        assert!(hook::process(r#"{"tool_name":"Bash","tool_input":{}}"#, Mode::Deny).is_none());
2160        assert!(
2161            hook::process(
2162                r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
2163                Mode::Deny
2164            )
2165            .is_none()
2166        );
2167    }
2168
2169    #[test]
2170    fn log_record_captures_steered_and_allowed_calls() {
2171        // A steered Bash command: the decision reflects the mode and the rule fires.
2172        let steered = hook::log_record(
2173            r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"},"cwd":"/work","session_id":"s1"}"#,
2174            Mode::Deny,
2175        );
2176        assert_eq!(steered["tool"], "Bash");
2177        assert_eq!(steered["command"], "grep -r TODO src");
2178        assert_eq!(steered["decision"], "deny");
2179        assert_eq!(steered["ct_tool"], "ct search");
2180        assert!(steered["rule_id"].is_string());
2181        assert_eq!(steered["cwd"], "/work");
2182        assert_eq!(steered["session_id"], "s1");
2183
2184        // An allowed command is still recorded — the missed-pattern raw material.
2185        let allowed = hook::log_record(
2186            r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
2187            Mode::Deny,
2188        );
2189        assert_eq!(allowed["decision"], "allow");
2190        assert!(allowed["rule_id"].is_null());
2191        assert!(allowed["ct_tool"].is_null());
2192        assert_eq!(allowed["command"], "git status");
2193
2194        // A non-shell tool the hook doesn't steer is logged as an allow.
2195        let other = hook::log_record(
2196            r#"{"tool_name":"Edit","tool_input":{"file_path":"a.rs"}}"#,
2197            Mode::Warn,
2198        );
2199        assert_eq!(other["tool"], "Edit");
2200        assert_eq!(other["decision"], "allow");
2201
2202        // Warn mode labels a steered call as "warn".
2203        let warned = hook::log_record(
2204            r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO"}}"#,
2205            Mode::Warn,
2206        );
2207        assert_eq!(warned["decision"], "warn");
2208    }
2209
2210    #[test]
2211    fn log_record_is_lenient_on_malformed_envelopes() {
2212        // Not JSON at all: an empty-tool allow record, never a panic.
2213        let bad = hook::log_record("not json", Mode::Deny);
2214        assert_eq!(bad["tool"], "");
2215        assert_eq!(bad["decision"], "allow");
2216        assert!(bad["command"].is_null());
2217    }
2218
2219    #[test]
2220    fn hook_command_bakes_logging_flags() {
2221        let head = "ct steer hook";
2222        // Default: logging is on, so the bare command needs no flag.
2223        assert_eq!(
2224            install::hook_command(head, Mode::Deny, None, false, false),
2225            "ct steer hook"
2226        );
2227        // --no-log wins over a directory override.
2228        assert_eq!(
2229            install::hook_command(head, Mode::Warn, Some("/x"), true, false),
2230            "ct steer hook --mode warn --no-log"
2231        );
2232        // A directory override is baked as --log-dir.
2233        assert_eq!(
2234            install::hook_command(head, Mode::Deny, Some("/var/log/tc"), false, false),
2235            "ct steer hook --log-dir /var/log/tc"
2236        );
2237        // A path with a space is quoted so the hook's shell keeps it one argument.
2238        assert_eq!(
2239            install::hook_command(head, Mode::Deny, Some("/my logs/tc"), false, false),
2240            "ct steer hook --log-dir \"/my logs/tc\""
2241        );
2242        // The pipeline nudge is baked before the logging flags.
2243        assert_eq!(
2244            install::hook_command(head, Mode::Warn, None, false, true),
2245            "ct steer hook --mode warn --nudge-pipelines"
2246        );
2247        // A pinned head bakes an absolute path instead of `ct steer`.
2248        assert_eq!(
2249            install::hook_command("/opt/ct/ct-steer hook", Mode::Deny, None, false, false),
2250            "/opt/ct/ct-steer hook"
2251        );
2252        // The post recorder shares the logging suffix.
2253        assert_eq!(
2254            install::post_command("ct steer post", None, false),
2255            "ct steer post"
2256        );
2257        assert_eq!(
2258            install::post_command("ct steer post", Some("/tc"), false),
2259            "ct steer post --log-dir /tc"
2260        );
2261    }
2262
2263    #[test]
2264    fn date_stem_is_utc_civil_date() {
2265        assert_eq!(date_stem(0), "1970-01-01");
2266        assert_eq!(date_stem(86_399), "1970-01-01"); // same day, last second
2267        assert_eq!(date_stem(86_400), "1970-01-02"); // next day
2268        assert_eq!(date_stem(1_600_000_000), "2020-09-13");
2269        // A leap day resolves correctly.
2270        assert_eq!(date_stem(1_582_934_400), "2020-02-29");
2271    }
2272
2273    #[test]
2274    fn gitignore_rule_is_added_once() {
2275        assert_eq!(gitignore_with_log_rule(None).as_deref(), Some("*log\n"));
2276        assert!(gitignore_with_log_rule(Some("*log\n")).is_none());
2277        // Appended to existing rules, and a missing trailing newline is repaired.
2278        assert_eq!(
2279            gitignore_with_log_rule(Some("target")).as_deref(),
2280            Some("target\n*log\n")
2281        );
2282    }
2283
2284    #[test]
2285    fn install_all_tools_writes_a_wildcard_matcher() {
2286        let (text, changed) = install(None, "ct steer hook", &[install::Tool::All]).unwrap();
2287        assert!(changed);
2288        assert!(text.contains("\"matcher\": \"*\""), "{text}");
2289        // uninstall still clears it (it scans by command, not matcher name).
2290        let (cleared, _) = uninstall(Some(&text)).unwrap();
2291        assert!(!cleared.contains("steer hook"));
2292    }
2293
2294    #[test]
2295    fn pipeline_nudge_only_on_unmapped_pipes() {
2296        // A pipe with no specific steer → generic nudge.
2297        let n = pipeline_nudge("ps aux | grep server").expect("unmapped pipe");
2298        assert_eq!(n.rule_id, "pipeline");
2299        assert_eq!(n.tool, "ct");
2300        // A pipe that IS specifically steered is left to that rule.
2301        assert!(pipeline_nudge("find . -name '*.rs' | xargs grep TODO").is_none());
2302        // No pipe, or already ct → nothing.
2303        assert!(pipeline_nudge("git status").is_none());
2304        assert!(pipeline_nudge("ct search --grep x | head").is_none());
2305    }
2306
2307    #[test]
2308    fn post_record_marks_ct_calls_and_carries_context() {
2309        let post = hook::post_record(
2310            r#"{"tool_name":"Bash","tool_input":{"command":"ct search --grep x"},"session_id":"s1"}"#,
2311        );
2312        assert_eq!(post["event"], "post");
2313        assert_eq!(post["ct"], true);
2314        assert_eq!(post["session_id"], "s1");
2315
2316        let raw =
2317            hook::post_record(r#"{"tool_name":"Bash","tool_input":{"command":"grep -r x ."}}"#);
2318        assert_eq!(raw["ct"], false);
2319        assert_eq!(raw["command"], "grep -r x .");
2320
2321        // A non-Bash tool has no command and is not a ct call.
2322        let edit = hook::post_record(r#"{"tool_name":"Edit","tool_input":{"file_path":"a.rs"}}"#);
2323        assert_eq!(edit["tool"], "Edit");
2324        assert_eq!(edit["ct"], false);
2325    }
2326
2327    #[test]
2328    fn install_post_adds_recorder_and_uninstall_clears_both_events() {
2329        // Steering hook, then the PostToolUse recorder on top.
2330        let (pre, _) = install_bash(None, "ct steer hook").unwrap();
2331        let (both, changed) = install::install_post(Some(&pre), "ct steer post").unwrap();
2332        assert!(changed);
2333        assert!(both.contains("\"PreToolUse\""), "{both}");
2334        assert!(both.contains("\"PostToolUse\""), "{both}");
2335        assert!(both.contains("ct steer post"), "{both}");
2336        // Re-installing the recorder is a no-op.
2337        let (_, again) = install::install_post(Some(&both), "ct steer post").unwrap();
2338        assert!(!again);
2339        // One uninstall clears both the steer hook and the recorder.
2340        let (cleared, _) = uninstall(Some(&both)).unwrap();
2341        assert!(!cleared.contains("steer hook"), "{cleared}");
2342        assert!(!cleared.contains("steer post"), "{cleared}");
2343    }
2344
2345    #[test]
2346    fn install_is_idempotent_and_preserves_other_settings() {
2347        // fresh install
2348        let (text, changed) = install_bash(None, "ct steer hook").unwrap();
2349        assert!(changed);
2350        assert!(text.contains("PreToolUse"));
2351        assert!(text.contains("\"matcher\": \"Bash\""));
2352        assert!(text.contains("ct steer hook"));
2353        // re-install is a no-op
2354        let (text2, changed2) = install_bash(Some(&text), "ct steer hook").unwrap();
2355        assert!(!changed2);
2356        assert_eq!(text, text2);
2357        // a mode change rewrites in place (still one hook)
2358        let (text3, changed3) = install_bash(Some(&text), "ct steer hook --mode ask").unwrap();
2359        assert!(changed3);
2360        assert_eq!(text3.matches("steer hook").count(), 1);
2361        // existing unrelated settings survive
2362        let existing = r#"{ "model": "opus", "hooks": { "PreToolUse": [] } }"#;
2363        let (merged, _) = install_bash(Some(existing), "ct steer hook").unwrap();
2364        assert!(merged.contains("\"model\": \"opus\""));
2365    }
2366
2367    #[test]
2368    fn uninstall_removes_only_our_hook() {
2369        let existing = r#"{
2370            "hooks": { "PreToolUse": [
2371                { "matcher": "Bash", "hooks": [
2372                    { "type": "command", "command": "ct steer hook" },
2373                    { "type": "command", "command": "./other.sh" }
2374                ] }
2375            ] }
2376        }"#;
2377        let (text, changed) = uninstall(Some(existing)).unwrap();
2378        assert!(changed);
2379        assert!(!text.contains("steer hook"));
2380        assert!(text.contains("./other.sh")); // the unrelated hook stays
2381        // uninstall on a clean file is a no-op
2382        let (_, changed2) = uninstall(Some("{}")).unwrap();
2383        assert!(!changed2);
2384    }
2385
2386    #[test]
2387    fn install_and_uninstall_preserve_comments() {
2388        // a settings.json with comments the user cares about
2389        let existing = "{\n  \
2390            // pin the model\n  \
2391            \"model\": \"opus\", // do not change\n  \
2392            \"hooks\": {\n    \
2393            \"PreToolUse\": [\n      \
2394            { \"matcher\": \"Bash\", \"hooks\": [ { \"type\": \"command\", \"command\": \"./guard.sh\" } ] }\n    \
2395            ]\n  }\n}\n";
2396        let (installed, changed) = install_bash(Some(existing), "ct steer hook").unwrap();
2397        assert!(changed);
2398        // comments survive the merge
2399        assert!(installed.contains("// pin the model"), "{installed}");
2400        assert!(installed.contains("// do not change"), "{installed}");
2401        // the prior hook is untouched and ours is appended to the same matcher
2402        assert!(installed.contains("./guard.sh"), "{installed}");
2403        assert!(installed.contains("ct steer hook"), "{installed}");
2404
2405        // uninstall removes only our hook, keeps the guard and the comments
2406        let (removed, changed2) = uninstall(Some(&installed)).unwrap();
2407        assert!(changed2);
2408        assert!(removed.contains("// pin the model"), "{removed}");
2409        assert!(removed.contains("./guard.sh"), "{removed}");
2410        assert!(!removed.contains("steer hook"), "{removed}");
2411    }
2412
2413    #[test]
2414    fn scope_paths() {
2415        let root = Path::new("/proj");
2416        let home = Path::new("/home/u");
2417        assert!(
2418            Scope::Project
2419                .path(root, home)
2420                .ends_with(".claude/settings.json")
2421        );
2422        assert!(
2423            Scope::Local
2424                .path(root, home)
2425                .ends_with(".claude/settings.local.json")
2426        );
2427        assert!(Scope::User.path(root, home).starts_with("/home/u"));
2428    }
2429}