use std::path::PathBuf;
use clap::{Args, Subcommand};
use crate::CliError;
use crate::outer_gitignore::{OuterRepoOutcome, apply_outer_gitignore};
use crate::output::ExitKind;
use crate::setup::{CliContext, CliEngine, find_workspace_root};
use memstead_engine::mem_management::{
self, MemCreateParams, MemCreateResponse, MemDeleteParams, MemDeleteResponse,
};
#[derive(Subcommand, Debug)]
pub enum MemAction {
Init(InitArgs),
Unregister(UnregisterArgs),
Delete(DeleteArgs),
#[command(name = "set-version")]
SetVersion(SetVersionArgs),
#[command(name = "set-schema")]
SetSchema(SetSchemaArgs),
#[command(name = "set-description")]
SetDescription(SetDescriptionArgs),
#[command(name = "set-sync-state")]
SetSyncState(SetSyncStateArgs),
#[command(name = "set-internal")]
SetInternal(SetInternalArgs),
List(ListArgs),
}
#[derive(Args, Debug)]
pub struct ListArgs {}
#[derive(Args, Debug)]
pub struct SetVersionArgs {
pub name: String,
pub version: String,
#[arg(long)]
pub note: Option<String>,
}
#[derive(Args, Debug)]
pub struct SetDescriptionArgs {
pub name: String,
pub description: String,
#[arg(long)]
pub note: Option<String>,
}
#[derive(Args, Debug)]
pub struct SetSyncStateArgs {
pub name: String,
pub key: String,
pub token: String,
#[arg(long)]
pub note: Option<String>,
}
#[derive(Args, Debug)]
pub struct SetSchemaArgs {
pub name: String,
pub schema: String,
}
#[derive(Args, Debug)]
pub struct InitArgs {
pub path: PathBuf,
#[arg(long, default_value = "default@1.0.0")]
pub schema: String,
#[arg(long)]
pub vcs_shared: bool,
#[arg(long)]
pub no_gitignore: bool,
#[arg(long)]
pub note: Option<String>,
#[arg(long, group = "recovery_action")]
pub reattach: bool,
#[arg(long = "force-overwrite", group = "recovery_action")]
pub force_overwrite: bool,
#[arg(long = "hard-cleanup-first", group = "recovery_action")]
pub hard_cleanup_first: bool,
#[arg(long = "operator-mode")]
pub operator_mode: bool,
#[arg(long = "write-guidance")]
pub write_guidance: Option<String>,
}
#[derive(Args, Debug)]
pub struct DeleteArgs {
pub name: String,
#[arg(long)]
pub note: Option<String>,
#[arg(long = "operator-mode")]
pub operator_mode: bool,
}
#[derive(Args, Debug)]
pub struct UnregisterArgs {
pub name: String,
#[arg(long)]
pub note: Option<String>,
#[arg(long = "operator-mode")]
pub operator_mode: bool,
}
pub fn run(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
let cwd = std::env::current_dir()
.map_err(|e| generic_error(format!("determine current directory: {e}")))?;
let workspace_root = find_workspace_root(&cwd).ok_or_else(|| {
validation_error(format!(
"no workspace found above {}. Run `memstead mem-repo init` first or \
change directory into an existing workspace.",
cwd.display(),
))
})?;
let mem_name = args.path.to_str().map(|s| s.to_string()).ok_or_else(|| {
invalid_input_error(format!(
"mem name {:?} is not valid UTF-8 — mem names must be ASCII \
(lowercase letters / digits / hyphens) optionally segmented by '/'.",
args.path.display(),
))
})?;
let location = mem_name.clone();
let schema_ref: memstead_schema::SchemaRef = args
.schema
.parse()
.map_err(|e| invalid_input_error(format!("invalid schema ref {:?}: {e}", args.schema)))?;
let vcs_config = if args.vcs_shared {
Some(memstead_schema::VcsConfig {
gitdir: "../.git".to_string(),
worktree: "..".to_string(),
})
} else {
None
};
let write_guidance = match &args.write_guidance {
None => std::collections::HashMap::new(),
Some(raw) => {
serde_json::from_str::<std::collections::HashMap<String, serde_json::Value>>(raw)
.map_err(|e| {
invalid_input_error(format!("--write-guidance must be a JSON object: {e}"))
})?
}
};
let params = MemCreateParams {
name: mem_name.clone(),
location: PathBuf::from(&location),
schema_ref,
vcs: vcs_config,
note: args.note.clone(),
write_guidance,
operator_mode: resolve_operator_mode(args.operator_mode),
recovery: recovery_from_flags(args.reattach, args.force_overwrite, args.hard_cleanup_first),
};
let mut engine = match ctx.cli_engine()? {
CliEngine::MemRepo(e) => e,
CliEngine::Filesystem(_) => {
return Err(validation_error(format!(
"`memstead mem init` requires a mem-repo workspace; the workspace at {} is filesystem-shaped. Use `memstead mem-repo init` first to migrate.",
workspace_root.display(),
)));
}
};
let response =
mem_management::create_mem(&mut engine, params).map_err(pro_engine_err_to_cli)?;
if ctx.json {
crate::output::print_json(&serde_json::json!({
"name": response.name,
"location": response.location,
"schema_ref": response.schema_ref.to_string(),
"seed_commit_sha": response.seed_commit_sha,
"warnings": response
.warnings
.iter()
.map(|w| serde_json::json!({"code": w.code(), "message": w.message()}))
.collect::<Vec<_>>(),
}))?;
} else {
crate::output::print_markdown(&render_mem_create_markdown(&response));
}
if !args.no_gitignore {
let mem_repo_path = workspace_root.join("mem-repo");
let walk_start = workspace_root
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| workspace_root.clone());
match apply_outer_gitignore(&walk_start, &mem_repo_path)? {
OuterRepoOutcome::Appended { outer_root, rel } => {
if !ctx.quiet {
eprintln!(
" outer: {} — added `{}` to .gitignore",
outer_root.display(),
rel,
);
}
}
OuterRepoOutcome::AlreadyIgnored { outer_root, rel } => {
if !ctx.quiet {
eprintln!(
" outer: {} — `{}` already in .gitignore, no change",
outer_root.display(),
rel,
);
}
}
OuterRepoOutcome::NoOuter | OuterRepoOutcome::Skipped => {}
}
}
if let Some(note) =
mem_template_guidance_note(&response.schema_ref, args.write_guidance.is_some())
&& !ctx.quiet
{
eprintln!(" template: {note}");
}
Ok(())
}
fn mem_template_guidance_note(
schema_ref: &memstead_schema::SchemaRef,
guidance_given: bool,
) -> Option<String> {
if guidance_given {
return None;
}
let template = memstead_schema::builtins::builtin_mem_template(&schema_ref.name)?;
let wg = template.get("writeGuidance")?.as_object()?;
if wg.is_empty() {
return None;
}
let keys: Vec<&str> = wg.keys().map(String::as_str).collect();
let first = keys.first().copied().unwrap_or("key");
Some(format!(
"schema {schema_ref} ships a mem-template with instance guidance key(s) [{}] — \
the mem was created without guidance. Re-run with \
--write-guidance '{{\"{first}\": \"…\"}}' (or edit the mem config) to fill them.",
keys.join(", "),
))
}
pub fn run_delete(ctx: &CliContext, args: DeleteArgs) -> anyhow::Result<()> {
run_delete_inner(
ctx,
args.name,
args.note,
true,
"delete",
resolve_operator_mode(args.operator_mode),
)
}
pub fn run_unregister(ctx: &CliContext, args: UnregisterArgs) -> anyhow::Result<()> {
run_delete_inner(
ctx,
args.name,
args.note,
false,
"unregister",
resolve_operator_mode(args.operator_mode),
)
}
fn resolve_operator_mode(flag: bool) -> bool {
if flag {
return true;
}
match std::env::var("MEMSTEAD_OPERATOR_MODE") {
Ok(v) => matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"),
Err(_) => false,
}
}
fn run_delete_inner(
ctx: &CliContext,
name: String,
note: Option<String>,
delete_files: bool,
verb: &str,
operator_mode: bool,
) -> anyhow::Result<()> {
let cwd = std::env::current_dir()
.map_err(|e| generic_error(format!("determine current directory: {e}")))?;
let workspace_root = find_workspace_root(&cwd).ok_or_else(|| {
validation_error(format!(
"no workspace found above {}. `memstead mem {verb}` must run \
inside a configured workspace.",
cwd.display(),
))
})?;
let params = MemDeleteParams {
name: name.clone(),
delete_files,
note: note.clone(),
operator_mode,
};
let mut engine = match ctx.cli_engine()? {
CliEngine::MemRepo(e) => e,
CliEngine::Filesystem(_) => {
return Err(validation_error(format!(
"`memstead mem {verb}` requires a mem-repo workspace; the workspace at {} is filesystem-shaped.",
workspace_root.display(),
)));
}
};
let response =
mem_management::delete_mem(&mut engine, params).map_err(pro_engine_err_to_cli)?;
if ctx.json {
crate::output::print_json(&serde_json::json!({
"name": response.name,
"deleted_from_router": response.deleted_from_router,
"files_deleted": response.files_deleted,
"warnings": response
.warnings
.iter()
.map(|w| serde_json::json!({"code": w.code(), "message": w.message()}))
.collect::<Vec<_>>(),
"allowlist_entries_removed": &response.allowlist_entries_removed,
}))?;
} else {
crate::output::print_markdown(&render_mem_delete_markdown(&response, verb));
}
Ok(())
}
fn render_mem_create_markdown(r: &MemCreateResponse) -> String {
let reattached = r.warnings.iter().any(|w| {
matches!(
w,
memstead_base::ops::WarningHint::MemReattachedAfterUnregister { .. }
)
});
let heading = if reattached {
format!("# Mem `{}` reattached\n\n", r.name)
} else {
format!("# Mem `{}` created\n\n", r.name)
};
let mut out = heading;
out.push_str(&format!("- Location: `{}`\n", r.location.display()));
out.push_str(&format!("- Schema: `{}`\n", r.schema_ref));
out.push_str(&format!("- Seed commit: `{}`\n", r.seed_commit_sha));
if !r.warnings.is_empty() {
out.push_str("\n## Warnings\n\n");
for w in &r.warnings {
out.push_str(&format!("- **{}**: {}\n", w.code(), w.message()));
}
}
out
}
fn render_mem_delete_markdown(r: &MemDeleteResponse, verb: &str) -> String {
let past_participle = match verb {
"unregister" => "unregistered",
_ => "deleted",
};
let mut out = format!("# Mem `{}` {past_participle}\n\n", r.name);
out.push_str(&format!(
"- Removed from router: {}\n",
r.deleted_from_router,
));
out.push_str(&format!("- Files deleted: {}\n", r.files_deleted));
if !r.allowlist_entries_removed.is_empty() {
out.push_str("\n## Allowlist entries scrubbed\n\n");
for entry in &r.allowlist_entries_removed {
match (&entry.pattern, &entry.from, &entry.to) {
(Some(p), _, _) => {
out.push_str(&format!("- `[{}]` pattern `{p}`\n", entry.table,));
}
(_, Some(from), Some(to)) => {
out.push_str(&format!("- `[{}]` `{from} → {to}`\n", entry.table,));
}
_ => {
out.push_str(&format!("- `[{}]`\n", entry.table));
}
}
}
}
if !r.warnings.is_empty() {
out.push_str("\n## Warnings\n\n");
for w in &r.warnings {
out.push_str(&format!("- **{}**: {}\n", w.code(), w.message()));
}
}
out
}
fn pro_engine_err_to_cli(err: memstead_engine::FullEngineError) -> anyhow::Error {
match err {
memstead_engine::FullEngineError::Lean(inner) => CliError::from_engine_op(inner).into(),
lifecycle => {
let code = lifecycle.code();
let details = lifecycle.details();
let message = lifecycle.prose_render();
CliError {
kind: ExitKind::Validation,
code,
message,
details: Some(details),
}
.into()
}
}
}
pub fn run_set_version(ctx: &CliContext, args: SetVersionArgs) -> anyhow::Result<()> {
let new_version = semver::Version::parse(&args.version).map_err(|e| {
invalid_input_error(format!(
"version {:?} is not a valid semver: {e}",
args.version,
))
})?;
let note = args.note.as_deref();
let outcome = match ctx.cli_engine()? {
crate::setup::CliEngine::MemRepo(mut engine) => engine
.set_mem_version(&args.name, new_version, note)
.map_err(crate::CliError::from_engine_op)?,
crate::setup::CliEngine::Filesystem(mut engine) => engine
.set_mem_version(&args.name, new_version, note)
.map_err(crate::CliError::from_engine_op)?,
};
if ctx.json {
crate::output::print_json(&outcome)?;
} else {
let old = outcome
.old_version
.as_ref()
.map(|v| v.to_string())
.unwrap_or_else(|| "<none>".to_string());
let warnings = if outcome.warnings.is_empty() {
String::new()
} else {
let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
};
crate::output::print_markdown(&format!(
"# Mem `{}` version updated\n\n- Old version: {}\n- New version: {}{}",
outcome.mem, old, outcome.new_version, warnings,
));
}
Ok(())
}
pub fn run_set_description(ctx: &CliContext, args: SetDescriptionArgs) -> anyhow::Result<()> {
let new_description = {
let trimmed = args.description.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
};
let note = args.note.as_deref();
let outcome = match ctx.cli_engine()? {
crate::setup::CliEngine::MemRepo(mut engine) => engine
.set_mem_description(&args.name, new_description, note)
.map_err(crate::CliError::from_engine_op)?,
crate::setup::CliEngine::Filesystem(mut engine) => engine
.set_mem_description(&args.name, new_description, note)
.map_err(crate::CliError::from_engine_op)?,
};
if ctx.json {
crate::output::print_json(&outcome)?;
} else {
let old = outcome.old_description.as_deref().unwrap_or("<none>");
let new = outcome.new_description.as_deref().unwrap_or("<cleared>");
let warnings = if outcome.warnings.is_empty() {
String::new()
} else {
let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
};
crate::output::print_markdown(&format!(
"# Mem `{}` description updated\n\n- Old: {}\n- New: {}{}",
outcome.mem, old, new, warnings,
));
}
Ok(())
}
#[derive(Args, Debug)]
pub struct SetInternalArgs {
pub name: String,
#[arg(long)]
pub off: bool,
#[arg(long)]
pub note: Option<String>,
}
pub fn run_set_internal(ctx: &CliContext, args: SetInternalArgs) -> anyhow::Result<()> {
let internal = !args.off;
let note = args.note.as_deref();
let applied = match ctx.cli_engine()? {
crate::setup::CliEngine::MemRepo(mut engine) => engine
.set_mem_internal(&args.name, internal, note)
.map_err(crate::CliError::from_engine_op)?,
crate::setup::CliEngine::Filesystem(mut engine) => engine
.set_mem_internal(&args.name, internal, note)
.map_err(crate::CliError::from_engine_op)?,
};
if ctx.json {
crate::output::print_json(&serde_json::json!({ "mem": args.name, "internal": applied }))?;
} else {
crate::output::print_markdown(&format!(
"# Mem `{}` {}\n\nHidden from the default overview: **{}**. Inspect with \
`memstead overview --mem {}`.",
args.name,
if applied {
"marked internal"
} else {
"un-marked internal"
},
applied,
args.name,
));
}
Ok(())
}
pub fn run_set_sync_state(ctx: &CliContext, args: SetSyncStateArgs) -> anyhow::Result<()> {
let note = args.note.as_deref();
let outcome = match ctx.cli_engine()? {
crate::setup::CliEngine::MemRepo(mut engine) => engine
.set_mem_sync_state(&args.name, &args.key, &args.token, note)
.map_err(crate::CliError::from_engine_op)?,
crate::setup::CliEngine::Filesystem(mut engine) => engine
.set_mem_sync_state(&args.name, &args.key, &args.token, note)
.map_err(crate::CliError::from_engine_op)?,
};
if ctx.json {
crate::output::print_json(&outcome)?;
} else {
let action = if outcome.removed {
"cleared".to_string()
} else if outcome.previous.is_some() {
"overwrote".to_string()
} else {
"set".to_string()
};
let warnings = if outcome.warnings.is_empty() {
String::new()
} else {
let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
};
crate::output::print_markdown(&format!(
"# Mem `{}` sync state {}\n\n- Key: `{}`{}",
outcome.mem, action, outcome.key, warnings,
));
}
Ok(())
}
pub fn run_set_schema(ctx: &CliContext, args: SetSchemaArgs) -> anyhow::Result<()> {
let target: memstead_schema::SchemaRef = args
.schema
.parse()
.map_err(|e| invalid_input_error(format!("invalid schema ref {:?}: {e}", args.schema)))?;
let outcome = match ctx.cli_engine()? {
crate::setup::CliEngine::MemRepo(mut engine) => engine
.set_mem_schema(&args.name, &target)
.map_err(crate::CliError::from_engine_op)?,
crate::setup::CliEngine::Filesystem(mut engine) => engine
.set_mem_schema(&args.name, &target)
.map_err(crate::CliError::from_engine_op)?,
};
if ctx.json {
crate::output::print_json(&outcome)?;
} else {
let findings = if outcome.findings.is_empty() {
String::new()
} else {
let rendered: Vec<String> = outcome
.findings
.iter()
.map(|f| format!("- {} — {}", f.id, f.code))
.collect();
format!("\n\n## Non-integral entities\n\n{}", rendered.join("\n"))
};
crate::output::print_markdown(&format!(
"# Mem `{}` schema: {:?}\n\n- Pin: {}\n- Migration target: {}{}",
outcome.mem,
outcome.outcome,
outcome.schema_pin,
outcome.migration_target.as_deref().unwrap_or("<none>"),
findings,
));
}
Ok(())
}
fn generic_error(msg: String) -> anyhow::Error {
CliError {
code: "MEM_ERROR",
kind: ExitKind::Generic,
message: msg,
details: None,
}
.into()
}
fn validation_error(msg: String) -> anyhow::Error {
CliError {
code: "VALIDATION_FAILED",
kind: ExitKind::Validation,
message: msg,
details: None,
}
.into()
}
fn invalid_input_error(msg: String) -> anyhow::Error {
CliError {
code: "INVALID_INPUT",
kind: ExitKind::Validation,
message: msg,
details: None,
}
.into()
}
fn recovery_from_flags(
reattach: bool,
force_overwrite: bool,
hard_cleanup_first: bool,
) -> Option<memstead_engine::RecoveryAction> {
if reattach {
Some(memstead_engine::RecoveryAction::Reattach)
} else if force_overwrite {
Some(memstead_engine::RecoveryAction::ForceOverwrite)
} else if hard_cleanup_first {
Some(memstead_engine::RecoveryAction::HardCleanupFirst)
} else {
None
}
}
pub fn run_list(ctx: &CliContext, _args: ListArgs) -> anyhow::Result<()> {
let setup_ctx = CliContext {
json: ctx.json,
quiet: ctx.quiet,
};
let engine = crate::setup::pro_engine(&setup_ctx)
.map_err(|e| generic_error(format!("mem list: could not initialize engine: {e}")))?;
let mut rows: Vec<serde_json::Value> = Vec::new();
for name in engine.mem_names() {
let cfg = engine
.mem_configs_named()
.find(|(n, _)| *n == name)
.map(|(_, c)| c);
let entity_count = engine
.store()
.all_entities()
.filter(|e| e.id.mem() == name && !e.stub)
.count();
let capability = if engine.mem_router().is_writable(name) {
"write"
} else {
"read_only"
};
rows.push(serde_json::json!({
"name": name,
"schema_ref": cfg.and_then(|c| c.schema.as_ref()).map(|s| s.to_string()),
"version": cfg.and_then(|c| c.version.clone()),
"entity_count": entity_count,
"capability": capability,
}));
}
if ctx.json {
crate::output::print_json(&serde_json::json!({ "mems": rows }))?;
return Ok(());
}
let mut lines: Vec<String> = vec![format!("# Mems ({})", rows.len()), String::new()];
if rows.is_empty() {
lines.push("_no mems mounted_".to_string());
} else {
for v in &rows {
let name = v["name"].as_str().unwrap_or("?");
let schema = v["schema_ref"].as_str().unwrap_or("—");
let version = v["version"].as_str().unwrap_or("—");
let count = v["entity_count"].as_u64().unwrap_or(0);
let cap = v["capability"].as_str().unwrap_or("?");
lines.push(format!(
"- `{name}` ({cap}) — schema `{schema}`, version `{version}`, {count} entities"
));
}
}
crate::output::print_markdown(&lines.join("\n"));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use memstead_base::EngineError;
use memstead_base::ReferrerInfo;
use memstead_engine::FullEngineError;
use std::path::PathBuf;
fn lifted_cli_error(err: FullEngineError) -> CliError {
let any = pro_engine_err_to_cli(err);
any.downcast::<CliError>()
.expect("pro_engine_err_to_cli must lift to a CliError")
}
#[test]
fn mem_template_guidance_note_surfaces_builtin_keys() {
let planning: memstead_schema::SchemaRef = "planning@0.1.0".parse().unwrap();
let note = mem_template_guidance_note(&planning, false)
.expect("planning ships a mem-template — a note is due");
assert!(note.contains("phase_context"), "note names the key: {note}");
assert!(
note.contains("--write-guidance"),
"note tells how to fill: {note}"
);
assert!(mem_template_guidance_note(&planning, false).is_some());
assert!(mem_template_guidance_note(&planning, true).is_none());
let default_: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
assert!(mem_template_guidance_note(&default_, false).is_none());
}
#[test]
fn mem_has_incoming_refs_keeps_typed_code_and_carries_details() {
let err = FullEngineError::Lean(EngineError::MemHasIncomingRefs {
mem: "other".to_string(),
referrers: vec![ReferrerInfo {
from_id: "test--source".to_string(),
rel_types: vec!["USES".to_string()],
mem: "test".to_string(),
}],
});
let cli = lifted_cli_error(err);
assert_eq!(cli.code, "MEM_HAS_INCOMING_REFS");
assert_eq!(cli.kind, ExitKind::Validation);
let details = cli.details.expect("details must reach the CLI envelope");
assert_eq!(details["mem"], "other");
let referrers = details["referrers"].as_array().expect("referrers array");
assert_eq!(referrers.len(), 1);
assert_eq!(referrers[0]["from_id"], "test--source");
assert_eq!(referrers[0]["mem"], "test");
}
#[test]
fn mem_path_not_allowed_carries_structured_details() {
let err = FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/ws/bogus"),
candidate: "bogus".to_string(),
patterns: vec!["specs".to_string(), "team/*".to_string()],
reason: "no_match",
policy_table: "mem_management.create",
};
let cli = lifted_cli_error(err);
assert_eq!(cli.code, "MEM_PATH_NOT_ALLOWED");
assert_eq!(cli.kind, ExitKind::Validation);
let details = cli.details.expect("details");
assert_eq!(details["candidate"], "bogus");
assert_eq!(details["reason"], "no_match");
assert_eq!(details["patterns"][0], "specs");
assert_eq!(details["policy_table"], "mem_management.create");
assert_eq!(details["patterns"][1], "team/*");
}
#[test]
fn lifecycle_refusal_never_degrades_to_validation_failed_token() {
let cases = [
FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/x"),
candidate: "x".to_string(),
patterns: vec![],
reason: "no_allowlist_configured",
policy_table: "mem_management.create",
},
FullEngineError::MemReferencedByPolicy {
name: "x".to_string(),
referring_mems: vec!["y".to_string()],
},
FullEngineError::MemSchemaNotAllowed {
candidate: "x".to_string(),
matched_pattern: "p".to_string(),
requested_schema: "default@1.0.0".to_string(),
allowed_schemas: vec!["other@1.0.0".to_string()],
},
FullEngineError::InvalidMemName {
name: "BadName".to_string(),
reason: "invalid_char",
},
];
for err in cases {
let cli = lifted_cli_error(err);
assert_ne!(
cli.code, "VALIDATION_FAILED",
"engine-sourced refusal must carry its typed code: got {} with details {:?}",
cli.code, cli.details,
);
}
}
#[test]
fn wrapped_lean_error_preserves_per_variant_exit_kind() {
let err = FullEngineError::Lean(EngineError::NotFound {
id: "specs--missing".to_string(),
});
let cli = lifted_cli_error(err);
assert_eq!(cli.code, "ENTITY_NOT_FOUND");
assert_eq!(cli.kind, ExitKind::NotFound);
assert_eq!(cli.details.as_ref().unwrap()["id"], "specs--missing");
}
}