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
87pub 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
106fn 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; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
114 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
119}
120
121pub const LOG_IGNORE_RULE: &str = "*log";
126
127pub 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#[derive(Debug, Clone, PartialEq, Eq)]
162enum Tok {
163 Word(String),
164 Pipe,
165 And,
166 Or,
167 Semi,
168}
169
170fn lex(cmd: &str) -> Vec<Tok> {
174 let mut toks = Vec::new();
175 let mut cur = String::new();
176 let mut have = false; 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); }
235 }
236 ';' => {
237 flush(&mut toks, &mut cur, &mut have);
238 toks.push(Tok::Semi);
239 }
240 '>' | '<' | '(' | ')' | '{' | '}' | '`' => {
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
255fn 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 if segs.last().is_some_and(Vec::is_empty) {
271 segs.pop();
272 joiners.pop();
273 }
274 (segs, joiners)
275}
276
277fn 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
291fn base_name(w: &str) -> &str {
295 w.rsplit(['/', '\\']).next().unwrap_or(w)
296}
297
298fn cmd_of(stage: &[String]) -> Option<&str> {
300 stage.first().map(|w| base_name(w))
301}
302
303fn 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
311fn has_flag(stage: &[String], flag: &str) -> bool {
313 stage
314 .iter()
315 .any(|w| w == flag || w.starts_with(&format!("{flag}=")))
316}
317
318fn 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
334fn 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
346fn find_base(find: &[String]) -> Option<&str> {
349 find.get(1)
350 .filter(|w| !w.starts_with('-'))
351 .map(String::as_str)
352}
353
354fn q(s: &str) -> String {
356 format!("'{}'", s.replace('\'', "'\\''"))
357}
358
359pub fn analyze(command: &str) -> Option<Steer> {
380 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
398fn 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
410enum LineKind {
412 Skip,
414 Ct(String),
416 Advisable(Steer),
418 Opaque,
420}
421
422fn 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
437fn 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
450fn 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
487fn 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 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; }
527 if opaque == 0 {
528 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 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
545pub fn pipeline_nudge(command: &str) -> Option<Steer> {
560 if analyze(command).is_some() {
561 return None; }
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
585fn 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 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 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 if segs.len() == 1 {
639 return analyze_segment(&seg_stages[0]);
640 }
641
642 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
661fn 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
688fn 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
702fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
704 let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
705 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
719fn 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 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
740fn 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 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
761fn rule_interpreter_read(stages: &[Vec<String>]) -> Option<Steer> {
766 for s in stages {
767 let Some(cmd) = cmd_of(s) else { continue };
768 if cmd == "jq" {
770 if let Some(&file) = positionals(s).get(1) {
771 return Some(interpreter_steer(Some(file)));
772 }
773 continue;
774 }
775 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
791fn 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
804fn 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
830fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
832 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 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 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)?; let range = match (cmd, n) {
859 (Some("head"), Some(n)) => Some((1, n)),
860 _ => None, };
862 return Some(view_steer(Some(file), range));
863 }
864 None
865}
866
867fn 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
885fn 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
910fn 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
928fn 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
943fn 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
954fn 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
970fn 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
995fn 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
1017fn 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
1039fn 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
1055fn 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
1068fn 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
1079fn 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
1091pub 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
1109pub 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
1126fn 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
1142pub 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 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
1172fn 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
1182pub mod hook {
1187 use super::{Mode, Steer, analyze, glob_steer, grep_steer, read_steer};
1188 use serde_json::{Value, json};
1189
1190 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 fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> {
1213 input.get(key).and_then(Value::as_str)
1214 }
1215
1216 fn int_field(input: &Value, key: &str) -> Option<i64> {
1218 input.get(key).and_then(Value::as_i64)
1219 }
1220
1221 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 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 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 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 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 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
1337pub 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 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1352 pub enum Scope {
1353 Project,
1355 Local,
1357 User,
1359 }
1360
1361 impl Scope {
1362 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 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 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1386 pub enum Tool {
1387 Bash,
1389 Grep,
1391 Glob,
1393 Read,
1395 All,
1398 }
1399
1400 impl Tool {
1401 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 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 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 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 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 fn is_steer_command(s: &str) -> bool {
1470 s.contains("steer") && (s.contains("hook") || s.contains("post"))
1471 }
1472
1473 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 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 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 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 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 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 fn is_matcher(entry: &Value, name: &str) -> bool {
1581 entry.get("matcher").and_then(Value::as_str) == Some(name)
1582 }
1583
1584 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 fn install_ops(root: &Value, command: &str, tools: &[Tool]) -> Result<Vec<Op>, String> {
1602 let mut ops = Vec::new();
1603
1604 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 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 let hook_obj = json!({ "type": "command", "command": command }).to_string();
1645 for tool in tools {
1646 let name = tool.matcher();
1647 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 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 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 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 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 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(); let mut partial = Vec::new(); 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 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 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 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 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 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 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 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 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 assert!(analyze("python -c 'print(2+2)'").is_none());
1927 assert!(analyze("python -c \"open('out.txt','w').write('hi')\"").is_none());
1929 assert!(analyze("cat x | jq '.note'").is_none());
1931 }
1932
1933 #[test]
1934 fn steers_count_idioms() {
1935 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 assert_eq!(tool("cat a.jsonl b.jsonl | wc -l"), Some("ct tree"));
1942 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 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 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 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 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 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 assert!(!s.suggestion.contains("cargo"), "{}", s.suggestion);
2017 }
2018
2019 #[test]
2020 fn scriptlet_of_only_ct_calls_is_left_alone() {
2021 assert!(
2023 analyze("ct search --grep A --quiet\nct edit --find a --replace b --base x.rs")
2024 .is_none()
2025 );
2026 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 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 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()); 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());
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 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 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 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 assert_eq!(
2120 read_steer("notes.md", None, None).unwrap().suggestion,
2121 "ct view notes.md"
2122 );
2123 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 let (_, again) = install(
2139 Some(&text),
2140 "ct steer hook",
2141 &[Tool::Bash, Tool::Grep, Tool::Read],
2142 )
2143 .unwrap();
2144 assert!(!again);
2145 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 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 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 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 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 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 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 assert_eq!(
2224 install::hook_command(head, Mode::Deny, None, false, false),
2225 "ct steer hook"
2226 );
2227 assert_eq!(
2229 install::hook_command(head, Mode::Warn, Some("/x"), true, false),
2230 "ct steer hook --mode warn --no-log"
2231 );
2232 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 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 assert_eq!(
2244 install::hook_command(head, Mode::Warn, None, false, true),
2245 "ct steer hook --mode warn --nudge-pipelines"
2246 );
2247 assert_eq!(
2249 install::hook_command("/opt/ct/ct-steer hook", Mode::Deny, None, false, false),
2250 "/opt/ct/ct-steer hook"
2251 );
2252 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"); assert_eq!(date_stem(86_400), "1970-01-02"); assert_eq!(date_stem(1_600_000_000), "2020-09-13");
2269 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 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 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 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 assert!(pipeline_nudge("find . -name '*.rs' | xargs grep TODO").is_none());
2302 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 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 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 let (_, again) = install::install_post(Some(&both), "ct steer post").unwrap();
2338 assert!(!again);
2339 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 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 let (text2, changed2) = install_bash(Some(&text), "ct steer hook").unwrap();
2355 assert!(!changed2);
2356 assert_eq!(text, text2);
2357 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 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")); let (_, changed2) = uninstall(Some("{}")).unwrap();
2383 assert!(!changed2);
2384 }
2385
2386 #[test]
2387 fn install_and_uninstall_preserve_comments() {
2388 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 assert!(installed.contains("// pin the model"), "{installed}");
2400 assert!(installed.contains("// do not change"), "{installed}");
2401 assert!(installed.contains("./guard.sh"), "{installed}");
2403 assert!(installed.contains("ct steer hook"), "{installed}");
2404
2405 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}