use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use coding_tools::explain::Format;
use coding_tools::patch::{Op, normalize_value, parse_path};
use coding_tools::payload;
use coding_tools::pulse;
use coding_tools::rules::{self, Adapter, ProbeOutcome, Severity};
use serde_json::json;
const EXPLAIN_MD: &str = include_str!("../../docs/explain/ct-rules.md");
const EXPLAIN_JSON: &str = include_str!("../../docs/explain/ct-rules.json");
const HEADER: &str = "\
// ct rule store — the project's recorded invariants (its \"living surface\").\n\
// Each rule is a read-only probe answering a question, with the why behind it.\n\
// Managed by `ct rules` (add/promote/remove/def); verified by `ct check`.\n\
// Docs: ct-rules --explain | ct-check --explain\n";
const TEMPLATE: &str = "{\n \"defs\": {\n },\n \"rules\": [\n ]\n}\n";
const HOOK_MARKER: &str = "// Generated by `ct rules --hook cargo`.";
#[derive(Parser, Debug)]
#[command(
name = "ct-rules",
version,
about = "Record, promote, remove, and list the project's invariant rules (.ct/rules.jsonc).",
long_about = "ct-rules is the writing side of the invariant surface (also reachable as \
`ct rules`): --add verifies a probe and records it as a rule, --pending parks \
an aspiration, --promote enforces it once it holds, --def names shared \
vocabulary, --hook cargo wires `ct check` into `cargo test`. Verification of \
the store is ct-check's job. See `ct-rules --explain` for details."
)]
struct Cli {
#[arg(long)]
file: Option<PathBuf>,
#[arg(long)]
init: bool,
#[arg(long, value_name = "ID")]
add: Option<String>,
#[arg(long)]
pending: bool,
#[arg(long)]
question: Option<String>,
#[arg(long)]
why: Option<String>,
#[arg(long)]
prompt: Option<String>,
#[arg(long, value_delimiter = ',')]
tag: Vec<String>,
#[arg(long)]
severity: Option<String>,
#[arg(long, value_name = "exit|empty")]
expect: Option<String>,
#[arg(long, value_name = "PATTERN")]
expect_ok: Option<String>,
#[arg(long, value_name = "PATTERN")]
expect_err: Option<String>,
#[arg(long)]
network: bool,
#[arg(long, value_name = "SECS")]
timeout: Option<f64>,
#[arg(long, value_name = "ID")]
promote: Option<String>,
#[arg(long, value_name = "ID")]
remove: Option<String>,
#[arg(long, value_name = "NAME=VALUE")]
def: Option<String>,
#[arg(long)]
list: bool,
#[arg(long)]
flatten: bool,
#[arg(long, value_name = "ECOSYSTEM")]
hook: Option<String>,
#[arg(long)]
quiet: bool,
#[arg(long, value_enum, num_args = 0..=1, default_missing_value = "md")]
explain: Option<Format>,
#[arg(last = true, value_name = "PROBE...")]
probe: Vec<String>,
}
fn today_utc() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}")
}
fn resolve_store(file: &Option<PathBuf>, create: bool, quiet: bool) -> Result<PathBuf, String> {
if let Some(f) = file {
return Ok(f.clone());
}
let cwd = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?;
if let Some(root) = rules::discover_root(&cwd) {
let path = rules::store_path(&root);
if path.is_file() || !create {
return Ok(path);
}
scaffold(&path, quiet)?;
return Ok(path);
}
if !create {
return Err(format!(
"no .ct directory found from {} upward; create the store with `ct rules --init`",
cwd.display()
));
}
let path = cwd.join(".ct").join(rules::STORE_FILE);
scaffold(&path, quiet)?;
Ok(path)
}
fn tidy(text: &str) -> String {
let body: String = text
.lines()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n");
let body = format!("{}\n", body.trim_end_matches('\n'));
if body.trim_start().starts_with("//") {
body
} else {
format!("{HEADER}{body}")
}
}
fn scaffold(path: &PathBuf, quiet: bool) -> Result<bool, String> {
if path.is_file() {
return Ok(false);
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
}
std::fs::write(path, tidy(TEMPLATE)).map_err(|e| format!("write {}: {e}", path.display()))?;
if !quiet {
println!("created {}", path.display());
}
Ok(true)
}
fn patch_store(path: &PathBuf, ops: &[Op]) -> Result<(), String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let (patched, changes) =
coding_tools::patch::apply_doc(&text, ops).map_err(|e| format!("{}: {e}", path.display()))?;
if changes == 0 {
return Err("store edit made no change".to_string());
}
std::fs::write(path, tidy(&patched)).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(())
}
fn format_rule_entry(fields: &[(&str, serde_json::Value)]) -> String {
let mut out = String::from("{\n");
for (i, (key, value)) in fields.iter().enumerate() {
let comma = if i + 1 < fields.len() { "," } else { "" };
out.push_str(&format!(" \"{key}\": {value}{comma}\n"));
}
out.push_str(" }");
out
}
fn verify(
store: &std::path::Path,
probe: &[String],
defs: &std::collections::BTreeMap<String, rules::Def>,
adapter: &Adapter,
network: bool,
timeout: Option<f64>,
) -> Result<(ProbeOutcome, String, String), String> {
let expanded = rules::expand_defs(probe, defs)?;
let gated = rules::gate_probe(&expanded)?;
let timeout = timeout.map(|v| pulse::secs("--timeout", v)).transpose()?;
let (outcome, reason, captured) = rules::run_probe(
&expanded,
&gated,
&rules::probe_root(store),
network,
timeout,
adapter,
);
let mut detail = captured.stdout.trim_end().to_string();
if detail.lines().count() > 10 {
let head: Vec<&str> = detail.lines().take(10).collect();
detail = format!("{}\n(...)", head.join("\n"));
}
Ok((outcome, reason, detail))
}
fn cmd_add(cli: &Cli, id: &str) -> Result<ExitCode, String> {
let question = cli
.question
.as_deref()
.ok_or("--add requires --question (what does this rule answer?)")?;
if cli.probe.is_empty() {
return Err("--add requires a probe after `--`".to_string());
}
if id.is_empty() || id.contains(char::is_whitespace) {
return Err(format!("invalid id '{id}'"));
}
let severity = match cli.severity.as_deref() {
Some(s) => Severity::parse(s)?,
None => Severity::Fail,
};
let adapter = match (&cli.expect, &cli.expect_ok, &cli.expect_err) {
(Some(_), Some(_), _) | (Some(_), _, Some(_)) => {
return Err("--expect conflicts with --expect-ok/--expect-err".to_string());
}
(Some(s), None, None) => Adapter::from_value(&json!(s))?,
(None, None, None) => Adapter::Exit,
(None, ok, err) => Adapter::Match {
ok: ok.clone(),
err: err.clone(),
},
};
let path = resolve_store(&cli.file, true, cli.quiet)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let store = rules::parse_store(&text).map_err(|e| format!("{}: {e}", path.display()))?;
if store.rules.iter().any(|r| r.id == id) {
return Err(format!("rule '{id}' already exists (use --remove first; ids are history)"));
}
let (outcome, reason, detail) =
verify(&path, &cli.probe, &store.defs, &adapter, cli.network, cli.timeout)?;
match (&outcome, cli.pending) {
(ProbeOutcome::Broken, _) => {
eprintln!("ct-rules: candidate probe is BROKEN ({reason}); not recorded");
return Ok(ExitCode::from(2));
}
(ProbeOutcome::Violated, false) => {
eprintln!("ct-rules: candidate rule FAILED ({reason}); not recorded");
if !detail.is_empty() {
eprintln!("{detail}");
}
eprintln!("ct-rules: fix the code first, or record it as an aspiration with --pending");
return Ok(ExitCode::from(1));
}
_ => {}
}
let mut fields: Vec<(&str, serde_json::Value)> =
vec![("id", json!(id)), ("question", json!(question))];
if let Some(w) = &cli.why {
fields.push(("why", json!(payload::resolve(w)?.text)));
}
if let Some(p) = &cli.prompt {
fields.push(("prompt", json!(payload::resolve(p)?.text)));
}
if !cli.tag.is_empty() {
fields.push(("tags", json!(cli.tag)));
}
if severity == Severity::Warn {
fields.push(("severity", json!("warn")));
}
match &adapter {
Adapter::Exit => {}
Adapter::Empty => fields.push(("expect", json!("empty"))),
Adapter::Match { ok, err } => {
let mut m = serde_json::Map::new();
if let Some(p) = ok {
m.insert("ok-match".into(), json!(p));
}
if let Some(p) = err {
m.insert("err-match".into(), json!(p));
}
fields.push(("expect", serde_json::Value::Object(m)));
}
}
if cli.network {
fields.push(("network", json!(true)));
}
if let Some(t) = cli.timeout {
fields.push(("timeout", json!(t)));
}
if cli.pending {
fields.push(("pending", json!(true)));
}
fields.push(("added", json!(today_utc())));
fields.push(("probe", json!(cli.probe)));
let entry = format_rule_entry(&fields);
let value = format!("\n {entry}");
patch_store(
&path,
&[Op::Add {
path: parse_path(".rules")?,
raw: ".rules".to_string(),
value,
}],
)?;
if !cli.quiet {
if cli.pending {
let state = match outcome {
ProbeOutcome::Holds => "already holds — consider recording without --pending",
_ => "not yet held",
};
println!("recorded '{id}' (pending; {state})");
} else {
println!("recorded '{id}' (verified: {reason})");
}
if cli.prompt.is_some() {
println!(
"note: the originating request is retained in the rule's \"prompt\" field; \
edit it in the store, or strip all prompts with `ct rules --flatten`"
);
}
}
Ok(ExitCode::SUCCESS)
}
fn cmd_flatten(cli: &Cli) -> Result<ExitCode, String> {
let path = resolve_store(&cli.file, false, cli.quiet)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let store = rules::parse_store(&text).map_err(|e| format!("{}: {e}", path.display()))?;
let with_prompts: Vec<&str> = store
.rules
.iter()
.filter(|r| r.prompt.is_some())
.map(|r| r.id.as_str())
.collect();
if with_prompts.is_empty() {
if !cli.quiet {
println!("nothing to flatten: no rule retains a prompt");
}
return Ok(ExitCode::SUCCESS);
}
let ops: Vec<Op> = with_prompts
.iter()
.map(|id| {
let spec = format!(".rules[id={id}].prompt");
Ok(Op::Delete {
path: parse_path(&spec)?,
raw: spec,
})
})
.collect::<Result<_, String>>()?;
patch_store(&path, &ops)?;
if !cli.quiet {
println!(
"flattened {} prompt(s) ({}); only the mechanical definitions remain",
with_prompts.len(),
with_prompts.join(", ")
);
}
Ok(ExitCode::SUCCESS)
}
fn cmd_promote(cli: &Cli, id: &str) -> Result<ExitCode, String> {
let path = resolve_store(&cli.file, false, cli.quiet)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let store = rules::parse_store(&text).map_err(|e| format!("{}: {e}", path.display()))?;
let rule = store
.rules
.iter()
.find(|r| r.id == id)
.ok_or_else(|| format!("no rule '{id}' in {}", path.display()))?;
if !rule.pending {
return Err(format!("rule '{id}' is not pending"));
}
let (outcome, reason, detail) = verify(
&path,
&rule.probe,
&store.defs,
&rule.expect,
rule.network,
rule.timeout,
)?;
if outcome != ProbeOutcome::Holds {
eprintln!("ct-rules: '{id}' does not hold yet ({reason}); not promoted");
if !detail.is_empty() {
eprintln!("{detail}");
}
return Ok(ExitCode::from(if outcome == ProbeOutcome::Broken { 2 } else { 1 }));
}
let spec = format!(".rules[id={id}].pending");
patch_store(
&path,
&[Op::Delete {
path: parse_path(&spec)?,
raw: spec,
}],
)?;
if !cli.quiet {
println!("promoted '{id}' (verified: {reason}); now enforced");
}
Ok(ExitCode::SUCCESS)
}
fn cmd_remove(cli: &Cli, id: &str) -> Result<ExitCode, String> {
let path = resolve_store(&cli.file, false, cli.quiet)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let store = rules::parse_store(&text).map_err(|e| format!("{}: {e}", path.display()))?;
if !store.rules.iter().any(|r| r.id == id) {
return Err(format!("no rule '{id}' in {}", path.display()));
}
let spec = format!(".rules[id={id}]");
patch_store(
&path,
&[Op::Delete {
path: parse_path(&spec)?,
raw: spec,
}],
)?;
if !cli.quiet {
println!("removed '{id}'");
}
Ok(ExitCode::SUCCESS)
}
fn cmd_def(cli: &Cli, spec: &str) -> Result<ExitCode, String> {
let (name, value) = coding_tools::patch::split_assign(spec)
.ok_or_else(|| format!("--def needs NAME=VALUE, got '{spec}'"))?;
if name.is_empty() || name.contains('.') || name.contains(char::is_whitespace) {
return Err(format!("invalid def name '{name}'"));
}
let path = resolve_store(&cli.file, true, cli.quiet)?;
let target = format!(".defs.{name}");
patch_store(
&path,
&[Op::Set {
path: parse_path(&target)?,
raw: target.clone(),
value: normalize_value(value),
}],
)?;
if !cli.quiet {
println!("def '{name}' set");
}
Ok(ExitCode::SUCCESS)
}
fn cmd_list(cli: &Cli) -> Result<ExitCode, String> {
let path = resolve_store(&cli.file, false, cli.quiet)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let store = rules::parse_store(&text).map_err(|e| format!("{}: {e}", path.display()))?;
if !store.defs.is_empty() {
println!("defs:");
for (name, def) in &store.defs {
match def {
rules::Def::One(s) => println!(" {name} = {s}"),
rules::Def::Many(items) => println!(" {name} = [{}]", items.join(", ")),
}
}
}
println!("rules ({}):", store.rules.len());
for r in &store.rules {
let mut flags = Vec::new();
if r.pending {
flags.push("pending");
}
if r.severity == Severity::Warn {
flags.push("warn");
}
let flags = if flags.is_empty() {
String::new()
} else {
format!(" [{}]", flags.join(","))
};
let tags = if r.tags.is_empty() {
String::new()
} else {
format!(" ({})", r.tags.join(","))
};
println!(" {}{flags} {}{tags}", r.id, r.question);
}
Ok(ExitCode::SUCCESS)
}
fn cmd_hook(cli: &Cli, ecosystem: &str) -> Result<ExitCode, String> {
if ecosystem != "cargo" {
return Err(format!("unknown --hook ecosystem '{ecosystem}' (supported: cargo)"));
}
let store = resolve_store(&cli.file, false, cli.quiet)?;
let root = store
.parent() .and_then(|p| p.parent())
.ok_or("cannot determine project root from store path")?
.to_path_buf();
if !root.join("Cargo.toml").is_file() {
return Err(format!(
"no Cargo.toml at {} — the cargo hook belongs at a Rust project root",
root.display()
));
}
let shim = root.join("tests").join("ct_invariants.rs");
if shim.exists() {
let existing = std::fs::read_to_string(&shim).unwrap_or_default();
if !existing.starts_with(HOOK_MARKER) {
return Err(format!(
"{} exists and was not generated by ct-rules; not overwriting",
shim.display()
));
}
}
if let Some(dir) = shim.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
}
let body = format!(
"{HOOK_MARKER}\n\
// Runs the project's recorded invariants (.ct/rules.jsonc) under `cargo test`.\n\
// Degrades loudly: a missing ct binary fails the test with instructions.\n\
\n\
#[test]\n\
fn ct_invariants_hold() {{\n \
let root = env!(\"CARGO_MANIFEST_DIR\");\n \
match std::process::Command::new(\"ct\")\n \
.args([\"check\", \"--quiet\"])\n \
.current_dir(root)\n \
.status()\n \
{{\n \
Ok(s) if s.success() => {{}}\n \
Ok(s) => panic!(\n \
\"ct check reported failures (exit {{:?}}); run `ct check` in {{root}} for details\",\n \
s.code()\n \
),\n \
Err(e) => panic!(\n \
\"could not run `ct` (install coding-tools and put it on PATH): {{e}}\"\n \
),\n \
}}\n\
}}\n"
);
std::fs::write(&shim, body).map_err(|e| format!("write {}: {e}", shim.display()))?;
if !cli.quiet {
println!("wrote {} — `cargo test` now enforces the rule store", shim.display());
}
Ok(ExitCode::SUCCESS)
}
fn run(cli: Cli) -> Result<ExitCode, String> {
let verbs = [
cli.init,
cli.add.is_some(),
cli.promote.is_some(),
cli.remove.is_some(),
cli.def.is_some(),
cli.list,
cli.flatten,
cli.hook.is_some(),
];
match verbs.iter().filter(|v| **v).count() {
0 => return Err(
"nothing to do: use --init, --add, --promote, --remove, --def, --list, --flatten, or --hook"
.to_string(),
),
1 => {}
_ => return Err("choose exactly one of --init/--add/--promote/--remove/--def/--list/--flatten/--hook".to_string()),
}
if cli.init {
let path = resolve_store(&cli.file, true, cli.quiet)?;
if path.is_file() && !cli.quiet {
println!("store present at {}", path.display());
}
return Ok(ExitCode::SUCCESS);
}
if let Some(id) = cli.add.clone() {
return cmd_add(&cli, &id);
}
if let Some(id) = cli.promote.clone() {
return cmd_promote(&cli, &id);
}
if let Some(id) = cli.remove.clone() {
return cmd_remove(&cli, &id);
}
if let Some(spec) = cli.def.clone() {
return cmd_def(&cli, &spec);
}
if cli.list {
return cmd_list(&cli);
}
if cli.flatten {
return cmd_flatten(&cli);
}
if let Some(eco) = cli.hook.clone() {
return cmd_hook(&cli, &eco);
}
unreachable!("verb dispatch covered above")
}
fn main() -> ExitCode {
let cli = Cli::parse();
if let Some(fmt) = cli.explain {
let body = match fmt {
Format::Md => EXPLAIN_MD,
Format::Json => EXPLAIN_JSON,
};
print!("{body}");
return ExitCode::SUCCESS;
}
match run(cli) {
Ok(code) => code,
Err(msg) => {
eprintln!("ct-rules: {msg}");
ExitCode::from(2)
}
}
}