1#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum Mode {
27 #[default]
29 Deny,
30 Ask,
32 Warn,
34}
35
36impl Mode {
37 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 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#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Steer {
66 pub rule_id: &'static str,
68 pub tool: &'static str,
70 pub suggestion: String,
72 pub note: &'static str,
74}
75
76impl Steer {
77 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#[derive(Debug, Clone, PartialEq, Eq)]
93enum Tok {
94 Word(String),
95 Pipe,
96 And,
97 Or,
98 Semi,
99}
100
101fn lex(cmd: &str) -> Vec<Tok> {
105 let mut toks = Vec::new();
106 let mut cur = String::new();
107 let mut have = false; let mut chars = cmd.chars().peekable();
109
110 fn flush(toks: &mut Vec<Tok>, cur: &mut String, have: &mut bool) {
111 if *have {
112 toks.push(Tok::Word(std::mem::take(cur)));
113 *have = false;
114 }
115 }
116
117 while let Some(c) = chars.next() {
118 match c {
119 '\'' => {
120 have = true;
121 for d in chars.by_ref() {
122 if d == '\'' {
123 break;
124 }
125 cur.push(d);
126 }
127 }
128 '"' => {
129 have = true;
130 while let Some(d) = chars.next() {
131 if d == '"' {
132 break;
133 }
134 if d == '\\' {
135 if let Some(e) = chars.next() {
136 cur.push(e);
137 }
138 } else {
139 cur.push(d);
140 }
141 }
142 }
143 '\\' => {
144 if let Some(d) = chars.next() {
145 cur.push(d);
146 have = true;
147 }
148 }
149 '|' => {
150 flush(&mut toks, &mut cur, &mut have);
151 if chars.peek() == Some(&'|') {
152 chars.next();
153 toks.push(Tok::Or);
154 } else {
155 toks.push(Tok::Pipe);
156 }
157 }
158 '&' => {
159 flush(&mut toks, &mut cur, &mut have);
160 if chars.peek() == Some(&'&') {
161 chars.next();
162 toks.push(Tok::And);
163 } else {
164 toks.push(Tok::Semi); }
166 }
167 ';' => {
168 flush(&mut toks, &mut cur, &mut have);
169 toks.push(Tok::Semi);
170 }
171 '>' | '<' | '(' | ')' | '{' | '}' | '`' => {
173 flush(&mut toks, &mut cur, &mut have);
174 }
175 c if c.is_whitespace() => flush(&mut toks, &mut cur, &mut have),
176 _ => {
177 cur.push(c);
178 have = true;
179 }
180 }
181 }
182 flush(&mut toks, &mut cur, &mut have);
183 toks
184}
185
186fn control_segments(toks: &[Tok]) -> (Vec<Vec<Tok>>, Vec<Tok>) {
189 let mut segs = vec![Vec::new()];
190 let mut joiners = Vec::new();
191 for t in toks {
192 match t {
193 Tok::And | Tok::Or | Tok::Semi => {
194 joiners.push(t.clone());
195 segs.push(Vec::new());
196 }
197 other => segs.last_mut().unwrap().push(other.clone()),
198 }
199 }
200 if segs.last().is_some_and(Vec::is_empty) {
202 segs.pop();
203 joiners.pop();
204 }
205 (segs, joiners)
206}
207
208fn pipe_stages(seg: &[Tok]) -> Vec<Vec<String>> {
211 let mut stages = vec![Vec::new()];
212 for t in seg {
213 match t {
214 Tok::Pipe => stages.push(Vec::new()),
215 Tok::Word(w) => stages.last_mut().unwrap().push(w.clone()),
216 _ => {}
217 }
218 }
219 stages
220}
221
222fn base_name(w: &str) -> &str {
226 w.rsplit(['/', '\\']).next().unwrap_or(w)
227}
228
229fn cmd_of(stage: &[String]) -> Option<&str> {
231 stage.first().map(|w| base_name(w))
232}
233
234fn has_short(stage: &[String], ch: char) -> bool {
237 stage
238 .iter()
239 .any(|w| w.starts_with('-') && !w.starts_with("--") && w[1..].chars().any(|c| c == ch))
240}
241
242fn has_flag(stage: &[String], flag: &str) -> bool {
244 stage
245 .iter()
246 .any(|w| w == flag || w.starts_with(&format!("{flag}=")))
247}
248
249fn flag_value<'a>(stage: &'a [String], names: &[&str]) -> Option<&'a str> {
251 for (i, w) in stage.iter().enumerate() {
252 for n in names {
253 if w == n {
254 return stage.get(i + 1).map(String::as_str);
255 }
256 let eq = format!("{n}=");
257 if let Some(v) = w.strip_prefix(&eq) {
258 return Some(v);
259 }
260 }
261 }
262 None
263}
264
265fn positionals(stage: &[String]) -> Vec<&str> {
269 stage
270 .iter()
271 .skip(1)
272 .filter(|w| !w.starts_with('-'))
273 .map(String::as_str)
274 .collect()
275}
276
277fn find_base(find: &[String]) -> Option<&str> {
280 find.get(1)
281 .filter(|w| !w.starts_with('-'))
282 .map(String::as_str)
283}
284
285fn q(s: &str) -> String {
287 format!("'{}'", s.replace('\'', "'\\''"))
288}
289
290pub fn analyze(command: &str) -> Option<Steer> {
304 let toks = lex(command);
305 if toks.is_empty() {
306 return None;
307 }
308 let (segs, joiners) = control_segments(&toks);
309 let seg_stages: Vec<Vec<Vec<String>>> = segs.iter().map(|s| pipe_stages(s)).collect();
310
311 let touches_ct = seg_stages.iter().flatten().flatten().any(|w| {
315 let b = base_name(w);
316 b == "ct" || b.starts_with("ct-")
317 });
318 if touches_ct {
319 return None;
320 }
321
322 if let Some(first) = seg_stages
326 .first()
327 .and_then(|s| s.first())
328 .and_then(|s| cmd_of(s))
329 && matches!(first, "for" | "while" | "until")
330 {
331 let waits = seg_stages
332 .iter()
333 .flatten()
334 .flatten()
335 .any(|w| matches!(base_name(w), "sleep" | "usleep" | "Start-Sleep"));
336 return Some(if waits {
337 Steer {
338 rule_id: "wait-loop",
339 tool: "ct await",
340 suggestion: "ct await --timeout <SECS> --every <N> -- <probe-argv>".to_string(),
341 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`",
342 }
343 } else {
344 Steer {
345 rule_id: "shell-loop",
346 tool: "ct each",
347 suggestion: "ct each --items <a> <b> -- <cmd-template-with-{ITEM}>".to_string(),
348 note: "ct each runs a command template once per item with no shell and an aggregate --expect verdict",
349 }
350 });
351 }
352
353 if segs.len() == 1 {
355 return analyze_segment(&seg_stages[0]);
356 }
357
358 let matches: Vec<Steer> = seg_stages
363 .iter()
364 .filter_map(|st| analyze_segment(st))
365 .collect();
366 if matches.len() == segs.len() && !joiners.is_empty() {
367 if joiners.iter().all(|j| *j == Tok::And) {
368 return Some(chain_steer("ct and", &matches));
369 }
370 if joiners.iter().all(|j| *j == Tok::Or) {
371 return Some(chain_steer("ct or", &matches));
372 }
373 }
374 None
375}
376
377fn chain_steer(head: &'static str, parts: &[Steer]) -> Steer {
380 let body = parts
381 .iter()
382 .map(|p| p.suggestion.trim_start_matches("ct ").to_string())
383 .collect::<Vec<_>>()
384 .join(" ::: ");
385 let (rule_id, note) = if head == "ct and" {
386 (
387 "and-chain",
388 "ct and runs each step in turn, stopping at the first failure — a shell-less && with no quoting",
389 )
390 } else {
391 (
392 "or-chain",
393 "ct or runs each step in turn, stopping at the first success — a shell-less || with no quoting",
394 )
395 };
396 Steer {
397 rule_id,
398 tool: head,
399 suggestion: format!("{head} {body}"),
400 note,
401 }
402}
403
404fn analyze_segment(stages: &[Vec<String>]) -> Option<Steer> {
407 rule_find_grep(stages)
408 .or_else(|| rule_grep_recursive(stages))
409 .or_else(|| rule_grep_count(stages))
410 .or_else(|| rule_sed_inplace(stages))
411 .or_else(|| rule_read_range(stages))
412 .or_else(|| rule_interpreter_read(stages))
413 .or_else(|| rule_find_files(stages))
414 .or_else(|| rule_list_recursive(stages))
415 .or_else(|| rule_count_lines(stages))
416}
417
418fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
420 let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
421 let grep_stage = stages
423 .iter()
424 .find(|s| s.iter().any(|w| base_name(w) == "grep"))?;
425 let glob = flag_value(find, &["-name", "-iname"]);
426 let pat = grep_pattern(grep_stage);
427 Some(Steer {
428 rule_id: "find-grep",
429 tool: "ct search",
430 suggestion: search_suggestion(find_base(find), glob, pat),
431 note: "ct search recurses, filters by name/type/size, and greps in one declarative pass — find | xargs grep in a single command",
432 })
433}
434
435fn rule_grep_recursive(stages: &[Vec<String>]) -> Option<Steer> {
437 for s in stages {
438 let Some(cmd) = cmd_of(s) else { continue };
439 let recursive_grep =
440 cmd == "grep" && (has_short(s, 'r') || has_short(s, 'R') || has_flag(s, "--recursive"));
441 if recursive_grep || matches!(cmd, "rg" | "ripgrep" | "ag") {
442 let pat = grep_pattern(s);
443 let base = positionals(s).get(1).copied();
445 return Some(Steer {
446 rule_id: "grep-recursive",
447 tool: "ct search",
448 suggestion: search_suggestion(base, None, pat),
449 note: "ct search is the suite's recursive content search, with a framed --expect verdict (e.g. --expect none asserts absence)",
450 });
451 }
452 }
453 None
454}
455
456fn rule_grep_count(stages: &[Vec<String>]) -> Option<Steer> {
458 for s in stages {
459 let Some(cmd) = cmd_of(s) else { continue };
460 if matches!(cmd, "grep" | "egrep" | "fgrep") && has_short(s, 'c') {
461 let base = positionals(s).get(1).copied();
463 return Some(Steer {
464 rule_id: "grep-count",
465 tool: "ct search",
466 suggestion: format!(
467 "{} --summary",
468 search_suggestion(base, None, grep_pattern(s))
469 ),
470 note: "ct search --summary reports the match count directly (and --expect +N|=N turns it into a pass/fail assertion), replacing grep -c",
471 });
472 }
473 }
474 None
475}
476
477fn rule_interpreter_read(stages: &[Vec<String>]) -> Option<Steer> {
482 for s in stages {
483 let Some(cmd) = cmd_of(s) else { continue };
484 if cmd == "jq" {
486 if let Some(&file) = positionals(s).get(1) {
487 return Some(interpreter_steer(Some(file)));
488 }
489 continue;
490 }
491 let interp = matches!(
493 cmd,
494 "python" | "python3" | "node" | "nodejs" | "perl" | "ruby"
495 );
496 if interp
497 && let Some(body) = flag_value(s, &["-c", "-e"])
498 && reads_file(body)
499 && !writes_file(body)
500 {
501 return Some(interpreter_steer(quoted_path(body)));
502 }
503 }
504 None
505}
506
507fn rule_find_files(stages: &[Vec<String>]) -> Option<Steer> {
509 let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
510 let glob = flag_value(find, &["-name", "-iname"])?;
511 let base = find_base(find);
512 Some(Steer {
513 rule_id: "find-files",
514 tool: "ct search",
515 suggestion: search_suggestion(base, Some(glob), None),
516 note: "ct search selects files by --name/--type/--size and reports them, replacing a bare find",
517 })
518}
519
520fn rule_sed_inplace(stages: &[Vec<String>]) -> Option<Steer> {
522 let stage = stages.iter().find(|s| {
523 let cmd = cmd_of(s);
524 let sed_i =
525 cmd == Some("sed") && s.iter().any(|w| w.starts_with("-i") || w == "--in-place");
526 let perl_i = cmd == Some("perl") && s.iter().any(|w| w.starts_with("-i"));
527 sed_i || perl_i
528 })?;
529 let (find, replace) = sed_subst(stage);
530 let suggestion = match (find, replace) {
531 (Some(f), Some(r)) => format!(
532 "ct edit --base . --find {} --replace {} --expect =1 --dry-run",
533 q(f),
534 q(r)
535 ),
536 _ => "ct edit --base . --find <text> --replace <text> --expect =1 --dry-run".to_string(),
537 };
538 Some(Steer {
539 rule_id: "sed-inplace",
540 tool: "ct edit",
541 suggestion,
542 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",
543 })
544}
545
546fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
548 for s in stages {
550 if cmd_of(s) == Some("sed")
551 && has_flag(s, "-n")
552 && let Some((a, b)) = positionals(s).into_iter().find_map(parse_sed_range)
553 {
554 let file = positionals(s).into_iter().find(|&w| !is_sed_script(w));
555 return Some(view_steer(file, Some((a, b))));
556 }
557 }
558 for (i, s) in stages.iter().enumerate() {
560 let cmd = cmd_of(s);
561 if cmd != Some("head") && cmd != Some("tail") {
562 continue;
563 }
564 let n = head_count(s);
565 let own = positionals(s)
568 .into_iter()
569 .find(|w| w.parse::<u64>().is_err());
570 let upstream = (i > 0 && cmd_of(&stages[i - 1]) == Some("cat"))
571 .then(|| positionals(&stages[i - 1]).into_iter().next())
572 .flatten();
573 let file = own.or(upstream)?; let range = match (cmd, n) {
575 (Some("head"), Some(n)) => Some((1, n)),
576 _ => None, };
578 return Some(view_steer(Some(file), range));
579 }
580 None
581}
582
583fn rule_list_recursive(stages: &[Vec<String>]) -> Option<Steer> {
585 let stage = stages
586 .iter()
587 .find(|s| cmd_of(s) == Some("tree") || (cmd_of(s) == Some("ls") && has_short(s, 'R')))?;
588 let base = positionals(stage).first().copied();
589 let suggestion = match base {
590 Some(b) => format!("ct tree --base {b}"),
591 None => "ct tree".to_string(),
592 };
593 Some(Steer {
594 rule_id: "list-recursive",
595 tool: "ct tree",
596 suggestion,
597 note: "ct tree reports the file tree with per-file line/word/char counts, filtering and sorting — a richer, bounded ls -R / tree",
598 })
599}
600
601fn rule_count_lines(stages: &[Vec<String>]) -> Option<Steer> {
605 for (i, s) in stages.iter().enumerate() {
606 if cmd_of(s) != Some("wc") {
607 continue;
608 }
609 let has_files = !positionals(s).is_empty();
610 let upstream = i.checked_sub(1).map(|j| &stages[j]);
611 let from_find = upstream.is_some_and(|u| matches!(cmd_of(u), Some("find") | Some("ls")));
612 let from_cat =
613 upstream.is_some_and(|u| cmd_of(u) == Some("cat") && !positionals(u).is_empty());
614 if has_files || from_find || from_cat {
615 return Some(Steer {
616 rule_id: "count-lines",
617 tool: "ct tree",
618 suggestion: "ct tree --summary".to_string(),
619 note: "ct tree reports per-file and total line/word/char counts directly, replacing wc -l over a file set",
620 });
621 }
622 }
623 None
624}
625
626fn search_suggestion(base: Option<&str>, name: Option<&str>, grep: Option<&str>) -> String {
630 let mut out = String::from("ct search");
631 if let Some(b) = base {
632 out.push_str(&format!(" --base {b}"));
633 }
634 if let Some(n) = name {
635 out.push_str(&format!(" --name {}", q(n)));
636 }
637 match grep {
638 Some(g) => out.push_str(&format!(" --grep {}", q(g))),
639 None => out.push_str(" --grep <pattern>"),
640 }
641 out
642}
643
644fn view_steer(file: Option<&str>, range: Option<(u32, u32)>) -> Steer {
646 let f = file.unwrap_or("<file>");
647 let suggestion = match range {
648 Some((a, b)) => format!("ct view {f} --range {a}:{b}"),
649 None => format!("ct view {f} --range <start>:<end>"),
650 };
651 Steer {
652 rule_id: "read-range",
653 tool: "ct view",
654 suggestion,
655 note: "ct view shows a file's lines by range (or the regions around a pattern with context) — a precise, bounded read",
656 }
657}
658
659fn interpreter_steer(file: Option<&str>) -> Steer {
661 let f = file.unwrap_or("<file>");
662 Steer {
663 rule_id: "interpreter-read",
664 tool: "ct view",
665 suggestion: format!("ct view {f} --range <start>:<end>"),
666 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",
667 }
668}
669
670fn reads_file(body: &str) -> bool {
672 const READS: &[&str] = &[
673 "open(",
674 "json.load",
675 "readlines",
676 "read_text",
677 "readFileSync",
678 "JSON.parse",
679 "File.read",
680 "IO.read",
681 "Get-Content",
682 ];
683 READS.iter().any(|m| body.contains(m))
684}
685
686fn writes_file(body: &str) -> bool {
689 const WRITES: &[&str] = &[
690 ",'w'",
691 ", 'w'",
692 ",\"w\"",
693 ", \"w\"",
694 ",'a'",
695 ", 'a'",
696 ",\"a\"",
697 "'r+'",
698 "\"r+\"",
699 "'wb'",
700 "\"wb\"",
701 ".write(",
702 "writeFile",
703 "json.dump",
704 "to_csv",
705 "to_json(",
706 "File.write",
707 ];
708 WRITES.iter().any(|m| body.contains(m))
709}
710
711fn quoted_path(body: &str) -> Option<&str> {
714 let bytes = body.as_bytes();
715 let mut i = 0;
716 while i < bytes.len() {
717 let c = bytes[i];
718 if (c == b'\'' || c == b'"')
719 && let Some(rel) = body[i + 1..].find(c as char)
720 {
721 let inner = &body[i + 1..i + 1 + rel];
722 if inner.contains('.') || inner.contains('/') {
723 return Some(inner);
724 }
725 i += 1 + rel + 1;
726 continue;
727 }
728 i += 1;
729 }
730 None
731}
732
733fn grep_pattern(stage: &[String]) -> Option<&str> {
737 if let Some(v) = flag_value(stage, &["-e", "--regexp"]) {
738 return Some(v);
739 }
740 let start = stage
741 .iter()
742 .position(|w| {
743 matches!(
744 base_name(w),
745 "grep" | "egrep" | "fgrep" | "rg" | "ripgrep" | "ag"
746 )
747 })
748 .map_or(1, |i| i + 1);
749 stage[start..]
750 .iter()
751 .find(|w| !w.starts_with('-'))
752 .map(String::as_str)
753}
754
755fn sed_subst(stage: &[String]) -> (Option<&str>, Option<&str>) {
757 for w in stage.iter().skip(1) {
758 if let Some(rest) = w.strip_prefix('s')
759 && let Some(delim) = rest.chars().next()
760 && !delim.is_alphanumeric()
761 {
762 let parts: Vec<&str> = rest[delim.len_utf8()..].split(delim).collect();
763 if parts.len() >= 2 {
764 return (Some(parts[0]), Some(parts[1]));
765 }
766 }
767 }
768 (None, None)
769}
770
771fn head_count(stage: &[String]) -> Option<u32> {
773 if let Some(v) = flag_value(stage, &["-n", "--lines"])
774 && let Ok(n) = v.parse::<u32>()
775 {
776 return Some(n);
777 }
778 stage
779 .iter()
780 .skip(1)
781 .find_map(|w| w.strip_prefix('-').and_then(|d| d.parse::<u32>().ok()))
782}
783
784fn is_sed_script(w: &str) -> bool {
788 if parse_sed_range(w).is_some() {
789 return true;
790 }
791 let mut ch = w.chars();
792 ch.next() == Some('s') && ch.next().is_some_and(|d| !d.is_alphanumeric())
793}
794
795fn parse_sed_range(w: &str) -> Option<(u32, u32)> {
797 let body = w.strip_suffix('p').unwrap_or(w);
798 match body.split_once(',') {
799 Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
800 None => {
801 let n = body.parse().ok()?;
802 Some((n, n))
803 }
804 }
805}
806
807pub fn grep_steer(pattern: &str, path: Option<&str>, glob: Option<&str>) -> Steer {
817 Steer {
818 rule_id: "harness-grep",
819 tool: "ct search",
820 suggestion: search_suggestion(path, glob, Some(pattern)),
821 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",
822 }
823}
824
825pub fn glob_steer(pattern: &str, path: Option<&str>) -> Steer {
827 let (glob_base, name) = split_glob(pattern);
828 let base = path.map(str::to_string).or(glob_base);
829 let mut out = String::from("ct search");
830 if let Some(b) = base {
831 out.push_str(&format!(" --base {b}"));
832 }
833 out.push_str(&format!(" --name {} --type f", q(&name)));
834 Steer {
835 rule_id: "harness-glob",
836 tool: "ct search",
837 suggestion: out,
838 note: "ct search selects files by --name/--type/--size from a chosen root and reports them — the suite's glob, recursive by default",
839 }
840}
841
842fn split_glob(pattern: &str) -> (Option<String>, String) {
846 let segs: Vec<&str> = pattern.split('/').collect();
847 let name = segs.last().copied().unwrap_or(pattern).to_string();
848 let is_wild = |s: &str| s.contains(['*', '?', '[', '{']);
849 let literal: Vec<&str> = segs
850 .iter()
851 .take(segs.len().saturating_sub(1))
852 .take_while(|s| !is_wild(s) && !s.is_empty())
853 .copied()
854 .collect();
855 ((!literal.is_empty()).then(|| literal.join("/")), name)
856}
857
858pub fn read_steer(file_path: &str, offset: Option<i64>, limit: Option<i64>) -> Option<Steer> {
862 if is_unrenderable(file_path) {
863 return None;
864 }
865 let range = match (offset, limit) {
868 (Some(o), Some(l)) => {
869 let start = o.max(1);
870 Some(format!("{start}:{}", (start + l - 1).max(start)))
871 }
872 (Some(o), None) => Some(format!("{}:", o.max(1))),
873 (None, Some(l)) => Some(format!("1:{}", l.max(1))),
874 (None, None) => None,
875 };
876 let suggestion = match range {
877 Some(r) => format!("ct view {file_path} --range {r}"),
878 None => format!("ct view {file_path}"),
879 };
880 Some(Steer {
881 rule_id: "harness-read",
882 tool: "ct view",
883 suggestion,
884 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)",
885 })
886}
887
888fn is_unrenderable(path: &str) -> bool {
891 const EXTS: &[&str] = &[
892 "png", "jpg", "jpeg", "gif", "bmp", "webp", "ico", "tif", "tiff", "pdf", "ipynb",
893 ];
894 let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
895 path.contains('.') && EXTS.contains(&ext.as_str())
896}
897
898pub mod hook {
903 use super::{Mode, Steer, analyze, glob_steer, grep_steer, read_steer};
904 use serde_json::{Value, json};
905
906 pub fn decision(steer: &Steer, mode: Mode) -> Value {
908 let reason = steer.reason();
909 match mode {
910 Mode::Deny => json!({"hookSpecificOutput": {
911 "hookEventName": "PreToolUse",
912 "permissionDecision": "deny",
913 "permissionDecisionReason": reason,
914 }}),
915 Mode::Ask => json!({"hookSpecificOutput": {
916 "hookEventName": "PreToolUse",
917 "permissionDecision": "ask",
918 "permissionDecisionReason": reason,
919 }}),
920 Mode::Warn => json!({"hookSpecificOutput": {
921 "hookEventName": "PreToolUse",
922 "additionalContext": reason,
923 }}),
924 }
925 }
926
927 fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> {
929 input.get(key).and_then(Value::as_str)
930 }
931
932 fn int_field(input: &Value, key: &str) -> Option<i64> {
934 input.get(key).and_then(Value::as_i64)
935 }
936
937 pub fn process(envelope: &str, mode: Mode) -> Option<Value> {
943 let v: Value = serde_json::from_str(envelope).ok()?;
944 let tool = v.get("tool_name").and_then(Value::as_str)?;
945 let input = v.get("tool_input")?;
946 let steer = match tool {
947 "Bash" => analyze(str_field(input, "command")?),
948 "Grep" => Some(grep_steer(
949 str_field(input, "pattern")?,
950 str_field(input, "path"),
951 str_field(input, "glob"),
952 )),
953 "Glob" => Some(glob_steer(
954 str_field(input, "pattern")?,
955 str_field(input, "path"),
956 )),
957 "Read" => read_steer(
958 str_field(input, "file_path")?,
959 int_field(input, "offset"),
960 int_field(input, "limit"),
961 ),
962 _ => None,
963 }?;
964 Some(decision(&steer, mode))
965 }
966}
967
968pub mod install {
976 use super::Mode;
977 use crate::patch::{self, Op, parse_path};
978 use serde_json::{Value, json};
979 use std::path::{Path, PathBuf};
980
981 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
983 pub enum Scope {
984 Project,
986 Local,
988 User,
990 }
991
992 impl Scope {
993 pub fn from_name(s: &str) -> Option<Scope> {
995 match s {
996 "project" => Some(Scope::Project),
997 "local" => Some(Scope::Local),
998 "user" => Some(Scope::User),
999 _ => None,
1000 }
1001 }
1002
1003 pub fn path(self, root: &Path, home: &Path) -> PathBuf {
1006 match self {
1007 Scope::Project => root.join(".claude").join("settings.json"),
1008 Scope::Local => root.join(".claude").join("settings.local.json"),
1009 Scope::User => home.join(".claude").join("settings.json"),
1010 }
1011 }
1012 }
1013
1014 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1017 pub enum Tool {
1018 Bash,
1020 Grep,
1022 Glob,
1024 Read,
1026 }
1027
1028 impl Tool {
1029 pub fn from_name(s: &str) -> Option<Tool> {
1031 match s {
1032 "Bash" => Some(Tool::Bash),
1033 "Grep" => Some(Tool::Grep),
1034 "Glob" => Some(Tool::Glob),
1035 "Read" => Some(Tool::Read),
1036 _ => None,
1037 }
1038 }
1039
1040 pub fn matcher(self) -> &'static str {
1042 match self {
1043 Tool::Bash => "Bash",
1044 Tool::Grep => "Grep",
1045 Tool::Glob => "Glob",
1046 Tool::Read => "Read",
1047 }
1048 }
1049 }
1050
1051 pub fn hook_command(mode: Mode) -> String {
1053 match mode {
1054 Mode::Deny => "ct steer hook".to_string(),
1055 other => format!("ct steer hook --mode {}", other.name()),
1056 }
1057 }
1058
1059 fn is_steer_command(s: &str) -> bool {
1061 s.contains("steer") && s.contains("hook")
1062 }
1063
1064 fn inspect(text: &str) -> Result<Value, String> {
1068 let root = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
1069 .map_err(|e| format!("parse settings: {e}"))?
1070 .unwrap_or_else(|| json!({}));
1071 if !root.is_object() {
1072 return Err("settings root must be a JSON object".to_string());
1073 }
1074 Ok(root)
1075 }
1076
1077 fn canonical(command: &str, tools: &[Tool]) -> String {
1081 let matchers: Vec<Value> = tools
1082 .iter()
1083 .map(|t| {
1084 json!({ "matcher": t.matcher(), "hooks": [ { "type": "command", "command": command } ] })
1085 })
1086 .collect();
1087 let v = json!({ "hooks": { "PreToolUse": matchers } });
1088 serde_json::to_string_pretty(&v).unwrap() + "\n"
1089 }
1090
1091 fn op_set(path: &str, value: String) -> Result<Op, String> {
1092 Ok(Op::Set {
1093 path: parse_path(path)?,
1094 raw: path.to_string(),
1095 value,
1096 })
1097 }
1098 fn op_add(path: &str, value: String) -> Result<Op, String> {
1099 Ok(Op::Add {
1100 path: parse_path(path)?,
1101 raw: path.to_string(),
1102 value,
1103 })
1104 }
1105 fn op_delete(path: &str) -> Result<Op, String> {
1106 Ok(Op::Delete {
1107 path: parse_path(path)?,
1108 raw: path.to_string(),
1109 })
1110 }
1111
1112 fn apply(text: &str, ops: &[Op]) -> Result<(String, bool), String> {
1115 if ops.is_empty() {
1116 return Ok((text.to_string(), false));
1117 }
1118 let (out, changes) =
1119 patch::apply_doc(text, ops).map_err(|e| format!("settings merge: {e}"))?;
1120 Ok((out, changes > 0))
1121 }
1122
1123 pub fn install(
1129 existing: Option<&str>,
1130 command: &str,
1131 tools: &[Tool],
1132 ) -> Result<(String, bool), String> {
1133 let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1134 return Ok((canonical(command, tools), true));
1135 };
1136 let root = inspect(text)?;
1137 let ops = install_ops(&root, command, tools)?;
1138 apply(text, &ops)
1139 }
1140
1141 pub fn uninstall(existing: Option<&str>) -> Result<(String, bool), String> {
1145 let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1146 return Ok((existing.unwrap_or_default().to_string(), false));
1147 };
1148 let root = inspect(text)?;
1149 let ops = uninstall_ops(&root)?;
1150 apply(text, &ops)
1151 }
1152
1153 fn pre_array(root: &Value) -> Option<&Vec<Value>> {
1155 root.get("hooks")
1156 .and_then(|h| h.get("PreToolUse"))
1157 .and_then(Value::as_array)
1158 }
1159
1160 fn is_matcher(entry: &Value, name: &str) -> bool {
1162 entry.get("matcher").and_then(Value::as_str) == Some(name)
1163 }
1164
1165 fn entry_has_steer(entry: &Value) -> bool {
1167 entry
1168 .get("hooks")
1169 .and_then(Value::as_array)
1170 .is_some_and(|l| {
1171 l.iter().any(|h| {
1172 h.get("command")
1173 .and_then(Value::as_str)
1174 .is_some_and(is_steer_command)
1175 })
1176 })
1177 }
1178
1179 fn install_ops(root: &Value, command: &str, tools: &[Tool]) -> Result<Vec<Op>, String> {
1183 let mut ops = Vec::new();
1184
1185 let hooks = root.get("hooks");
1187 match hooks {
1188 None => ops.push(op_set(".hooks", "{}".to_string())?),
1189 Some(h) if !h.is_object() => {
1190 return Err("settings `hooks` must be an object".to_string());
1191 }
1192 Some(_) => {}
1193 }
1194 let pre = hooks.and_then(|h| h.get("PreToolUse"));
1195 match pre {
1196 None => ops.push(op_set(".hooks.PreToolUse", "[]".to_string())?),
1197 Some(p) if !p.is_array() => {
1198 return Err("settings `hooks.PreToolUse` must be an array".to_string());
1199 }
1200 Some(_) => {}
1201 }
1202 let pre_arr = pre.and_then(Value::as_array);
1203
1204 if let Some(arr) = pre_arr {
1206 for (ei, entry) in arr.iter().enumerate() {
1207 let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1208 continue;
1209 };
1210 for (hi, h) in list.iter().enumerate() {
1211 if let Some(c) = h.get("command").and_then(Value::as_str)
1212 && is_steer_command(c)
1213 && c != command
1214 {
1215 ops.push(op_set(
1216 &format!(".hooks.PreToolUse[{ei}].hooks[{hi}].command"),
1217 json!(command).to_string(),
1218 )?);
1219 }
1220 }
1221 }
1222 }
1223
1224 let hook_obj = json!({ "type": "command", "command": command }).to_string();
1226 for tool in tools {
1227 let name = tool.matcher();
1228 if pre_arr.is_some_and(|arr| {
1230 arr.iter()
1231 .any(|e| is_matcher(e, name) && entry_has_steer(e))
1232 }) {
1233 continue;
1234 }
1235 let target =
1237 pre_arr.and_then(|arr| arr.iter().enumerate().find(|(_, e)| is_matcher(e, name)));
1238 match target {
1239 Some((ei, e)) if e.get("hooks").and_then(Value::as_array).is_some() => {
1240 ops.push(op_add(
1241 &format!(".hooks.PreToolUse[{ei}].hooks"),
1242 hook_obj.clone(),
1243 )?);
1244 }
1245 Some((ei, _)) => {
1246 ops.push(op_set(
1247 &format!(".hooks.PreToolUse[{ei}].hooks"),
1248 format!("[{hook_obj}]"),
1249 )?);
1250 }
1251 None => {
1252 let matcher = json!({ "matcher": name, "hooks": [ { "type": "command", "command": command } ] })
1253 .to_string();
1254 ops.push(op_add(".hooks.PreToolUse", matcher)?);
1255 }
1256 }
1257 }
1258 Ok(ops)
1259 }
1260
1261 fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
1263 let Some(pre) = pre_array(root) else {
1264 return Ok(vec![]);
1265 };
1266 let mut whole_entries = Vec::new(); let mut partial = Vec::new(); for (ei, entry) in pre.iter().enumerate() {
1271 let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1272 continue;
1273 };
1274 let ours: Vec<usize> = list
1275 .iter()
1276 .enumerate()
1277 .filter(|(_, h)| {
1278 h.get("command")
1279 .and_then(Value::as_str)
1280 .is_some_and(is_steer_command)
1281 })
1282 .map(|(hi, _)| hi)
1283 .collect();
1284 if ours.is_empty() {
1285 continue;
1286 }
1287 if ours.len() == list.len() {
1288 whole_entries.push(ei);
1289 } else {
1290 partial.push((ei, ours));
1291 }
1292 }
1293 if whole_entries.is_empty() && partial.is_empty() {
1294 return Ok(vec![]);
1295 }
1296
1297 if partial.is_empty() && whole_entries.len() == pre.len() {
1300 let hooks_solo = root
1301 .get("hooks")
1302 .and_then(Value::as_object)
1303 .is_some_and(|o| o.len() == 1);
1304 let path = if hooks_solo {
1305 ".hooks"
1306 } else {
1307 ".hooks.PreToolUse"
1308 };
1309 return Ok(vec![op_delete(path)?]);
1310 }
1311
1312 let mut ops = Vec::new();
1313 for (ei, his) in &partial {
1317 for hi in his.iter().rev() {
1318 ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}].hooks[{hi}]"))?);
1319 }
1320 }
1321 for ei in whole_entries.iter().rev() {
1322 ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}]"))?);
1323 }
1324 Ok(ops)
1325 }
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330 use super::install::{Scope, Tool, install, uninstall};
1331 use super::*;
1332 use std::path::Path;
1333
1334 fn install_bash(existing: Option<&str>, command: &str) -> Result<(String, bool), String> {
1336 install(existing, command, &[Tool::Bash])
1337 }
1338
1339 fn tool(cmd: &str) -> Option<&'static str> {
1340 analyze(cmd).map(|s| s.tool)
1341 }
1342 fn rule(cmd: &str) -> Option<&'static str> {
1343 analyze(cmd).map(|s| s.rule_id)
1344 }
1345
1346 #[test]
1347 fn steers_high_confidence_idioms() {
1348 assert_eq!(
1349 tool("find . -name '*.rs' | xargs grep TODO"),
1350 Some("ct search")
1351 );
1352 assert_eq!(
1353 rule("find . -name '*.rs' | xargs grep TODO"),
1354 Some("find-grep")
1355 );
1356 assert_eq!(tool("grep -rn TODO src"), Some("ct search"));
1357 assert_eq!(tool("rg TODO src"), Some("ct search"));
1358 assert_eq!(tool("find src -name '*.rs'"), Some("ct search"));
1359 assert_eq!(tool("sed -i 's/foo/bar/g' src/x.rs"), Some("ct edit"));
1360 assert_eq!(tool("head -n 40 src/lib.rs"), Some("ct view"));
1361 assert_eq!(tool("cat src/lib.rs | head -n 20"), Some("ct view"));
1362 assert_eq!(tool("sed -n '10,20p' src/lib.rs"), Some("ct view"));
1363 assert_eq!(tool("ls -R src"), Some("ct tree"));
1364 assert_eq!(tool("wc -l src/lib.rs"), Some("ct tree"));
1365 assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1366 assert_eq!(
1367 rule("for f in a b; do grep -r x $f; done"),
1368 Some("shell-loop")
1369 );
1370 }
1371
1372 #[test]
1373 fn steers_wait_loops_to_await_not_each() {
1374 assert_eq!(
1376 tool("for i in $(seq 1 900); do cat f; sleep 2; done"),
1377 Some("ct await")
1378 );
1379 assert_eq!(
1380 rule("for i in $(seq 1 900); do cat f; sleep 2; done"),
1381 Some("wait-loop")
1382 );
1383 assert_eq!(
1384 tool("while true; do check; sleep 5; done"),
1385 Some("ct await")
1386 );
1387 assert_eq!(
1388 tool("until curl -sf http://x; do sleep 3; done"),
1389 Some("ct await")
1390 );
1391 assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1393 assert_eq!(
1394 rule("for f in a b; do grep -r x $f; done"),
1395 Some("shell-loop")
1396 );
1397 }
1398
1399 #[test]
1400 fn steers_interpreter_file_reads() {
1401 assert_eq!(tool("jq '.note' feedback/x.jsonl"), Some("ct view"));
1403 assert_eq!(
1404 rule("jq '.note' feedback/x.jsonl"),
1405 Some("interpreter-read")
1406 );
1407 let s = analyze(
1409 "python -c \"rows=[json.loads(l) for l in open('feedback/x.jsonl')]; print(rows[-1])\"",
1410 )
1411 .unwrap();
1412 assert_eq!(s.tool, "ct view");
1413 assert!(
1414 s.suggestion.contains("feedback/x.jsonl"),
1415 "{}",
1416 s.suggestion
1417 );
1418 assert_eq!(
1419 tool("node -e 'const d=require(\"fs\").readFileSync(\"a.json\")'"),
1420 Some("ct view")
1421 );
1422 assert!(analyze("python -c 'print(2+2)'").is_none());
1424 assert!(analyze("python -c \"open('out.txt','w').write('hi')\"").is_none());
1426 assert!(analyze("cat x | jq '.note'").is_none());
1428 }
1429
1430 #[test]
1431 fn steers_count_idioms() {
1432 assert_eq!(tool("grep -c TODO src/lib.rs"), Some("ct search"));
1434 assert_eq!(rule("grep -c TODO src/lib.rs"), Some("grep-count"));
1435 let s = analyze("grep -c TODO src/lib.rs").unwrap();
1436 assert!(s.suggestion.contains("--grep 'TODO'") && s.suggestion.contains("--summary"));
1437 assert_eq!(tool("cat a.jsonl b.jsonl | wc -l"), Some("ct tree"));
1439 assert!(analyze("ps aux | wc -l").is_none());
1441 }
1442
1443 #[test]
1444 fn extracts_obvious_slots() {
1445 let s = analyze("grep -rn TODO src").unwrap();
1446 assert!(s.suggestion.contains("--grep 'TODO'"), "{}", s.suggestion);
1447 let e = analyze("sed -i 's/foo/bar/g' f.rs").unwrap();
1448 assert!(
1449 e.suggestion.contains("--find 'foo'") && e.suggestion.contains("--replace 'bar'"),
1450 "{}",
1451 e.suggestion
1452 );
1453 let v = analyze("head -n 40 src/lib.rs").unwrap();
1454 assert!(
1455 v.suggestion.contains("src/lib.rs --range 1:40"),
1456 "{}",
1457 v.suggestion
1458 );
1459 let fg = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
1461 assert!(fg.suggestion.contains("--grep 'TODO'"), "{}", fg.suggestion);
1462 assert!(fg.suggestion.contains("--name '*.rs'"), "{}", fg.suggestion);
1463 }
1464
1465 #[test]
1466 fn chain_only_when_all_segments_serviceable() {
1467 let s = analyze("grep -r foo src && sed -i 's/a/b/' f.rs").unwrap();
1468 assert_eq!(s.tool, "ct and");
1469 assert!(
1470 s.suggestion.starts_with("ct and search"),
1471 "{}",
1472 s.suggestion
1473 );
1474 assert!(s.suggestion.contains(":::"), "{}", s.suggestion);
1475 assert!(analyze("grep -r foo src && make").is_none());
1477 }
1478
1479 #[test]
1480 fn allows_safe_and_unknown_commands() {
1481 assert!(analyze("git status").is_none());
1482 assert!(analyze("cargo build && cargo test").is_none());
1483 assert!(analyze("ls -la").is_none());
1484 assert!(analyze("cat file.txt").is_none()); assert!(analyze("grep TODO file.rs").is_none()); assert!(analyze("echo 'a | b && c'").is_none()); assert!(analyze("ps aux | head -n 5").is_none()); assert!(analyze("").is_none());
1489 }
1490
1491 #[test]
1492 fn never_resteers_a_ct_command() {
1493 assert!(analyze("ct search --grep TODO").is_none());
1494 assert!(analyze("ct-search --grep TODO").is_none());
1495 assert!(analyze("find . -name '*.rs' | xargs ct-edit").is_none());
1496 }
1497
1498 #[test]
1499 fn hook_decisions_respect_mode() {
1500 let envelope = r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"}}"#;
1501 let deny = hook::process(envelope, Mode::Deny).unwrap();
1502 assert_eq!(deny["hookSpecificOutput"]["permissionDecision"], "deny");
1503 assert!(
1504 deny["hookSpecificOutput"]["permissionDecisionReason"]
1505 .as_str()
1506 .unwrap()
1507 .contains("ct search")
1508 );
1509 let ask = hook::process(envelope, Mode::Ask).unwrap();
1510 assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
1511 let warn = hook::process(envelope, Mode::Warn).unwrap();
1512 assert!(warn["hookSpecificOutput"]["additionalContext"].is_string());
1513 assert!(
1514 warn["hookSpecificOutput"]
1515 .get("permissionDecision")
1516 .is_none()
1517 );
1518 }
1519
1520 #[test]
1521 fn hook_steers_harness_grep_glob_read() {
1522 let grep = hook::process(
1524 r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO","path":"src","glob":"*.rs"}}"#,
1525 Mode::Deny,
1526 )
1527 .unwrap();
1528 let reason = grep["hookSpecificOutput"]["permissionDecisionReason"]
1529 .as_str()
1530 .unwrap();
1531 assert!(reason.contains("ct search"), "{reason}");
1532 assert!(
1533 reason.contains("--grep 'TODO'") && reason.contains("--base src"),
1534 "{reason}"
1535 );
1536 assert!(reason.contains("--name '*.rs'"), "{reason}");
1537
1538 let s = glob_steer("src/**/*.rs", None);
1540 assert_eq!(s.tool, "ct search");
1541 assert!(s.suggestion.contains("--base src"), "{}", s.suggestion);
1542 assert!(s.suggestion.contains("--name '*.rs'"), "{}", s.suggestion);
1543
1544 let read = read_steer("src/lib.rs", Some(10), Some(20)).unwrap();
1546 assert_eq!(read.tool, "ct view");
1547 assert!(
1548 read.suggestion.contains("ct view src/lib.rs --range 10:29"),
1549 "{}",
1550 read.suggestion
1551 );
1552 assert_eq!(
1554 read_steer("notes.md", None, None).unwrap().suggestion,
1555 "ct view notes.md"
1556 );
1557 assert!(read_steer("diagram.png", None, None).is_none());
1559 assert!(read_steer("paper.pdf", None, None).is_none());
1560 assert!(read_steer("nb.ipynb", None, None).is_none());
1561 }
1562
1563 #[test]
1564 fn install_covers_multiple_tools() {
1565 let (text, changed) =
1566 install(None, "ct steer hook", &[Tool::Bash, Tool::Grep, Tool::Read]).unwrap();
1567 assert!(changed);
1568 for m in ["\"Bash\"", "\"Grep\"", "\"Read\""] {
1569 assert!(text.contains(m), "missing matcher {m} in {text}");
1570 }
1571 let (_, again) = install(
1573 Some(&text),
1574 "ct steer hook",
1575 &[Tool::Bash, Tool::Grep, Tool::Read],
1576 )
1577 .unwrap();
1578 assert!(!again);
1579 let (grown, did) = install(Some(&text), "ct steer hook", &[Tool::Glob]).unwrap();
1581 assert!(did);
1582 assert!(grown.contains("\"Glob\""));
1583 assert_eq!(grown.matches("\"matcher\"").count(), 4);
1584 let (cleared, _) = uninstall(Some(&grown)).unwrap();
1586 assert!(!cleared.contains("steer hook"));
1587 }
1588
1589 #[test]
1590 fn hook_fails_open() {
1591 assert!(hook::process("not json", Mode::Deny).is_none());
1592 assert!(hook::process(r#"{"tool_name":"Read"}"#, Mode::Deny).is_none());
1593 assert!(hook::process(r#"{"tool_name":"Bash","tool_input":{}}"#, Mode::Deny).is_none());
1594 assert!(
1595 hook::process(
1596 r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
1597 Mode::Deny
1598 )
1599 .is_none()
1600 );
1601 }
1602
1603 #[test]
1604 fn install_is_idempotent_and_preserves_other_settings() {
1605 let (text, changed) = install_bash(None, "ct steer hook").unwrap();
1607 assert!(changed);
1608 assert!(text.contains("PreToolUse"));
1609 assert!(text.contains("\"matcher\": \"Bash\""));
1610 assert!(text.contains("ct steer hook"));
1611 let (text2, changed2) = install_bash(Some(&text), "ct steer hook").unwrap();
1613 assert!(!changed2);
1614 assert_eq!(text, text2);
1615 let (text3, changed3) = install_bash(Some(&text), "ct steer hook --mode ask").unwrap();
1617 assert!(changed3);
1618 assert_eq!(text3.matches("steer hook").count(), 1);
1619 let existing = r#"{ "model": "opus", "hooks": { "PreToolUse": [] } }"#;
1621 let (merged, _) = install_bash(Some(existing), "ct steer hook").unwrap();
1622 assert!(merged.contains("\"model\": \"opus\""));
1623 }
1624
1625 #[test]
1626 fn uninstall_removes_only_our_hook() {
1627 let existing = r#"{
1628 "hooks": { "PreToolUse": [
1629 { "matcher": "Bash", "hooks": [
1630 { "type": "command", "command": "ct steer hook" },
1631 { "type": "command", "command": "./other.sh" }
1632 ] }
1633 ] }
1634 }"#;
1635 let (text, changed) = uninstall(Some(existing)).unwrap();
1636 assert!(changed);
1637 assert!(!text.contains("steer hook"));
1638 assert!(text.contains("./other.sh")); let (_, changed2) = uninstall(Some("{}")).unwrap();
1641 assert!(!changed2);
1642 }
1643
1644 #[test]
1645 fn install_and_uninstall_preserve_comments() {
1646 let existing = "{\n \
1648 // pin the model\n \
1649 \"model\": \"opus\", // do not change\n \
1650 \"hooks\": {\n \
1651 \"PreToolUse\": [\n \
1652 { \"matcher\": \"Bash\", \"hooks\": [ { \"type\": \"command\", \"command\": \"./guard.sh\" } ] }\n \
1653 ]\n }\n}\n";
1654 let (installed, changed) = install_bash(Some(existing), "ct steer hook").unwrap();
1655 assert!(changed);
1656 assert!(installed.contains("// pin the model"), "{installed}");
1658 assert!(installed.contains("// do not change"), "{installed}");
1659 assert!(installed.contains("./guard.sh"), "{installed}");
1661 assert!(installed.contains("ct steer hook"), "{installed}");
1662
1663 let (removed, changed2) = uninstall(Some(&installed)).unwrap();
1665 assert!(changed2);
1666 assert!(removed.contains("// pin the model"), "{removed}");
1667 assert!(removed.contains("./guard.sh"), "{removed}");
1668 assert!(!removed.contains("steer hook"), "{removed}");
1669 }
1670
1671 #[test]
1672 fn scope_paths() {
1673 let root = Path::new("/proj");
1674 let home = Path::new("/home/u");
1675 assert!(
1676 Scope::Project
1677 .path(root, home)
1678 .ends_with(".claude/settings.json")
1679 );
1680 assert!(
1681 Scope::Local
1682 .path(root, home)
1683 .ends_with(".claude/settings.local.json")
1684 );
1685 assert!(Scope::User.path(root, home).starts_with("/home/u"));
1686 }
1687}