mod entry;
mod integrity;
mod render;
mod store;
mod write;
use std::collections::BTreeMap;
use clap::Subcommand;
use console::style;
use entry::{short, Entry, EntryType};
use store::{Memory, Status, Store};
#[derive(Subcommand)]
pub(crate) enum Action {
Render,
Show {
#[arg(long)]
subject: Option<String>,
#[arg(long = "type", value_name = "TYPE")]
entry_type: Option<String>,
#[arg(long)]
active: bool,
#[arg(long)]
grep: Option<String>,
},
Verify,
Remember {
#[arg(long = "type", value_name = "TYPE")]
entry_type: String,
#[arg(long = "subject", value_name = "SUBJECT")]
subjects: Vec<String>,
#[arg(long)]
note: String,
#[arg(long)]
foundational: bool,
#[arg(long = "source", value_name = "REF")]
sources: Vec<String>,
#[arg(long)]
by: Option<String>,
},
Supersede {
id: String,
#[arg(long = "type", value_name = "TYPE")]
entry_type: String,
#[arg(long = "subject", value_name = "SUBJECT")]
subjects: Vec<String>,
#[arg(long)]
note: String,
#[arg(long)]
foundational: bool,
#[arg(long = "source", value_name = "REF")]
sources: Vec<String>,
#[arg(long)]
by: Option<String>,
},
Pending {
#[arg(long)]
by: Option<String>,
},
Approve {
id: String,
#[arg(long)]
by: Option<String>,
#[arg(long = "as", value_name = "EMAIL", conflicts_with = "by")]
as_user: Option<String>,
},
Reject {
id: String,
#[arg(long)]
reason: String,
#[arg(long)]
by: Option<String>,
#[arg(long = "as", value_name = "EMAIL", conflicts_with = "by")]
as_user: Option<String>,
},
Apply {
id: String,
#[arg(long)]
by: Option<String>,
#[arg(long = "as", value_name = "EMAIL", conflicts_with = "by")]
as_user: Option<String>,
},
Redact {
id: String,
#[arg(long)]
class: String,
#[arg(long)]
reason: String,
#[arg(long)]
by: Option<String>,
},
Chain { id: String },
Stats,
PromoteCandidates {
#[arg(long, default_value_t = 2)]
min: usize,
},
Index,
}
pub(crate) fn run(action: Action) -> Result<(), String> {
let store = Store::new(".");
match action {
Action::Render => render_cmd(&store),
Action::Show {
subject,
entry_type,
active,
grep,
} => show_cmd(
&store,
subject.as_deref(),
entry_type.as_deref(),
active,
grep.as_deref(),
),
Action::Verify => verify_cmd(&store),
Action::Remember {
entry_type,
subjects,
note,
foundational,
sources,
by,
} => write::remember(entry_type, subjects, foundational, sources, note, by),
Action::Supersede {
id,
entry_type,
subjects,
note,
foundational,
sources,
by,
} => write::supersede(id, entry_type, subjects, foundational, sources, note, by),
Action::Pending { by } => write::pending(by),
Action::Approve { id, by, as_user } => write::approve(id, by, as_user),
Action::Reject {
id,
reason,
by,
as_user,
} => write::reject(id, reason, by, as_user),
Action::Apply { id, by, as_user } => write::apply(id, by, as_user),
Action::Redact {
id,
class,
reason,
by,
} => write::redact(id, class, reason, by),
Action::Chain { id } => chain_cmd(&store, &id),
Action::Stats => stats_cmd(&store),
Action::PromoteCandidates { min } => promote_candidates_cmd(&store, min),
Action::Index => index_cmd(&store),
}
}
fn render_cmd(store: &Store) -> Result<(), String> {
let mem = Memory::build(store.load_entries()?)?;
let out = render::render(&mem);
let path = store.cloud_md_path();
std::fs::write(&path, &out).map_err(|e| format!("could not write {}: {e}", path.display()))?;
let n = mem.entries.len();
println!(
"{} {} ({n} entr{})",
style("rendered").green().bold(),
path.display(),
if n == 1 { "y" } else { "ies" }
);
Ok(())
}
fn show_cmd(
store: &Store,
subject: Option<&str>,
entry_type: Option<&str>,
active_only: bool,
grep: Option<&str>,
) -> Result<(), String> {
let want_type = entry_type.map(EntryType::parse).transpose()?;
let needle = grep.map(str::to_lowercase);
let mem = Memory::build(store.load_entries()?)?;
let mut shown = 0usize;
for e in &mem.entries {
let status = mem.status_of(&e.id);
if active_only && status != Status::Active {
continue;
}
if let Some(s) = subject {
if !e.subjects.iter().any(|x| x == s) {
continue;
}
}
if let Some(t) = want_type {
if e.entry_type != t {
continue;
}
}
if let Some(n) = &needle {
if !e.body.to_lowercase().contains(n) {
continue;
}
}
print_row(e, &status);
shown += 1;
}
if shown == 0 {
println!("{}", style("no matching entries").dim());
}
Ok(())
}
fn verify_cmd(store: &Store) -> Result<(), String> {
render::verify(store)?;
match integrity::check(store) {
integrity::Report::Tampered(files) => {
return Err(format!(
"entry files changed outside the lifecycle — only a ratified redaction may edit an \
entry:\n {}",
files.join("\n ")
));
}
integrity::Report::NotARepo => {
println!(
"{} CLOUD.md is fresh and entries are well-formed {}",
style("ok").green().bold(),
style("(git tamper check skipped — not a git repo)").dim()
);
}
integrity::Report::Clean => {
println!(
"{} CLOUD.md is fresh, entries are well-formed, and no working-tree tampering",
style("ok").green().bold()
);
}
}
Ok(())
}
fn chain_cmd(store: &Store, query: &str) -> Result<(), String> {
let mem = Memory::build(store.load_entries()?)?;
let entry = mem.resolve(query)?;
let id = entry.id.clone();
println!(
"{} {} {}",
style(short(&id)).cyan(),
entry.date,
entry.entry_type.as_str()
);
let ancestors = mem.ancestors(&id);
if ancestors.is_empty() {
println!(" supersedes: (nothing — original)");
} else {
let chain: Vec<&str> = ancestors.iter().map(|a| short(a)).collect();
println!(" supersedes: {}", chain.join(" → "));
}
let successors = mem.successors(&id);
if successors.is_empty() {
println!(" superseded by: (nothing — current)");
} else {
let succ: Vec<&str> = successors.iter().map(|s| short(s)).collect();
println!(" superseded by: {}", succ.join(", "));
}
Ok(())
}
fn stats_cmd(store: &Store) -> Result<(), String> {
let mem = Memory::build(store.load_entries()?)?;
let total = mem.entries.len();
let (mut active, mut superseded, mut tension, mut foundational, mut redacted) = (0, 0, 0, 0, 0);
let mut by_type: BTreeMap<&str, usize> = BTreeMap::new();
let mut subjects: BTreeMap<String, usize> = BTreeMap::new();
for e in &mem.entries {
*by_type.entry(e.entry_type.as_str()).or_default() += 1;
match mem.status_of(&e.id) {
Status::Active => active += 1,
Status::Superseded(_) => superseded += 1,
Status::Forked(_) => tension += 1,
}
if e.foundational {
foundational += 1;
}
if e.redacted {
redacted += 1;
}
for s in &e.subjects {
*subjects.entry(s.clone()).or_default() += 1;
}
}
println!("{}: {total}", style("entries").bold());
println!(" status: {active} active · {superseded} superseded · {tension} open-tension");
println!(" foundational: {foundational} redacted: {redacted}");
if !by_type.is_empty() {
let types: Vec<String> = by_type.iter().map(|(k, n)| format!("{k} {n}")).collect();
println!(" by type: {}", types.join(" · "));
}
if !subjects.is_empty() {
let subs: Vec<String> = subjects.iter().map(|(k, n)| format!("{k} {n}")).collect();
println!(" subjects: {}", subs.join(" · "));
}
Ok(())
}
fn promote_candidates_cmd(store: &Store, min: usize) -> Result<(), String> {
let mem = Memory::build(store.load_entries()?)?;
let mut shown = 0usize;
for e in &mem.entries {
if !matches!(mem.status_of(&e.id), Status::Active) {
continue;
}
let depth = mem.ancestors(&e.id).len();
if depth >= min {
println!(
"{} {} {} (revised {depth}×)",
style(short(&e.id)).cyan(),
e.date,
e.entry_type.as_str()
);
println!(" {}", e.body.lines().next().unwrap_or(""));
shown += 1;
}
}
if shown == 0 {
println!(
"{}",
style(format!("no entries revised ≥ {min}× — nothing to suggest")).dim()
);
} else {
println!(
"\n{}",
style("promotion is a human action — write an ADR, then supersede the entry with a link (§10).").dim()
);
}
Ok(())
}
fn index_cmd(store: &Store) -> Result<(), String> {
let mem = Memory::build(store.load_entries()?)?;
let index = mem.build_index();
store.write_index(&index)?;
let n = index.entry_count;
println!(
"{} {} ({n} entr{})",
style("rebuilt").green().bold(),
store.index_path().display(),
if n == 1 { "y" } else { "ies" }
);
Ok(())
}
fn print_row(e: &Entry, status: &Status) {
let tag = match status {
Status::Active => style("active").green(),
Status::Superseded(_) => style("superseded").dim(),
Status::Forked(_) => style("open-tension").yellow(),
};
let star = if e.foundational { " ⭑" } else { "" };
println!(
"{} {} {} [{}]{}",
style(short(&e.id)).cyan(),
e.date,
e.entry_type.as_str(),
tag,
star
);
let first = e.body.lines().next().unwrap_or("");
let subjects = if e.subjects.is_empty() {
String::new()
} else {
format!(" ({})", e.subjects.join(", "))
};
println!(" {first}{}", style(subjects).dim());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::ulid_gen::new_ulid;
fn temp_store_with(entries: &[(&str, &str, &str, Option<&str>)]) -> Store {
let root = std::env::temp_dir().join(format!("rustio-memory-cmd-{}", new_ulid()));
let dir = root.join(".rustio").join("memory").join("entries");
std::fs::create_dir_all(&dir).unwrap();
for (id, ty, date, sup) in entries {
let content = format!(
"+++\nid = \"{id}\"\ntype = \"{ty}\"\nsubjects = [\"core\"]\n\
supersedes = \"{}\"\nfoundational = false\nsources = []\n\
author = \"ai:test\"\nratified_by = \"t@e\"\ndate = \"{date}\"\n\
correlation_id = \"c\"\n+++\n\nBody of {id}.\n",
sup.unwrap_or("")
);
std::fs::write(dir.join(format!("{id}.md")), content).unwrap();
}
Store::new(root)
}
#[test]
fn render_then_verify_round_trips() {
let s = temp_store_with(&[("aaa", "decision", "2026-01-01", None)]);
render_cmd(&s).expect("render");
verify_cmd(&s).expect("verify after render");
}
#[test]
fn verify_fails_before_render() {
let s = temp_store_with(&[("aaa", "decision", "2026-01-01", None)]);
assert!(verify_cmd(&s).is_err());
}
#[test]
fn show_rejects_unknown_type_filter() {
let s = temp_store_with(&[("aaa", "decision", "2026-01-01", None)]);
let err = show_cmd(&s, None, Some("musing"), false, None).unwrap_err();
assert!(err.contains("unknown entry type"), "{err}");
}
#[test]
fn show_accepts_valid_filters() {
let s = temp_store_with(&[
("aaa", "decision", "2026-01-01", None),
("bbb", "rejected", "2026-02-01", None),
]);
show_cmd(&s, Some("core"), Some("rejected"), true, Some("bbb")).expect("show");
}
}