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> {
373 let toks = lex(command);
374 if toks.is_empty() {
375 return None;
376 }
377 let (segs, joiners) = control_segments(&toks);
378 let seg_stages: Vec<Vec<Vec<String>>> = segs.iter().map(|s| pipe_stages(s)).collect();
379
380 let touches_ct = seg_stages.iter().flatten().flatten().any(|w| {
384 let b = base_name(w);
385 b == "ct" || b.starts_with("ct-")
386 });
387 if touches_ct {
388 return None;
389 }
390
391 if let Some(first) = seg_stages
395 .first()
396 .and_then(|s| s.first())
397 .and_then(|s| cmd_of(s))
398 && matches!(first, "for" | "while" | "until")
399 {
400 let waits = seg_stages
401 .iter()
402 .flatten()
403 .flatten()
404 .any(|w| matches!(base_name(w), "sleep" | "usleep" | "Start-Sleep"));
405 return Some(if waits {
406 Steer {
407 rule_id: "wait-loop",
408 tool: "ct await",
409 suggestion: "ct await --timeout <SECS> --every <N> -- <probe-argv>".to_string(),
410 note: "ct await polls a read-only probe until it passes (or a timeout/abort fires) with no shell loop — and being the wait itself, it should be launched in the background, never wrapped in `for/while … sleep`",
411 }
412 } else {
413 Steer {
414 rule_id: "shell-loop",
415 tool: "ct each",
416 suggestion: "ct each --items <a> <b> -- <cmd-template-with-{ITEM}>".to_string(),
417 note: "ct each runs a command template once per item with no shell and an aggregate --expect verdict",
418 }
419 });
420 }
421
422 if segs.len() == 1 {
424 return analyze_segment(&seg_stages[0]);
425 }
426
427 let matches: Vec<Steer> = seg_stages
432 .iter()
433 .filter_map(|st| analyze_segment(st))
434 .collect();
435 if matches.len() == segs.len() && !joiners.is_empty() {
436 if joiners.iter().all(|j| *j == Tok::And) {
437 return Some(chain_steer("ct and", &matches));
438 }
439 if joiners.iter().all(|j| *j == Tok::Or) {
440 return Some(chain_steer("ct or", &matches));
441 }
442 }
443 None
444}
445
446fn chain_steer(head: &'static str, parts: &[Steer]) -> Steer {
449 let body = parts
450 .iter()
451 .map(|p| p.suggestion.trim_start_matches("ct ").to_string())
452 .collect::<Vec<_>>()
453 .join(" ::: ");
454 let (rule_id, note) = if head == "ct and" {
455 (
456 "and-chain",
457 "ct and runs each step in turn, stopping at the first failure — a shell-less && with no quoting",
458 )
459 } else {
460 (
461 "or-chain",
462 "ct or runs each step in turn, stopping at the first success — a shell-less || with no quoting",
463 )
464 };
465 Steer {
466 rule_id,
467 tool: head,
468 suggestion: format!("{head} {body}"),
469 note,
470 }
471}
472
473fn analyze_segment(stages: &[Vec<String>]) -> Option<Steer> {
476 rule_find_grep(stages)
477 .or_else(|| rule_grep_recursive(stages))
478 .or_else(|| rule_grep_count(stages))
479 .or_else(|| rule_sed_inplace(stages))
480 .or_else(|| rule_read_range(stages))
481 .or_else(|| rule_interpreter_read(stages))
482 .or_else(|| rule_find_files(stages))
483 .or_else(|| rule_list_recursive(stages))
484 .or_else(|| rule_count_lines(stages))
485}
486
487fn rule_find_grep(stages: &[Vec<String>]) -> Option<Steer> {
489 let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
490 let grep_stage = stages
492 .iter()
493 .find(|s| s.iter().any(|w| base_name(w) == "grep"))?;
494 let glob = flag_value(find, &["-name", "-iname"]);
495 let pat = grep_pattern(grep_stage);
496 Some(Steer {
497 rule_id: "find-grep",
498 tool: "ct search",
499 suggestion: search_suggestion(find_base(find), glob, pat),
500 note: "ct search recurses, filters by name/type/size, and greps in one declarative pass — find | xargs grep in a single command",
501 })
502}
503
504fn rule_grep_recursive(stages: &[Vec<String>]) -> Option<Steer> {
506 for s in stages {
507 let Some(cmd) = cmd_of(s) else { continue };
508 let recursive_grep =
509 cmd == "grep" && (has_short(s, 'r') || has_short(s, 'R') || has_flag(s, "--recursive"));
510 if recursive_grep || matches!(cmd, "rg" | "ripgrep" | "ag") {
511 let pat = grep_pattern(s);
512 let base = positionals(s).get(1).copied();
514 return Some(Steer {
515 rule_id: "grep-recursive",
516 tool: "ct search",
517 suggestion: search_suggestion(base, None, pat),
518 note: "ct search is the suite's recursive content search, with a framed --expect verdict (e.g. --expect none asserts absence)",
519 });
520 }
521 }
522 None
523}
524
525fn rule_grep_count(stages: &[Vec<String>]) -> Option<Steer> {
527 for s in stages {
528 let Some(cmd) = cmd_of(s) else { continue };
529 if matches!(cmd, "grep" | "egrep" | "fgrep") && has_short(s, 'c') {
530 let base = positionals(s).get(1).copied();
532 return Some(Steer {
533 rule_id: "grep-count",
534 tool: "ct search",
535 suggestion: format!(
536 "{} --summary",
537 search_suggestion(base, None, grep_pattern(s))
538 ),
539 note: "ct search --summary reports the match count directly (and --expect +N|=N turns it into a pass/fail assertion), replacing grep -c",
540 });
541 }
542 }
543 None
544}
545
546fn rule_interpreter_read(stages: &[Vec<String>]) -> Option<Steer> {
551 for s in stages {
552 let Some(cmd) = cmd_of(s) else { continue };
553 if cmd == "jq" {
555 if let Some(&file) = positionals(s).get(1) {
556 return Some(interpreter_steer(Some(file)));
557 }
558 continue;
559 }
560 let interp = matches!(
562 cmd,
563 "python" | "python3" | "node" | "nodejs" | "perl" | "ruby"
564 );
565 if interp
566 && let Some(body) = flag_value(s, &["-c", "-e"])
567 && reads_file(body)
568 && !writes_file(body)
569 {
570 return Some(interpreter_steer(quoted_path(body)));
571 }
572 }
573 None
574}
575
576fn rule_find_files(stages: &[Vec<String>]) -> Option<Steer> {
578 let find = stages.iter().find(|s| cmd_of(s) == Some("find"))?;
579 let glob = flag_value(find, &["-name", "-iname"])?;
580 let base = find_base(find);
581 Some(Steer {
582 rule_id: "find-files",
583 tool: "ct search",
584 suggestion: search_suggestion(base, Some(glob), None),
585 note: "ct search selects files by --name/--type/--size and reports them, replacing a bare find",
586 })
587}
588
589fn rule_sed_inplace(stages: &[Vec<String>]) -> Option<Steer> {
591 let stage = stages.iter().find(|s| {
592 let cmd = cmd_of(s);
593 let sed_i =
594 cmd == Some("sed") && s.iter().any(|w| w.starts_with("-i") || w == "--in-place");
595 let perl_i = cmd == Some("perl") && s.iter().any(|w| w.starts_with("-i"));
596 sed_i || perl_i
597 })?;
598 let (find, replace) = sed_subst(stage);
599 let suggestion = match (find, replace) {
600 (Some(f), Some(r)) => format!(
601 "ct edit --base . --find {} --replace {} --expect =1 --dry-run",
602 q(f),
603 q(r)
604 ),
605 _ => "ct edit --base . --find <text> --replace <text> --expect =1 --dry-run".to_string(),
606 };
607 Some(Steer {
608 rule_id: "sed-inplace",
609 tool: "ct edit",
610 suggestion,
611 note: "ct edit previews the diff (--dry-run) and writes only when the match count matches --expect, so a wrong-sized in-place edit fails loudly instead of applying silently",
612 })
613}
614
615fn rule_read_range(stages: &[Vec<String>]) -> Option<Steer> {
617 for s in stages {
619 if cmd_of(s) == Some("sed")
620 && has_flag(s, "-n")
621 && let Some((a, b)) = positionals(s).into_iter().find_map(parse_sed_range)
622 {
623 let file = positionals(s).into_iter().find(|&w| !is_sed_script(w));
624 return Some(view_steer(file, Some((a, b))));
625 }
626 }
627 for (i, s) in stages.iter().enumerate() {
629 let cmd = cmd_of(s);
630 if cmd != Some("head") && cmd != Some("tail") {
631 continue;
632 }
633 let n = head_count(s);
634 let own = positionals(s)
637 .into_iter()
638 .find(|w| w.parse::<u64>().is_err());
639 let upstream = (i > 0 && cmd_of(&stages[i - 1]) == Some("cat"))
640 .then(|| positionals(&stages[i - 1]).into_iter().next())
641 .flatten();
642 let file = own.or(upstream)?; let range = match (cmd, n) {
644 (Some("head"), Some(n)) => Some((1, n)),
645 _ => None, };
647 return Some(view_steer(Some(file), range));
648 }
649 None
650}
651
652fn rule_list_recursive(stages: &[Vec<String>]) -> Option<Steer> {
654 let stage = stages
655 .iter()
656 .find(|s| cmd_of(s) == Some("tree") || (cmd_of(s) == Some("ls") && has_short(s, 'R')))?;
657 let base = positionals(stage).first().copied();
658 let suggestion = match base {
659 Some(b) => format!("ct tree --base {b}"),
660 None => "ct tree".to_string(),
661 };
662 Some(Steer {
663 rule_id: "list-recursive",
664 tool: "ct tree",
665 suggestion,
666 note: "ct tree reports the file tree with per-file line/word/char counts, filtering and sorting — a richer, bounded ls -R / tree",
667 })
668}
669
670fn rule_count_lines(stages: &[Vec<String>]) -> Option<Steer> {
674 for (i, s) in stages.iter().enumerate() {
675 if cmd_of(s) != Some("wc") {
676 continue;
677 }
678 let has_files = !positionals(s).is_empty();
679 let upstream = i.checked_sub(1).map(|j| &stages[j]);
680 let from_find = upstream.is_some_and(|u| matches!(cmd_of(u), Some("find") | Some("ls")));
681 let from_cat =
682 upstream.is_some_and(|u| cmd_of(u) == Some("cat") && !positionals(u).is_empty());
683 if has_files || from_find || from_cat {
684 return Some(Steer {
685 rule_id: "count-lines",
686 tool: "ct tree",
687 suggestion: "ct tree --summary".to_string(),
688 note: "ct tree reports per-file and total line/word/char counts directly, replacing wc -l over a file set",
689 });
690 }
691 }
692 None
693}
694
695fn search_suggestion(base: Option<&str>, name: Option<&str>, grep: Option<&str>) -> String {
699 let mut out = String::from("ct search");
700 if let Some(b) = base {
701 out.push_str(&format!(" --base {b}"));
702 }
703 if let Some(n) = name {
704 out.push_str(&format!(" --name {}", q(n)));
705 }
706 match grep {
707 Some(g) => out.push_str(&format!(" --grep {}", q(g))),
708 None => out.push_str(" --grep <pattern>"),
709 }
710 out
711}
712
713fn view_steer(file: Option<&str>, range: Option<(u32, u32)>) -> Steer {
715 let f = file.unwrap_or("<file>");
716 let suggestion = match range {
717 Some((a, b)) => format!("ct view {f} --range {a}:{b}"),
718 None => format!("ct view {f} --range <start>:<end>"),
719 };
720 Steer {
721 rule_id: "read-range",
722 tool: "ct view",
723 suggestion,
724 note: "ct view shows a file's lines by range (or the regions around a pattern with context) — a precise, bounded read",
725 }
726}
727
728fn interpreter_steer(file: Option<&str>) -> Steer {
730 let f = file.unwrap_or("<file>");
731 Steer {
732 rule_id: "interpreter-read",
733 tool: "ct view",
734 suggestion: format!("ct view {f} --range <start>:<end>"),
735 note: "an interpreter one-liner that reads a file is a bounded read — `ct view` shows a line range (or `--match <pat> --context N`), and `ct search <file> --grep <pat> --detail` finds the matching record, both without a hand-rolled parser",
736 }
737}
738
739fn reads_file(body: &str) -> bool {
741 const READS: &[&str] = &[
742 "open(",
743 "json.load",
744 "readlines",
745 "read_text",
746 "readFileSync",
747 "JSON.parse",
748 "File.read",
749 "IO.read",
750 "Get-Content",
751 ];
752 READS.iter().any(|m| body.contains(m))
753}
754
755fn writes_file(body: &str) -> bool {
758 const WRITES: &[&str] = &[
759 ",'w'",
760 ", 'w'",
761 ",\"w\"",
762 ", \"w\"",
763 ",'a'",
764 ", 'a'",
765 ",\"a\"",
766 "'r+'",
767 "\"r+\"",
768 "'wb'",
769 "\"wb\"",
770 ".write(",
771 "writeFile",
772 "json.dump",
773 "to_csv",
774 "to_json(",
775 "File.write",
776 ];
777 WRITES.iter().any(|m| body.contains(m))
778}
779
780fn quoted_path(body: &str) -> Option<&str> {
783 let bytes = body.as_bytes();
784 let mut i = 0;
785 while i < bytes.len() {
786 let c = bytes[i];
787 if (c == b'\'' || c == b'"')
788 && let Some(rel) = body[i + 1..].find(c as char)
789 {
790 let inner = &body[i + 1..i + 1 + rel];
791 if inner.contains('.') || inner.contains('/') {
792 return Some(inner);
793 }
794 i += 1 + rel + 1;
795 continue;
796 }
797 i += 1;
798 }
799 None
800}
801
802fn grep_pattern(stage: &[String]) -> Option<&str> {
806 if let Some(v) = flag_value(stage, &["-e", "--regexp"]) {
807 return Some(v);
808 }
809 let start = stage
810 .iter()
811 .position(|w| {
812 matches!(
813 base_name(w),
814 "grep" | "egrep" | "fgrep" | "rg" | "ripgrep" | "ag"
815 )
816 })
817 .map_or(1, |i| i + 1);
818 stage[start..]
819 .iter()
820 .find(|w| !w.starts_with('-'))
821 .map(String::as_str)
822}
823
824fn sed_subst(stage: &[String]) -> (Option<&str>, Option<&str>) {
826 for w in stage.iter().skip(1) {
827 if let Some(rest) = w.strip_prefix('s')
828 && let Some(delim) = rest.chars().next()
829 && !delim.is_alphanumeric()
830 {
831 let parts: Vec<&str> = rest[delim.len_utf8()..].split(delim).collect();
832 if parts.len() >= 2 {
833 return (Some(parts[0]), Some(parts[1]));
834 }
835 }
836 }
837 (None, None)
838}
839
840fn head_count(stage: &[String]) -> Option<u32> {
842 if let Some(v) = flag_value(stage, &["-n", "--lines"])
843 && let Ok(n) = v.parse::<u32>()
844 {
845 return Some(n);
846 }
847 stage
848 .iter()
849 .skip(1)
850 .find_map(|w| w.strip_prefix('-').and_then(|d| d.parse::<u32>().ok()))
851}
852
853fn is_sed_script(w: &str) -> bool {
857 if parse_sed_range(w).is_some() {
858 return true;
859 }
860 let mut ch = w.chars();
861 ch.next() == Some('s') && ch.next().is_some_and(|d| !d.is_alphanumeric())
862}
863
864fn parse_sed_range(w: &str) -> Option<(u32, u32)> {
866 let body = w.strip_suffix('p').unwrap_or(w);
867 match body.split_once(',') {
868 Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
869 None => {
870 let n = body.parse().ok()?;
871 Some((n, n))
872 }
873 }
874}
875
876pub fn grep_steer(pattern: &str, path: Option<&str>, glob: Option<&str>) -> Steer {
886 Steer {
887 rule_id: "harness-grep",
888 tool: "ct search",
889 suggestion: search_suggestion(path, glob, Some(pattern)),
890 note: "ct search is the suite's content search — recursive, filtered by name/type/size, with a framed --expect verdict; ct outline maps a file's symbols when you are after a definition",
891 }
892}
893
894pub fn glob_steer(pattern: &str, path: Option<&str>) -> Steer {
896 let (glob_base, name) = split_glob(pattern);
897 let base = path.map(str::to_string).or(glob_base);
898 let mut out = String::from("ct search");
899 if let Some(b) = base {
900 out.push_str(&format!(" --base {b}"));
901 }
902 out.push_str(&format!(" --name {} --type f", q(&name)));
903 Steer {
904 rule_id: "harness-glob",
905 tool: "ct search",
906 suggestion: out,
907 note: "ct search selects files by --name/--type/--size from a chosen root and reports them — the suite's glob, recursive by default",
908 }
909}
910
911fn split_glob(pattern: &str) -> (Option<String>, String) {
915 let segs: Vec<&str> = pattern.split('/').collect();
916 let name = segs.last().copied().unwrap_or(pattern).to_string();
917 let is_wild = |s: &str| s.contains(['*', '?', '[', '{']);
918 let literal: Vec<&str> = segs
919 .iter()
920 .take(segs.len().saturating_sub(1))
921 .take_while(|s| !is_wild(s) && !s.is_empty())
922 .copied()
923 .collect();
924 ((!literal.is_empty()).then(|| literal.join("/")), name)
925}
926
927pub fn read_steer(file_path: &str, offset: Option<i64>, limit: Option<i64>) -> Option<Steer> {
931 if is_unrenderable(file_path) {
932 return None;
933 }
934 let range = match (offset, limit) {
937 (Some(o), Some(l)) => {
938 let start = o.max(1);
939 Some(format!("{start}:{}", (start + l - 1).max(start)))
940 }
941 (Some(o), None) => Some(format!("{}:", o.max(1))),
942 (None, Some(l)) => Some(format!("1:{}", l.max(1))),
943 (None, None) => None,
944 };
945 let suggestion = match range {
946 Some(r) => format!("ct view {file_path} --range {r}"),
947 None => format!("ct view {file_path}"),
948 };
949 Some(Steer {
950 rule_id: "harness-read",
951 tool: "ct view",
952 suggestion,
953 note: "ct view is the suite's bounded file reader — a line range, or --match with context (Read stays the tool for images, PDFs, and notebooks ct view cannot render)",
954 })
955}
956
957fn is_unrenderable(path: &str) -> bool {
960 const EXTS: &[&str] = &[
961 "png", "jpg", "jpeg", "gif", "bmp", "webp", "ico", "tif", "tiff", "pdf", "ipynb",
962 ];
963 let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
964 path.contains('.') && EXTS.contains(&ext.as_str())
965}
966
967pub mod hook {
972 use super::{Mode, Steer, analyze, glob_steer, grep_steer, read_steer};
973 use serde_json::{Value, json};
974
975 pub fn decision(steer: &Steer, mode: Mode) -> Value {
977 let reason = steer.reason();
978 match mode {
979 Mode::Deny => json!({"hookSpecificOutput": {
980 "hookEventName": "PreToolUse",
981 "permissionDecision": "deny",
982 "permissionDecisionReason": reason,
983 }}),
984 Mode::Ask => json!({"hookSpecificOutput": {
985 "hookEventName": "PreToolUse",
986 "permissionDecision": "ask",
987 "permissionDecisionReason": reason,
988 }}),
989 Mode::Warn => json!({"hookSpecificOutput": {
990 "hookEventName": "PreToolUse",
991 "additionalContext": reason,
992 }}),
993 }
994 }
995
996 fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> {
998 input.get(key).and_then(Value::as_str)
999 }
1000
1001 fn int_field(input: &Value, key: &str) -> Option<i64> {
1003 input.get(key).and_then(Value::as_i64)
1004 }
1005
1006 pub fn classify(tool: &str, input: &Value) -> Option<Steer> {
1012 match tool {
1013 "Bash" => analyze(str_field(input, "command")?),
1014 "Grep" => Some(grep_steer(
1015 str_field(input, "pattern")?,
1016 str_field(input, "path"),
1017 str_field(input, "glob"),
1018 )),
1019 "Glob" => Some(glob_steer(
1020 str_field(input, "pattern")?,
1021 str_field(input, "path"),
1022 )),
1023 "Read" => read_steer(
1024 str_field(input, "file_path")?,
1025 int_field(input, "offset"),
1026 int_field(input, "limit"),
1027 ),
1028 _ => None,
1029 }
1030 }
1031
1032 pub fn process(envelope: &str, mode: Mode) -> Option<Value> {
1036 let v: Value = serde_json::from_str(envelope).ok()?;
1037 let tool = v.get("tool_name").and_then(Value::as_str)?;
1038 let input = v.get("tool_input")?;
1039 let steer = classify(tool, input)?;
1040 Some(decision(&steer, mode))
1041 }
1042
1043 pub fn log_record(envelope: &str, mode: Mode) -> Value {
1052 let v: Value = serde_json::from_str(envelope).unwrap_or(Value::Null);
1053 let tool = v.get("tool_name").and_then(Value::as_str).unwrap_or("");
1054 let input = v.get("tool_input").cloned().unwrap_or(Value::Null);
1055 let (decision, rule_id, ct_tool) = match classify(tool, &input) {
1056 Some(s) => (mode.name(), Some(s.rule_id), Some(s.tool)),
1057 None => ("allow", None, None),
1058 };
1059 json!({
1060 "tool": tool,
1061 "command": input.get("command").and_then(Value::as_str),
1062 "cwd": v.get("cwd").and_then(Value::as_str),
1063 "session_id": v.get("session_id").and_then(Value::as_str),
1064 "decision": decision,
1065 "rule_id": rule_id,
1066 "ct_tool": ct_tool,
1067 })
1068 }
1069}
1070
1071pub mod install {
1079 use super::Mode;
1080 use crate::patch::{self, Op, parse_path};
1081 use serde_json::{Value, json};
1082 use std::path::{Path, PathBuf};
1083
1084 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086 pub enum Scope {
1087 Project,
1089 Local,
1091 User,
1093 }
1094
1095 impl Scope {
1096 pub fn from_name(s: &str) -> Option<Scope> {
1098 match s {
1099 "project" => Some(Scope::Project),
1100 "local" => Some(Scope::Local),
1101 "user" => Some(Scope::User),
1102 _ => None,
1103 }
1104 }
1105
1106 pub fn path(self, root: &Path, home: &Path) -> PathBuf {
1109 match self {
1110 Scope::Project => root.join(".claude").join("settings.json"),
1111 Scope::Local => root.join(".claude").join("settings.local.json"),
1112 Scope::User => home.join(".claude").join("settings.json"),
1113 }
1114 }
1115 }
1116
1117 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1120 pub enum Tool {
1121 Bash,
1123 Grep,
1125 Glob,
1127 Read,
1129 All,
1132 }
1133
1134 impl Tool {
1135 pub fn from_name(s: &str) -> Option<Tool> {
1137 match s {
1138 "Bash" => Some(Tool::Bash),
1139 "Grep" => Some(Tool::Grep),
1140 "Glob" => Some(Tool::Glob),
1141 "Read" => Some(Tool::Read),
1142 "all" | "*" => Some(Tool::All),
1143 _ => None,
1144 }
1145 }
1146
1147 pub fn matcher(self) -> &'static str {
1149 match self {
1150 Tool::Bash => "Bash",
1151 Tool::Grep => "Grep",
1152 Tool::Glob => "Glob",
1153 Tool::Read => "Read",
1154 Tool::All => "*",
1155 }
1156 }
1157 }
1158
1159 pub fn hook_command(mode: Mode, log_dir: Option<&str>, no_log: bool) -> String {
1165 let mut cmd = match mode {
1166 Mode::Deny => "ct steer hook".to_string(),
1167 other => format!("ct steer hook --mode {}", other.name()),
1168 };
1169 if no_log {
1170 cmd.push_str(" --no-log");
1171 } else if let Some(path) = log_dir {
1172 let quoted = if path.chars().any(char::is_whitespace) {
1173 format!("\"{path}\"")
1174 } else {
1175 path.to_string()
1176 };
1177 cmd.push_str(&format!(" --log-dir {quoted}"));
1178 }
1179 cmd
1180 }
1181
1182 fn is_steer_command(s: &str) -> bool {
1184 s.contains("steer") && s.contains("hook")
1185 }
1186
1187 fn inspect(text: &str) -> Result<Value, String> {
1191 let root = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
1192 .map_err(|e| format!("parse settings: {e}"))?
1193 .unwrap_or_else(|| json!({}));
1194 if !root.is_object() {
1195 return Err("settings root must be a JSON object".to_string());
1196 }
1197 Ok(root)
1198 }
1199
1200 fn canonical(command: &str, tools: &[Tool]) -> String {
1204 let matchers: Vec<Value> = tools
1205 .iter()
1206 .map(|t| {
1207 json!({ "matcher": t.matcher(), "hooks": [ { "type": "command", "command": command } ] })
1208 })
1209 .collect();
1210 let v = json!({ "hooks": { "PreToolUse": matchers } });
1211 serde_json::to_string_pretty(&v).unwrap() + "\n"
1212 }
1213
1214 fn op_set(path: &str, value: String) -> Result<Op, String> {
1215 Ok(Op::Set {
1216 path: parse_path(path)?,
1217 raw: path.to_string(),
1218 value,
1219 })
1220 }
1221 fn op_add(path: &str, value: String) -> Result<Op, String> {
1222 Ok(Op::Add {
1223 path: parse_path(path)?,
1224 raw: path.to_string(),
1225 value,
1226 })
1227 }
1228 fn op_delete(path: &str) -> Result<Op, String> {
1229 Ok(Op::Delete {
1230 path: parse_path(path)?,
1231 raw: path.to_string(),
1232 })
1233 }
1234
1235 fn apply(text: &str, ops: &[Op]) -> Result<(String, bool), String> {
1238 if ops.is_empty() {
1239 return Ok((text.to_string(), false));
1240 }
1241 let (out, changes) =
1242 patch::apply_doc(text, ops).map_err(|e| format!("settings merge: {e}"))?;
1243 Ok((out, changes > 0))
1244 }
1245
1246 pub fn install(
1252 existing: Option<&str>,
1253 command: &str,
1254 tools: &[Tool],
1255 ) -> Result<(String, bool), String> {
1256 let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1257 return Ok((canonical(command, tools), true));
1258 };
1259 let root = inspect(text)?;
1260 let ops = install_ops(&root, command, tools)?;
1261 apply(text, &ops)
1262 }
1263
1264 pub fn uninstall(existing: Option<&str>) -> Result<(String, bool), String> {
1268 let Some(text) = existing.filter(|t| !t.trim().is_empty()) else {
1269 return Ok((existing.unwrap_or_default().to_string(), false));
1270 };
1271 let root = inspect(text)?;
1272 let ops = uninstall_ops(&root)?;
1273 apply(text, &ops)
1274 }
1275
1276 fn pre_array(root: &Value) -> Option<&Vec<Value>> {
1278 root.get("hooks")
1279 .and_then(|h| h.get("PreToolUse"))
1280 .and_then(Value::as_array)
1281 }
1282
1283 fn is_matcher(entry: &Value, name: &str) -> bool {
1285 entry.get("matcher").and_then(Value::as_str) == Some(name)
1286 }
1287
1288 fn entry_has_steer(entry: &Value) -> bool {
1290 entry
1291 .get("hooks")
1292 .and_then(Value::as_array)
1293 .is_some_and(|l| {
1294 l.iter().any(|h| {
1295 h.get("command")
1296 .and_then(Value::as_str)
1297 .is_some_and(is_steer_command)
1298 })
1299 })
1300 }
1301
1302 fn install_ops(root: &Value, command: &str, tools: &[Tool]) -> Result<Vec<Op>, String> {
1306 let mut ops = Vec::new();
1307
1308 let hooks = root.get("hooks");
1310 match hooks {
1311 None => ops.push(op_set(".hooks", "{}".to_string())?),
1312 Some(h) if !h.is_object() => {
1313 return Err("settings `hooks` must be an object".to_string());
1314 }
1315 Some(_) => {}
1316 }
1317 let pre = hooks.and_then(|h| h.get("PreToolUse"));
1318 match pre {
1319 None => ops.push(op_set(".hooks.PreToolUse", "[]".to_string())?),
1320 Some(p) if !p.is_array() => {
1321 return Err("settings `hooks.PreToolUse` must be an array".to_string());
1322 }
1323 Some(_) => {}
1324 }
1325 let pre_arr = pre.and_then(Value::as_array);
1326
1327 if let Some(arr) = pre_arr {
1329 for (ei, entry) in arr.iter().enumerate() {
1330 let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1331 continue;
1332 };
1333 for (hi, h) in list.iter().enumerate() {
1334 if let Some(c) = h.get("command").and_then(Value::as_str)
1335 && is_steer_command(c)
1336 && c != command
1337 {
1338 ops.push(op_set(
1339 &format!(".hooks.PreToolUse[{ei}].hooks[{hi}].command"),
1340 json!(command).to_string(),
1341 )?);
1342 }
1343 }
1344 }
1345 }
1346
1347 let hook_obj = json!({ "type": "command", "command": command }).to_string();
1349 for tool in tools {
1350 let name = tool.matcher();
1351 if pre_arr.is_some_and(|arr| {
1353 arr.iter()
1354 .any(|e| is_matcher(e, name) && entry_has_steer(e))
1355 }) {
1356 continue;
1357 }
1358 let target =
1360 pre_arr.and_then(|arr| arr.iter().enumerate().find(|(_, e)| is_matcher(e, name)));
1361 match target {
1362 Some((ei, e)) if e.get("hooks").and_then(Value::as_array).is_some() => {
1363 ops.push(op_add(
1364 &format!(".hooks.PreToolUse[{ei}].hooks"),
1365 hook_obj.clone(),
1366 )?);
1367 }
1368 Some((ei, _)) => {
1369 ops.push(op_set(
1370 &format!(".hooks.PreToolUse[{ei}].hooks"),
1371 format!("[{hook_obj}]"),
1372 )?);
1373 }
1374 None => {
1375 let matcher = json!({ "matcher": name, "hooks": [ { "type": "command", "command": command } ] })
1376 .to_string();
1377 ops.push(op_add(".hooks.PreToolUse", matcher)?);
1378 }
1379 }
1380 }
1381 Ok(ops)
1382 }
1383
1384 fn uninstall_ops(root: &Value) -> Result<Vec<Op>, String> {
1386 let Some(pre) = pre_array(root) else {
1387 return Ok(vec![]);
1388 };
1389 let mut whole_entries = Vec::new(); let mut partial = Vec::new(); for (ei, entry) in pre.iter().enumerate() {
1394 let Some(list) = entry.get("hooks").and_then(Value::as_array) else {
1395 continue;
1396 };
1397 let ours: Vec<usize> = list
1398 .iter()
1399 .enumerate()
1400 .filter(|(_, h)| {
1401 h.get("command")
1402 .and_then(Value::as_str)
1403 .is_some_and(is_steer_command)
1404 })
1405 .map(|(hi, _)| hi)
1406 .collect();
1407 if ours.is_empty() {
1408 continue;
1409 }
1410 if ours.len() == list.len() {
1411 whole_entries.push(ei);
1412 } else {
1413 partial.push((ei, ours));
1414 }
1415 }
1416 if whole_entries.is_empty() && partial.is_empty() {
1417 return Ok(vec![]);
1418 }
1419
1420 if partial.is_empty() && whole_entries.len() == pre.len() {
1423 let hooks_solo = root
1424 .get("hooks")
1425 .and_then(Value::as_object)
1426 .is_some_and(|o| o.len() == 1);
1427 let path = if hooks_solo {
1428 ".hooks"
1429 } else {
1430 ".hooks.PreToolUse"
1431 };
1432 return Ok(vec![op_delete(path)?]);
1433 }
1434
1435 let mut ops = Vec::new();
1436 for (ei, his) in &partial {
1440 for hi in his.iter().rev() {
1441 ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}].hooks[{hi}]"))?);
1442 }
1443 }
1444 for ei in whole_entries.iter().rev() {
1445 ops.push(op_delete(&format!(".hooks.PreToolUse[{ei}]"))?);
1446 }
1447 Ok(ops)
1448 }
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::install::{Scope, Tool, install, uninstall};
1454 use super::*;
1455 use std::path::Path;
1456
1457 fn install_bash(existing: Option<&str>, command: &str) -> Result<(String, bool), String> {
1459 install(existing, command, &[Tool::Bash])
1460 }
1461
1462 fn tool(cmd: &str) -> Option<&'static str> {
1463 analyze(cmd).map(|s| s.tool)
1464 }
1465 fn rule(cmd: &str) -> Option<&'static str> {
1466 analyze(cmd).map(|s| s.rule_id)
1467 }
1468
1469 #[test]
1470 fn steers_high_confidence_idioms() {
1471 assert_eq!(
1472 tool("find . -name '*.rs' | xargs grep TODO"),
1473 Some("ct search")
1474 );
1475 assert_eq!(
1476 rule("find . -name '*.rs' | xargs grep TODO"),
1477 Some("find-grep")
1478 );
1479 assert_eq!(tool("grep -rn TODO src"), Some("ct search"));
1480 assert_eq!(tool("rg TODO src"), Some("ct search"));
1481 assert_eq!(tool("find src -name '*.rs'"), Some("ct search"));
1482 assert_eq!(tool("sed -i 's/foo/bar/g' src/x.rs"), Some("ct edit"));
1483 assert_eq!(tool("head -n 40 src/lib.rs"), Some("ct view"));
1484 assert_eq!(tool("cat src/lib.rs | head -n 20"), Some("ct view"));
1485 assert_eq!(tool("sed -n '10,20p' src/lib.rs"), Some("ct view"));
1486 assert_eq!(tool("ls -R src"), Some("ct tree"));
1487 assert_eq!(tool("wc -l src/lib.rs"), Some("ct tree"));
1488 assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1489 assert_eq!(
1490 rule("for f in a b; do grep -r x $f; done"),
1491 Some("shell-loop")
1492 );
1493 }
1494
1495 #[test]
1496 fn steers_wait_loops_to_await_not_each() {
1497 assert_eq!(
1499 tool("for i in $(seq 1 900); do cat f; sleep 2; done"),
1500 Some("ct await")
1501 );
1502 assert_eq!(
1503 rule("for i in $(seq 1 900); do cat f; sleep 2; done"),
1504 Some("wait-loop")
1505 );
1506 assert_eq!(
1507 tool("while true; do check; sleep 5; done"),
1508 Some("ct await")
1509 );
1510 assert_eq!(
1511 tool("until curl -sf http://x; do sleep 3; done"),
1512 Some("ct await")
1513 );
1514 assert_eq!(tool("for f in a b; do grep -r x $f; done"), Some("ct each"));
1516 assert_eq!(
1517 rule("for f in a b; do grep -r x $f; done"),
1518 Some("shell-loop")
1519 );
1520 }
1521
1522 #[test]
1523 fn steers_interpreter_file_reads() {
1524 assert_eq!(tool("jq '.note' feedback/x.jsonl"), Some("ct view"));
1526 assert_eq!(
1527 rule("jq '.note' feedback/x.jsonl"),
1528 Some("interpreter-read")
1529 );
1530 let s = analyze(
1532 "python -c \"rows=[json.loads(l) for l in open('feedback/x.jsonl')]; print(rows[-1])\"",
1533 )
1534 .unwrap();
1535 assert_eq!(s.tool, "ct view");
1536 assert!(
1537 s.suggestion.contains("feedback/x.jsonl"),
1538 "{}",
1539 s.suggestion
1540 );
1541 assert_eq!(
1542 tool("node -e 'const d=require(\"fs\").readFileSync(\"a.json\")'"),
1543 Some("ct view")
1544 );
1545 assert!(analyze("python -c 'print(2+2)'").is_none());
1547 assert!(analyze("python -c \"open('out.txt','w').write('hi')\"").is_none());
1549 assert!(analyze("cat x | jq '.note'").is_none());
1551 }
1552
1553 #[test]
1554 fn steers_count_idioms() {
1555 assert_eq!(tool("grep -c TODO src/lib.rs"), Some("ct search"));
1557 assert_eq!(rule("grep -c TODO src/lib.rs"), Some("grep-count"));
1558 let s = analyze("grep -c TODO src/lib.rs").unwrap();
1559 assert!(s.suggestion.contains("--grep 'TODO'") && s.suggestion.contains("--summary"));
1560 assert_eq!(tool("cat a.jsonl b.jsonl | wc -l"), Some("ct tree"));
1562 assert!(analyze("ps aux | wc -l").is_none());
1564 }
1565
1566 #[test]
1567 fn extracts_obvious_slots() {
1568 let s = analyze("grep -rn TODO src").unwrap();
1569 assert!(s.suggestion.contains("--grep 'TODO'"), "{}", s.suggestion);
1570 let e = analyze("sed -i 's/foo/bar/g' f.rs").unwrap();
1571 assert!(
1572 e.suggestion.contains("--find 'foo'") && e.suggestion.contains("--replace 'bar'"),
1573 "{}",
1574 e.suggestion
1575 );
1576 let v = analyze("head -n 40 src/lib.rs").unwrap();
1577 assert!(
1578 v.suggestion.contains("src/lib.rs --range 1:40"),
1579 "{}",
1580 v.suggestion
1581 );
1582 let fg = analyze("find . -name '*.rs' | xargs grep TODO").unwrap();
1584 assert!(fg.suggestion.contains("--grep 'TODO'"), "{}", fg.suggestion);
1585 assert!(fg.suggestion.contains("--name '*.rs'"), "{}", fg.suggestion);
1586 }
1587
1588 #[test]
1589 fn chain_only_when_all_segments_serviceable() {
1590 let s = analyze("grep -r foo src && sed -i 's/a/b/' f.rs").unwrap();
1591 assert_eq!(s.tool, "ct and");
1592 assert!(
1593 s.suggestion.starts_with("ct and search"),
1594 "{}",
1595 s.suggestion
1596 );
1597 assert!(s.suggestion.contains(":::"), "{}", s.suggestion);
1598 assert!(analyze("grep -r foo src && make").is_none());
1600 }
1601
1602 #[test]
1603 fn allows_safe_and_unknown_commands() {
1604 assert!(analyze("git status").is_none());
1605 assert!(analyze("cargo build && cargo test").is_none());
1606 assert!(analyze("ls -la").is_none());
1607 assert!(analyze("cat file.txt").is_none()); 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());
1612 }
1613
1614 #[test]
1615 fn never_resteers_a_ct_command() {
1616 assert!(analyze("ct search --grep TODO").is_none());
1617 assert!(analyze("ct-search --grep TODO").is_none());
1618 assert!(analyze("find . -name '*.rs' | xargs ct-edit").is_none());
1619 }
1620
1621 #[test]
1622 fn hook_decisions_respect_mode() {
1623 let envelope = r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"}}"#;
1624 let deny = hook::process(envelope, Mode::Deny).unwrap();
1625 assert_eq!(deny["hookSpecificOutput"]["permissionDecision"], "deny");
1626 assert!(
1627 deny["hookSpecificOutput"]["permissionDecisionReason"]
1628 .as_str()
1629 .unwrap()
1630 .contains("ct search")
1631 );
1632 let ask = hook::process(envelope, Mode::Ask).unwrap();
1633 assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
1634 let warn = hook::process(envelope, Mode::Warn).unwrap();
1635 assert!(warn["hookSpecificOutput"]["additionalContext"].is_string());
1636 assert!(
1637 warn["hookSpecificOutput"]
1638 .get("permissionDecision")
1639 .is_none()
1640 );
1641 }
1642
1643 #[test]
1644 fn hook_steers_harness_grep_glob_read() {
1645 let grep = hook::process(
1647 r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO","path":"src","glob":"*.rs"}}"#,
1648 Mode::Deny,
1649 )
1650 .unwrap();
1651 let reason = grep["hookSpecificOutput"]["permissionDecisionReason"]
1652 .as_str()
1653 .unwrap();
1654 assert!(reason.contains("ct search"), "{reason}");
1655 assert!(
1656 reason.contains("--grep 'TODO'") && reason.contains("--base src"),
1657 "{reason}"
1658 );
1659 assert!(reason.contains("--name '*.rs'"), "{reason}");
1660
1661 let s = glob_steer("src/**/*.rs", None);
1663 assert_eq!(s.tool, "ct search");
1664 assert!(s.suggestion.contains("--base src"), "{}", s.suggestion);
1665 assert!(s.suggestion.contains("--name '*.rs'"), "{}", s.suggestion);
1666
1667 let read = read_steer("src/lib.rs", Some(10), Some(20)).unwrap();
1669 assert_eq!(read.tool, "ct view");
1670 assert!(
1671 read.suggestion.contains("ct view src/lib.rs --range 10:29"),
1672 "{}",
1673 read.suggestion
1674 );
1675 assert_eq!(
1677 read_steer("notes.md", None, None).unwrap().suggestion,
1678 "ct view notes.md"
1679 );
1680 assert!(read_steer("diagram.png", None, None).is_none());
1682 assert!(read_steer("paper.pdf", None, None).is_none());
1683 assert!(read_steer("nb.ipynb", None, None).is_none());
1684 }
1685
1686 #[test]
1687 fn install_covers_multiple_tools() {
1688 let (text, changed) =
1689 install(None, "ct steer hook", &[Tool::Bash, Tool::Grep, Tool::Read]).unwrap();
1690 assert!(changed);
1691 for m in ["\"Bash\"", "\"Grep\"", "\"Read\""] {
1692 assert!(text.contains(m), "missing matcher {m} in {text}");
1693 }
1694 let (_, again) = install(
1696 Some(&text),
1697 "ct steer hook",
1698 &[Tool::Bash, Tool::Grep, Tool::Read],
1699 )
1700 .unwrap();
1701 assert!(!again);
1702 let (grown, did) = install(Some(&text), "ct steer hook", &[Tool::Glob]).unwrap();
1704 assert!(did);
1705 assert!(grown.contains("\"Glob\""));
1706 assert_eq!(grown.matches("\"matcher\"").count(), 4);
1707 let (cleared, _) = uninstall(Some(&grown)).unwrap();
1709 assert!(!cleared.contains("steer hook"));
1710 }
1711
1712 #[test]
1713 fn hook_fails_open() {
1714 assert!(hook::process("not json", Mode::Deny).is_none());
1715 assert!(hook::process(r#"{"tool_name":"Read"}"#, Mode::Deny).is_none());
1716 assert!(hook::process(r#"{"tool_name":"Bash","tool_input":{}}"#, Mode::Deny).is_none());
1717 assert!(
1718 hook::process(
1719 r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
1720 Mode::Deny
1721 )
1722 .is_none()
1723 );
1724 }
1725
1726 #[test]
1727 fn log_record_captures_steered_and_allowed_calls() {
1728 let steered = hook::log_record(
1730 r#"{"tool_name":"Bash","tool_input":{"command":"grep -r TODO src"},"cwd":"/work","session_id":"s1"}"#,
1731 Mode::Deny,
1732 );
1733 assert_eq!(steered["tool"], "Bash");
1734 assert_eq!(steered["command"], "grep -r TODO src");
1735 assert_eq!(steered["decision"], "deny");
1736 assert_eq!(steered["ct_tool"], "ct search");
1737 assert!(steered["rule_id"].is_string());
1738 assert_eq!(steered["cwd"], "/work");
1739 assert_eq!(steered["session_id"], "s1");
1740
1741 let allowed = hook::log_record(
1743 r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
1744 Mode::Deny,
1745 );
1746 assert_eq!(allowed["decision"], "allow");
1747 assert!(allowed["rule_id"].is_null());
1748 assert!(allowed["ct_tool"].is_null());
1749 assert_eq!(allowed["command"], "git status");
1750
1751 let other = hook::log_record(
1753 r#"{"tool_name":"Edit","tool_input":{"file_path":"a.rs"}}"#,
1754 Mode::Warn,
1755 );
1756 assert_eq!(other["tool"], "Edit");
1757 assert_eq!(other["decision"], "allow");
1758
1759 let warned = hook::log_record(
1761 r#"{"tool_name":"Grep","tool_input":{"pattern":"TODO"}}"#,
1762 Mode::Warn,
1763 );
1764 assert_eq!(warned["decision"], "warn");
1765 }
1766
1767 #[test]
1768 fn log_record_is_lenient_on_malformed_envelopes() {
1769 let bad = hook::log_record("not json", Mode::Deny);
1771 assert_eq!(bad["tool"], "");
1772 assert_eq!(bad["decision"], "allow");
1773 assert!(bad["command"].is_null());
1774 }
1775
1776 #[test]
1777 fn hook_command_bakes_logging_flags() {
1778 assert_eq!(
1780 install::hook_command(Mode::Deny, None, false),
1781 "ct steer hook"
1782 );
1783 assert_eq!(
1785 install::hook_command(Mode::Warn, Some("/x"), true),
1786 "ct steer hook --mode warn --no-log"
1787 );
1788 assert_eq!(
1790 install::hook_command(Mode::Deny, Some("/var/log/tc"), false),
1791 "ct steer hook --log-dir /var/log/tc"
1792 );
1793 assert_eq!(
1795 install::hook_command(Mode::Deny, Some("/my logs/tc"), false),
1796 "ct steer hook --log-dir \"/my logs/tc\""
1797 );
1798 }
1799
1800 #[test]
1801 fn date_stem_is_utc_civil_date() {
1802 assert_eq!(date_stem(0), "1970-01-01");
1803 assert_eq!(date_stem(86_399), "1970-01-01"); assert_eq!(date_stem(86_400), "1970-01-02"); assert_eq!(date_stem(1_600_000_000), "2020-09-13");
1806 assert_eq!(date_stem(1_582_934_400), "2020-02-29");
1808 }
1809
1810 #[test]
1811 fn gitignore_rule_is_added_once() {
1812 assert_eq!(gitignore_with_log_rule(None).as_deref(), Some("*log\n"));
1813 assert!(gitignore_with_log_rule(Some("*log\n")).is_none());
1814 assert_eq!(
1816 gitignore_with_log_rule(Some("target")).as_deref(),
1817 Some("target\n*log\n")
1818 );
1819 }
1820
1821 #[test]
1822 fn install_all_tools_writes_a_wildcard_matcher() {
1823 let (text, changed) = install(None, "ct steer hook", &[install::Tool::All]).unwrap();
1824 assert!(changed);
1825 assert!(text.contains("\"matcher\": \"*\""), "{text}");
1826 let (cleared, _) = uninstall(Some(&text)).unwrap();
1828 assert!(!cleared.contains("steer hook"));
1829 }
1830
1831 #[test]
1832 fn install_is_idempotent_and_preserves_other_settings() {
1833 let (text, changed) = install_bash(None, "ct steer hook").unwrap();
1835 assert!(changed);
1836 assert!(text.contains("PreToolUse"));
1837 assert!(text.contains("\"matcher\": \"Bash\""));
1838 assert!(text.contains("ct steer hook"));
1839 let (text2, changed2) = install_bash(Some(&text), "ct steer hook").unwrap();
1841 assert!(!changed2);
1842 assert_eq!(text, text2);
1843 let (text3, changed3) = install_bash(Some(&text), "ct steer hook --mode ask").unwrap();
1845 assert!(changed3);
1846 assert_eq!(text3.matches("steer hook").count(), 1);
1847 let existing = r#"{ "model": "opus", "hooks": { "PreToolUse": [] } }"#;
1849 let (merged, _) = install_bash(Some(existing), "ct steer hook").unwrap();
1850 assert!(merged.contains("\"model\": \"opus\""));
1851 }
1852
1853 #[test]
1854 fn uninstall_removes_only_our_hook() {
1855 let existing = r#"{
1856 "hooks": { "PreToolUse": [
1857 { "matcher": "Bash", "hooks": [
1858 { "type": "command", "command": "ct steer hook" },
1859 { "type": "command", "command": "./other.sh" }
1860 ] }
1861 ] }
1862 }"#;
1863 let (text, changed) = uninstall(Some(existing)).unwrap();
1864 assert!(changed);
1865 assert!(!text.contains("steer hook"));
1866 assert!(text.contains("./other.sh")); let (_, changed2) = uninstall(Some("{}")).unwrap();
1869 assert!(!changed2);
1870 }
1871
1872 #[test]
1873 fn install_and_uninstall_preserve_comments() {
1874 let existing = "{\n \
1876 // pin the model\n \
1877 \"model\": \"opus\", // do not change\n \
1878 \"hooks\": {\n \
1879 \"PreToolUse\": [\n \
1880 { \"matcher\": \"Bash\", \"hooks\": [ { \"type\": \"command\", \"command\": \"./guard.sh\" } ] }\n \
1881 ]\n }\n}\n";
1882 let (installed, changed) = install_bash(Some(existing), "ct steer hook").unwrap();
1883 assert!(changed);
1884 assert!(installed.contains("// pin the model"), "{installed}");
1886 assert!(installed.contains("// do not change"), "{installed}");
1887 assert!(installed.contains("./guard.sh"), "{installed}");
1889 assert!(installed.contains("ct steer hook"), "{installed}");
1890
1891 let (removed, changed2) = uninstall(Some(&installed)).unwrap();
1893 assert!(changed2);
1894 assert!(removed.contains("// pin the model"), "{removed}");
1895 assert!(removed.contains("./guard.sh"), "{removed}");
1896 assert!(!removed.contains("steer hook"), "{removed}");
1897 }
1898
1899 #[test]
1900 fn scope_paths() {
1901 let root = Path::new("/proj");
1902 let home = Path::new("/home/u");
1903 assert!(
1904 Scope::Project
1905 .path(root, home)
1906 .ends_with(".claude/settings.json")
1907 );
1908 assert!(
1909 Scope::Local
1910 .path(root, home)
1911 .ends_with(".claude/settings.local.json")
1912 );
1913 assert!(Scope::User.path(root, home).starts_with("/home/u"));
1914 }
1915}