use clap::{Parser, Subcommand};
use crate::commands;
pub const EXIT_CODES_HELP: &str = "\
Exit codes:
0 success
1 generic failure (catch-all for non-classified errors)
2 usage error (clap argument-parse failure — unknown flag, bad value)
3 not found (entity / mem / resource missing)
4 hash mismatch (optimistic-locking failure on a mutation)
5 validation / schema / policy refusal
6 findings present — the measurement COMPLETED and recorded
something you asked to be gated on
(`projection verify --fail-on-findings`). A run that could not
complete returns its own code above, so a CI job can tell \"the
mem and its source disagree\" from \"the engine could not run\".
An artifact the pass could not read is a finding, not an error:
it was observed, and not being able to adjudicate it is the
measurement's answer.
For programmatic branching, prefer `--json` over the exit code:
memstead <subcommand> ... --json | jq -r .code
One caveat, and it bites exactly where code 6 matters: a gate-mode run
that exits 6 emits TWO documents on stdout — the report, then the typed
error. The recipe above reads only the first and prints `null`. Read the
stream instead:
memstead ... --fail-on-findings --json | jq -s -r '.[-1].code'
The JSON envelope's `code` field carries the typed token
(e.g. INVALID_TITLE, HAS_INCOMING_REFS, CROSS_MEM_LINK_NOT_ALLOWED)
with structured recovery details under `.details`.";
#[derive(Parser, Debug)]
#[command(name = "memstead", version = memstead_base::build_info::full_version(), about, long_about = None, after_long_help = EXIT_CODES_HELP)]
pub struct Cli {
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub quiet: bool,
#[arg(long, global = true, value_name = "PATH")]
pub workspace: Option<std::path::PathBuf>,
#[arg(long = "role", global = true)]
pub role: Option<String>,
#[arg(long = "identity", global = true)]
pub identity: Option<String>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Status,
Entity(commands::entity::Args),
Relations(commands::relations::Args),
Search(commands::search::Args),
List(commands::list::Args),
Context(commands::context::Args),
Overview(commands::overview::Args),
Type(commands::type_cmd::Args),
Health(commands::health::Args),
Due(commands::due::Args),
Gates(commands::gates::Args),
Export(commands::export::Args),
Init(commands::init::InitArgs),
Quickstart(commands::quickstart::Args),
#[cfg(feature = "mem-repo")]
Install(commands::install::Args),
#[cfg(feature = "mem-repo")]
Uninstall(commands::uninstall::Args),
#[command(name = "verify-anchors")]
VerifyAnchors(commands::verify_anchors::Args),
Publish(commands::publish::Args),
Unpublish(commands::unpublish::Args),
Domain {
#[command(subcommand)]
action: commands::domain::DomainAction,
},
Admin {
#[command(subcommand)]
action: commands::admin::AdminAction,
},
Login(commands::login::Args),
Logout(commands::logout::Args),
Create(commands::create::Args),
Update(commands::update::Args),
Relate(commands::relate::Args),
Delete(commands::delete::Args),
Rename(commands::rename::Args),
#[cfg(feature = "mem-repo")]
#[command(name = "batch-update")]
BatchUpdate(commands::batch_update::Args),
#[cfg(feature = "mem-repo")]
#[command(name = "batch-create")]
BatchCreate(commands::batch_create::Args),
#[cfg(feature = "mem-repo")]
#[command(name = "batch-relate")]
BatchRelate(commands::batch_relate::Args),
#[cfg(feature = "mem-repo")]
Recover(commands::recover::Args),
Anchors(commands::anchors::Args),
Conflicts(commands::conflicts::Args),
Changes(commands::changes::Args),
Check(commands::check::Args),
#[command(name = "review-mark")]
ReviewMark(commands::review_mark::Args),
Reload(commands::reload::Args),
#[cfg(feature = "mem-repo")]
Fetch(commands::transport::FetchArgs),
#[cfg(feature = "mem-repo")]
Pull(commands::transport::PullArgs),
#[cfg(feature = "mem-repo")]
Push(commands::transport::PushArgs),
#[cfg(feature = "mem-repo")]
#[command(name = "branch-reset")]
BranchReset(commands::branch_reset::BranchResetArgs),
#[cfg(feature = "mem-repo")]
Mem {
#[command(subcommand)]
action: commands::mem::MemAction,
},
#[cfg(feature = "mem-repo")]
#[command(name = "mem-repo")]
MemRepo {
#[command(subcommand)]
action: commands::mem_repo::MemRepoAction,
},
#[cfg(feature = "mem-repo")]
Workspace {
#[command(subcommand)]
action: commands::workspace::WorkspaceAction,
},
Schema(commands::schema::Args),
Projection(commands::projection::Args),
}
impl Command {
pub fn verb(&self) -> &'static str {
match self {
Command::Status => "status",
Command::Entity(_) => "entity",
Command::Relations(_) => "relations",
Command::Search(_) => "search",
Command::List(_) => "list",
Command::Context(_) => "context",
Command::Overview(_) => "overview",
Command::Type(_) => "type",
Command::Health(_) => "health",
Command::Due(_) => "due",
Command::Gates(_) => "gates",
Command::Export(_) => "export",
Command::Init(_) => "init",
Command::Quickstart(_) => "quickstart",
#[cfg(feature = "mem-repo")]
Command::Install(_) => "install",
#[cfg(feature = "mem-repo")]
Command::Uninstall(_) => "uninstall",
Command::VerifyAnchors(_) => "verify-anchors",
Command::Publish(_) => "publish",
Command::Unpublish(_) => "unpublish",
Command::Domain { .. } => "domain",
Command::Admin { .. } => "admin",
Command::Login(_) => "login",
Command::Logout(_) => "logout",
Command::Create(_) => "create",
Command::Update(_) => "update",
Command::Relate(_) => "relate",
Command::Delete(_) => "delete",
Command::Rename(_) => "rename",
#[cfg(feature = "mem-repo")]
Command::BatchUpdate(_) => "batch-update",
#[cfg(feature = "mem-repo")]
Command::BatchCreate(_) => "batch-create",
#[cfg(feature = "mem-repo")]
Command::BatchRelate(_) => "batch-relate",
#[cfg(feature = "mem-repo")]
Command::Recover(_) => "recover",
Command::Anchors(_) => "anchors",
Command::Conflicts(_) => "conflicts",
Command::Changes(_) => "changes",
Command::Check(_) => "check",
Command::ReviewMark(_) => "review-mark",
Command::Reload(_) => "reload",
#[cfg(feature = "mem-repo")]
Command::Fetch(_) => "fetch",
#[cfg(feature = "mem-repo")]
Command::Pull(_) => "pull",
#[cfg(feature = "mem-repo")]
Command::Push(_) => "push",
#[cfg(feature = "mem-repo")]
Command::BranchReset(_) => "branch-reset",
#[cfg(feature = "mem-repo")]
Command::Mem { .. } => "mem",
#[cfg(feature = "mem-repo")]
Command::MemRepo { .. } => "mem-repo",
#[cfg(feature = "mem-repo")]
Command::Workspace { .. } => "workspace",
Command::Schema(_) => "schema",
Command::Projection(_) => "projection",
}
}
}
#[cfg(test)]
mod write_id_gloss_tests {
use clap::CommandFactory;
#[test]
fn no_cli_help_text_glosses_write_id_as_git_or_cursor() {
const CURSOR_INVITES: &[&str] = &[
"polling",
"poll via",
"since cursor",
"as the `since`",
"prior `write_id`",
"`write_id` from a mutation",
];
fn texts(cmd: &clap::Command, path: &str, out: &mut Vec<(String, String)>) {
let mut push = |s: Option<&clap::builder::StyledStr>| {
if let Some(v) = s {
out.push((path.to_string(), v.to_string()));
}
};
push(cmd.get_about());
push(cmd.get_long_about());
for arg in cmd.get_arguments() {
if let Some(h) = arg.get_help() {
out.push((format!("{path} --{}", arg.get_id()), h.to_string()));
}
if let Some(h) = arg.get_long_help() {
out.push((format!("{path} --{}", arg.get_id()), h.to_string()));
}
}
for sub in cmd.get_subcommands() {
if sub.get_name() == "help" {
continue;
}
let child = if path.is_empty() {
sub.get_name().to_string()
} else {
format!("{path} {}", sub.get_name())
};
texts(sub, &child, out);
}
}
let cmd = super::Cli::command();
let mut all = Vec::new();
texts(&cmd, "", &mut all);
let mut violations = Vec::new();
for (where_, text) in &all {
if !text.contains("write_id") {
continue;
}
for sentence in text.split(". ").filter(|s| s.contains("write_id")) {
let lower = sentence.to_lowercase();
if (lower.contains("commit") || lower.contains("sha"))
&& !lower.contains("git-branch")
{
violations.push(format!(
"`memstead {where_}` help calls `write_id` a commit without naming \
which backend produces one — {sentence}"
));
}
if lower.contains("gitdir") || lower.contains("include_config") {
violations.push(format!(
"`memstead {where_}` help points at a gitdir in a sentence about \
`write_id` — the lookup errors on a backend without one"
));
}
for phrase in CURSOR_INVITES {
if lower.contains(phrase) {
violations.push(format!(
"`memstead {where_}` help invites polling with `write_id` \
(\"{phrase}\") — it is an identity, not a change cursor"
));
}
}
}
}
assert!(
violations.is_empty(),
"write_id gloss violations in CLI help:\n {}",
violations.join("\n ")
);
assert!(
all.iter().any(|(_, t)| t.contains("write_id")),
"no CLI help text mentions `write_id` — this check has gone vacuous"
);
const RETIRED_EDGE_SHAPES: &[&str] = &[
"`from` / `type` / `to`",
"`from`/`type`/`to`",
"{from, to, type}",
"{to, type}",
];
let mut edge_violations = Vec::new();
for (where_, text) in &all {
for shape in RETIRED_EDGE_SHAPES {
if text.contains(shape) {
edge_violations.push(format!(
"`memstead {where_}` help documents a relation entry as {shape} — \
the type is `rel_type` on every surface and the parser refuses \
the retired spelling"
));
}
}
}
assert!(
all.iter()
.any(|(_, t)| t.contains("REL_TYPE:") || t.contains("rel_type")),
"no CLI help documents a relation entry shape — this check has gone vacuous"
);
assert!(
edge_violations.is_empty(),
"retired edge spelling in CLI help:\n {}",
edge_violations.join("\n ")
);
}
#[test]
fn no_rendered_cli_output_labels_a_write_id_as_a_commit() {
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
walk(&src, &mut files);
assert!(
!files.is_empty(),
"found no sources — check has gone vacuous"
);
let mut violations = Vec::new();
let mut saw_a_render = false;
for path in &files {
let Ok(text) = std::fs::read_to_string(path) else {
continue;
};
let text = text
.split_once("mod write_id_gloss_tests")
.map(|(before, _)| before.to_string())
.unwrap_or(text);
let lines: Vec<&str> = text.lines().collect();
for (i, line) in lines.iter().enumerate() {
let renders_token = line.contains("write_id");
if renders_token && (line.contains("format!") || line.contains("push_str")) {
saw_a_render = true;
}
if !renders_token {
continue;
}
let lo = i.saturating_sub(2);
let hi = (i + 3).min(lines.len());
let window = lines[lo..hi].join(" ").to_lowercase();
let renders = lines[lo..hi]
.iter()
.any(|l| l.contains("format!") || l.contains("push_str"));
if (window.contains("commit") || window.contains(" sha")) && renders {
violations.push(format!(
"{}:{}: {}",
path.file_name().unwrap_or_default().to_string_lossy(),
i + 1,
line.trim()
));
}
}
}
assert!(
saw_a_render,
"no CLI source renders a write token — this check has gone vacuous"
);
assert!(
violations.is_empty(),
"rendered CLI output labels a write token with git vocabulary:\n {}",
violations.join("\n ")
);
}
}