use std::ffi::OsString;
use std::path::PathBuf;
use std::process::{Command, ExitCode};
const EXPLAIN_MD: &str = include_str!("../../docs/explain/ct.md");
const EXPLAIN_JSON: &str = include_str!("../../docs/explain/ct.json");
const SUBCOMMANDS: &[(&str, &str)] = &[
(
"search",
"Recursively find files by name, type, size, and content (ct-search)",
),
(
"view",
"Show a file's lines by range, or regions around a pattern (ct-view)",
),
(
"tree",
"Report a file tree with per-file line/word/char counts and filters (ct-tree)",
),
(
"edit",
"Find/replace across files, gated by an --expect verdict and --dry-run (ct-edit)",
),
(
"patch",
"Set/delete nodes by path in JSON/JSONC/JSONL, preserving formatting (ct-patch)",
),
(
"test",
"Run a command as a framed experiment with a templated verdict (ct-test)",
),
(
"each",
"Run a command template once per item, no shell, with an aggregate --expect verdict (ct-each)",
),
(
"outline",
"Report the declarations in a file or tree: kind, name, start:end span (ct-outline)",
),
(
"survey",
"Survey a codebase by build-system units: crates and modules, with file/line/test counts (ct-survey)",
),
(
"okf",
"Author, query, and index Open Knowledge Format bundles: search, roots, index, validate (ct-okf)",
),
(
"rules",
"Record, promote, remove, and list the project's invariant rules (ct-rules)",
),
(
"check",
"Verify the project's recorded invariants from .ct/rules.jsonc (ct-check)",
),
(
"await",
"Poll a read-only probe until it succeeds, aborts, or times out (ct-await)",
),
(
"steer",
"Steer ad-hoc shell to the ct tool that serves it; install the PreToolUse hook (ct-steer)",
),
];
const DIRECTIVE: &str = "\
Use ct — do not route around it. When a task below has a ct tool, call that tool
instead of raw shell or the harness's generic search/read tools. Each ct tool is
bounded (it will not flood your context), deterministic, and self-verifying (a
framed --expect/--question verdict, exit 0/1/2). The ad-hoc equivalent is none of
those — reaching for it \"because it is quick\" is the exact mistake this suite fixes:
search find files / search content not find, grep -r, rg, ag, Grep, Glob
view read a line range or regions not sed -n, head, tail, Read + offset
tree file tree with counts / totals not ls -R, tree, wc -l
survey crate / module survey w/ counts not ad-hoc cargo metadata + wc
outline declarations in a file or tree not grepping for 'fn '/'class '/'def '
edit find/replace, previewed + gated not sed -i, perl -i
patch set/delete JSON/JSONC nodes not jq -i, hand-editing JSON
test run a command as an experiment not eyeballing raw output
each one command template per item not for / while read loops
await poll until a probe passes not sleep / retry loops
One call, not a pipeline. Do NOT cobble commands together with pipes, xargs, and
command substitution. Use ct's native compound and aggregate forms so the task is
a single, checkable call:
ct and A ::: B ::: C shell-less && — chain steps, stop at the first failure
ct or A ::: B shell-less || — try alternatives, stop at the first success
ct each ... fan one command over many items, with one aggregate verdict
--summary --expect --question totals and pass/fail built in — never pipe into wc, grep, or test
A hand-built pipeline is unbounded, order-dependent, and silent on failure; a ct
call is bounded, deterministic, and reports whether it succeeded.
";
fn usage() -> String {
let mut commands = String::new();
for (name, blurb) in SUBCOMMANDS {
commands.push_str(&format!(" {name:<9} {blurb}\n"));
}
format!(
"ct — umbrella launcher for the coding_tools suite\n\
\n\
Usage:\n \
ct <command> [args...] run the matching ct-<command> tool\n \
ct and <cmd...> ::: <cmd...> run each in turn, stop at the first failure (shell-less &&)\n \
ct or <cmd...> ::: <cmd...> run each in turn, stop at the first success (shell-less ||)\n \
ct help [<command>] show this help, or a command's own --help\n \
ct <command> --explain print one tool's definition (md or json)\n \
ct --explain [md|json] describe the whole suite (json = a manifest of every tool)\n \
ct completions [shell] print the shell completion script (bash/zsh/fish; auto-detects if omitted)\n \
ct --version\n\
\n\
Commands:\n\
{commands}\n\
{DIRECTIVE}\n\
ct <command> runs ct-<command> — found beside ct or on PATH — the same way\n\
git runs git-<command>, so any ct-* tool you install is reachable through ct.\n"
)
}
fn resolve(child: &str) -> OsString {
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent()
{
let candidate: PathBuf = dir.join(child);
if candidate.is_file() {
return candidate.into_os_string();
}
}
OsString::from(child)
}
fn dispatch(sub: &str, rest: &[String]) -> ExitCode {
let program = resolve(&format!("ct-{sub}"));
let mut command = Command::new(&program);
command.args(rest);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
launch_error(sub, command.exec())
}
#[cfg(not(unix))]
{
match command.status() {
Ok(status) => ExitCode::from(u8::try_from(status.code().unwrap_or(2)).unwrap_or(2)),
Err(e) => launch_error(sub, e),
}
}
}
fn print_launch_error(sub: &str, err: &std::io::Error) {
if err.kind() == std::io::ErrorKind::NotFound {
eprintln!("ct: unknown command '{sub}' — no 'ct-{sub}' found beside ct or on PATH");
eprint!("\n{}", usage());
} else {
eprintln!("ct: could not run 'ct-{sub}': {err}");
}
}
fn launch_error(sub: &str, err: std::io::Error) -> ExitCode {
print_launch_error(sub, &err);
ExitCode::from(2)
}
const CHAIN_SEP: &str = ":::";
#[derive(Clone, Copy, PartialEq, Eq)]
enum ChainMode {
And,
Or,
}
fn split_segments(rest: &[String]) -> Result<Vec<&[String]>, String> {
let segs: Vec<&[String]> = rest.split(|a| a == CHAIN_SEP).collect();
if segs.iter().any(|s| s.is_empty()) {
return Err(format!(
"empty segment — separate sub-commands with '{CHAIN_SEP}' and don't leave one blank \
(e.g. `ct and search … {CHAIN_SEP} edit …`)"
));
}
Ok(segs)
}
fn chain_code<R: FnMut(&str, &[String]) -> i32>(
mode: ChainMode,
segs: &[&[String]],
mut run: R,
) -> i32 {
let mut last = 0;
for seg in segs {
let code = run(seg[0].as_str(), &seg[1..]);
match mode {
ChainMode::And if code != 0 => return code,
ChainMode::Or if code == 0 => return 0,
ChainMode::Or => last = code,
ChainMode::And => {}
}
}
match mode {
ChainMode::And => 0, ChainMode::Or => last, }
}
fn run_segment(sub: &str, args: &[String]) -> i32 {
let program = resolve(&format!("ct-{sub}"));
match Command::new(&program).args(args).status() {
Ok(status) => status.code().unwrap_or(2),
Err(e) => {
print_launch_error(sub, &e);
2
}
}
}
fn code_to_exit(code: i32) -> ExitCode {
ExitCode::from(u8::try_from(code).unwrap_or(2))
}
fn run_chain(mode: ChainMode, kw: &str, rest: &[String]) -> ExitCode {
match split_segments(rest) {
Ok(segs) => code_to_exit(chain_code(mode, &segs, run_segment)),
Err(msg) => {
eprintln!("ct {kw}: {msg}");
ExitCode::from(2)
}
}
}
fn completions(rest: &[String]) -> ExitCode {
let shell: Option<&str> = match rest.first().map(String::as_str) {
None => None,
Some("--shell") => match rest.get(1) {
Some(s) => Some(s.as_str()),
None => {
eprintln!("ct: --shell needs a value (bash, zsh, fish)");
return ExitCode::from(2);
}
},
Some(s) if s.starts_with("--shell=") => s.strip_prefix("--shell="),
Some(s) if !s.starts_with('-') => Some(s),
Some(other) => {
eprintln!("ct: unknown completions argument '{other}'");
return ExitCode::from(2);
}
};
match shell {
None => veks_completion::print_indirect_wrapper("ct"),
Some(name) => match veks_completion::Shell::from_name(name) {
Some(sh) => veks_completion::print_completions("ct", sh),
None => {
eprintln!("ct: unknown shell '{name}' (bash, zsh, fish)");
return ExitCode::from(2);
}
},
}
ExitCode::SUCCESS
}
fn main() -> ExitCode {
let tree = coding_tools::completion::command_tree();
if veks_completion::handle_complete_env("ct", &tree) {
return ExitCode::SUCCESS;
}
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) == Some(coding_tools::update::BG_FLAG) {
coding_tools::update::run_background_poll();
return ExitCode::SUCCESS;
}
let Some(first) = args.first() else {
eprint!("{}", usage());
return ExitCode::from(2);
};
match first.as_str() {
"-h" | "--help" => {
print!("{}", usage());
ExitCode::SUCCESS
}
"-V" | "--version" => {
println!("ct {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
"help" => match args.get(1) {
None => {
print!("{}", usage());
ExitCode::SUCCESS
}
Some(sub) => dispatch(sub, &["--help".to_string()]),
},
explain if explain == "--explain" || explain.starts_with("--explain=") => {
let fmt = explain
.strip_prefix("--explain=")
.or(args.get(1).map(String::as_str))
.unwrap_or("md");
match fmt {
"md" => {
print!("{EXPLAIN_MD}");
ExitCode::SUCCESS
}
"json" => {
print!("{EXPLAIN_JSON}");
ExitCode::SUCCESS
}
other => {
eprintln!("ct: unknown --explain format '{other}' (use md or json)");
ExitCode::from(2)
}
}
}
"completions" => completions(&args[1..]),
"and" => {
coding_tools::update::on_invocation();
run_chain(ChainMode::And, "and", &args[1..])
}
"or" => {
coding_tools::update::on_invocation();
run_chain(ChainMode::Or, "or", &args[1..])
}
flag if flag.starts_with('-') => {
eprintln!("ct: unknown option '{flag}'");
eprint!("\n{}", usage());
ExitCode::from(2)
}
sub => {
coding_tools::update::on_invocation();
dispatch(sub, &args[1..])
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn segs(parts: &[&[&str]]) -> Vec<Vec<String>> {
parts
.iter()
.map(|seg| seg.iter().map(|s| s.to_string()).collect())
.collect()
}
fn run(mode: ChainMode, names: &[&str], codes: &[i32]) -> (i32, Vec<String>) {
let storage = segs(&names.iter().map(std::slice::from_ref).collect::<Vec<_>>());
let view: Vec<&[String]> = storage.iter().map(Vec::as_slice).collect();
let mut ran = Vec::new();
let mut i = 0;
let code = chain_code(mode, &view, |sub, _| {
ran.push(sub.to_string());
let c = codes[i];
i += 1;
c
});
(code, ran)
}
#[test]
fn and_stops_at_first_failure_and_returns_it() {
let (code, ran) = run(ChainMode::And, &["a", "b", "c"], &[0, 1, 0]);
assert_eq!(code, 1);
assert_eq!(ran, ["a", "b"]); }
#[test]
fn and_runs_all_when_every_segment_succeeds() {
let (code, ran) = run(ChainMode::And, &["a", "b"], &[0, 0]);
assert_eq!(code, 0);
assert_eq!(ran, ["a", "b"]);
}
#[test]
fn and_propagates_a_two_abort_and_halts() {
let (code, ran) = run(ChainMode::And, &["a", "b", "c"], &[0, 2, 0]);
assert_eq!(code, 2);
assert_eq!(ran, ["a", "b"]);
}
#[test]
fn or_stops_at_first_success() {
let (code, ran) = run(ChainMode::Or, &["a", "b", "c"], &[1, 0, 1]);
assert_eq!(code, 0);
assert_eq!(ran, ["a", "b"]); }
#[test]
fn or_returns_last_code_when_all_fail() {
let (code, ran) = run(ChainMode::Or, &["a", "b"], &[1, 2]);
assert_eq!(code, 2);
assert_eq!(ran, ["a", "b"]);
}
#[test]
fn split_segments_breaks_on_the_separator() {
let argv: Vec<String> = ["search", "--quiet", ":::", "edit", "--mutating"]
.iter()
.map(|s| s.to_string())
.collect();
let segs = split_segments(&argv).unwrap();
assert_eq!(segs.len(), 2);
assert_eq!(segs[0], ["search", "--quiet"]);
assert_eq!(segs[1], ["edit", "--mutating"]);
}
#[test]
fn split_segments_rejects_blank_segments() {
let blank = |parts: &[&str]| {
let argv: Vec<String> = parts.iter().map(|s| s.to_string()).collect();
split_segments(&argv).is_err()
};
assert!(blank(&[])); assert!(blank(&[":::", "edit"])); assert!(blank(&["search", ":::"])); assert!(blank(&["search", ":::", ":::", "edit"])); }
}