use anyhow::{bail, Result};
use mecha_core::learning::{LeapRun, LearningStore, Rule};
#[derive(clap::Args, Debug)]
pub struct Args {
#[command(subcommand)]
pub cmd: Option<Cmd>,
}
#[derive(clap::Subcommand, Debug)]
pub enum Cmd {
List,
Show { id: String },
Accept {
id: String,
#[arg(long)]
force: bool,
},
Reject {
id: String,
#[arg(long)]
reason: Option<String>,
},
}
pub async fn execute(args: Args) -> Result<()> {
let store = LearningStore::open(LearningStore::default_root()?)?;
match args.cmd.unwrap_or(Cmd::List) {
Cmd::List => list(&store),
Cmd::Show { id } => show(&store, &id),
Cmd::Accept { id, force } => accept(&store, &id, force),
Cmd::Reject { id, reason } => reject(&store, &id, reason),
}
}
fn list(store: &LearningStore) -> Result<()> {
let proposals = store.proposals()?;
if proposals.is_empty() {
println!("no proposals — `mecha learn --propose` creates them");
return Ok(());
}
for p in &proposals {
println!(
"{} {:<16} {:<10} {} rule(s) from {} reflection(s)",
p.id,
p.status,
p.domain,
p.rules.len(),
p.reflexion_ids.len()
);
}
Ok(())
}
fn show(store: &LearningStore, id: &str) -> Result<()> {
let p = store.proposal(id)?;
println!("proposal {} · {} · {}", p.id, p.domain, p.status);
println!("created {}", p.created_at);
if let Some(resolved) = &p.resolved_at {
println!(
"resolved {resolved}{}",
p.reason
.as_deref()
.map(|r| format!(" — {r}"))
.unwrap_or_default()
);
}
println!("\n{}", render_diff(&p.rules_before, &p.rules));
println!("evidence:\n{}", indent(&p.evidence));
if p.status == "pending" {
println!("\naccept with `mecha proposals accept {}`", p.id);
}
Ok(())
}
fn accept(store: &LearningStore, id: &str, force: bool) -> Result<()> {
let _lock = store.lock()?;
let mut p = store.proposal(id)?;
if p.status != "pending" {
bail!("proposal {} is {}, not pending", p.id, p.status);
}
let live = store.learned_rules(&p.domain)?;
if !same_rules(&live, &p.rules_before) && !force {
bail!(
"the live rules for `{}` changed after this proposal was measured; \
re-run `mecha learn --propose`, or apply anyway with --force",
p.domain
);
}
store.write_learned_rules(&p.domain, &p.rules)?;
store.append_run(&LeapRun {
id: p.id.clone(),
domain: p.domain.clone(),
reflexions_processed: p.reflexion_ids.len() as u32,
rules_before: p.rules_before.len() as u32,
rules_after: p.rules.len() as u32,
created_at: chrono::Utc::now().to_rfc3339(),
})?;
store.mark_reflexions_processed(&p.reflexion_ids, &p.id)?;
p.status = "accepted".into();
p.resolved_at = Some(chrono::Utc::now().to_rfc3339());
store.write_proposal(&p)?;
store.commit(&format!(
"accept[{}]: proposal {} — {} rule(s)",
p.domain,
p.id,
p.rules.len()
));
println!(
"accepted: {} rule(s) now live for `{}`",
p.rules.len(),
p.domain
);
Ok(())
}
fn reject(store: &LearningStore, id: &str, reason: Option<String>) -> Result<()> {
let _lock = store.lock()?;
let mut p = store.proposal(id)?;
if p.status != "pending" {
bail!("proposal {} is {}, not pending", p.id, p.status);
}
store.mark_reflexions_processed(&p.reflexion_ids, &p.id)?;
p.status = "rejected".into();
p.resolved_at = Some(chrono::Utc::now().to_rfc3339());
p.reason = reason;
store.write_proposal(&p)?;
store.commit(&format!("reject[{}]: proposal {}", p.domain, p.id));
println!("rejected; its reflections will not be re-argued");
Ok(())
}
fn render_diff(before: &[Rule], after: &[Rule]) -> String {
let mut out = String::new();
for r in before {
if !after.iter().any(|a| a.text == r.text) {
out.push_str(&format!(" - {}\n", r.text));
}
}
for r in after {
match before.iter().find(|b| b.text == r.text) {
None => out.push_str(&format!(" + {}\n", r.text)),
Some(b) if b.active() && !r.active() => out.push_str(&format!(
" ~ retired: {}{}\n",
r.text,
r.retired_reason
.as_deref()
.map(|w| format!(" ({w})"))
.unwrap_or_default()
)),
Some(b) if !b.active() && r.active() => {
out.push_str(&format!(" ~ restored: {}\n", r.text))
}
Some(_) => {}
}
}
if out.is_empty() {
out.push_str(" (no textual change)\n");
}
out
}
fn same_rules(a: &[Rule], b: &[Rule]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x.text == y.text && x.enabled == y.enabled)
}
fn indent(s: &str) -> String {
s.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(text: &str) -> Rule {
Rule {
text: text.into(),
..Default::default()
}
}
#[test]
fn the_diff_names_what_changed_and_ignores_reordering() {
let before = vec![rule("a"), rule("b")];
let after = vec![rule("b"), rule("c")];
let d = render_diff(&before, &after);
assert!(d.contains("- a") && d.contains("+ c"), "{d}");
assert!(!d.contains("- b") && !d.contains("+ b"), "{d}");
let reordered = render_diff(&[rule("a"), rule("b")], &[rule("b"), rule("a")]);
assert!(reordered.contains("no textual change"), "{reordered}");
}
#[test]
fn a_retirement_shows_in_the_diff_even_though_the_text_survives() {
let before = vec![rule("keep"), rule("bad")];
let mut after = before.clone();
after[1].enabled = false;
after[1].retired_at = Some("2026-08-05T00:00:00Z".into());
after[1].retired_reason = Some("3 attributed regressions".into());
let d = render_diff(&before, &after);
assert!(
d.contains("~ retired: bad (3 attributed regressions)"),
"{d}"
);
assert!(!d.contains("no textual change"), "{d}");
let back = render_diff(&after, &before);
assert!(back.contains("~ restored: bad"), "{back}");
}
#[test]
fn rule_sets_match_on_text_and_enablement_not_metadata() {
let a = vec![rule("x")];
let mut b = vec![rule("x")];
b[0].confidence = Some(0.5);
assert!(same_rules(&a, &b), "confidence drift is not a conflict");
b[0].enabled = false;
assert!(!same_rules(&a, &b), "disabling a rule is a real change");
}
}