use clap::Parser;
use indexmap::IndexMap;
use memstead_base::vcs::Actor;
use memstead_base::{EntityId, RetypeEntityArgs};
use crate::CliError;
use crate::output::{ExitKind, print_json, print_markdown};
use crate::setup::{CliContext, CliEngine};
#[derive(Parser, Debug)]
pub struct Args {
pub id: String,
#[arg(long = "type", value_name = "TYPE")]
pub target_type: String,
#[arg(long = "section-map", value_name = "OLD=NEW", value_delimiter = ',')]
pub section_map: Vec<String>,
#[arg(long = "drop-metadata", value_name = "KEY", value_delimiter = ',')]
pub drop_metadata: Vec<String>,
#[arg(long = "expected-hash", value_name = "HASH")]
pub expected_hash: Option<String>,
#[arg(long, conflicts_with_all = ["expected_hash", "force"])]
pub auto_hash: bool,
#[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
pub force: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub note: Option<String>,
}
fn parse_section_map(raw: &[String]) -> anyhow::Result<IndexMap<String, String>> {
let mut map = IndexMap::new();
for entry in raw {
let Some((from, to)) = entry.split_once('=') else {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("--section-map entry `{entry}` is not `old=new`"),
)
.into());
};
let (from, to) = (from.trim(), to.trim());
if from.is_empty() || to.is_empty() {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("--section-map entry `{entry}` has an empty side"),
)
.into());
}
map.insert(from.to_string(), to.to_string());
}
Ok(map)
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
let id = EntityId::canonical(&args.id);
let section_map = parse_section_map(&args.section_map)?;
let outcome = match ctx.cli_engine()? {
#[cfg(feature = "mem-repo")]
CliEngine::MemRepo(mut engine) => {
let lookup_id = crate::setup::preflight_id(&mut engine, &id)?;
let expected_hash = resolve_expected_hash(&engine, &lookup_id, &args)?;
let mem_repo_ctx = crate::setup::cli_ctx_with_note(args.note.clone());
engine.set_role(mem_repo_ctx.role);
engine.set_identity(mem_repo_ctx.identity.clone());
let outcome = engine
.retype_entity(
RetypeEntityArgs {
id: id.clone(),
expected_hash,
target_type: args.target_type.clone(),
section_map: section_map.clone(),
drop_metadata: args.drop_metadata.clone(),
dry_run: args.dry_run,
},
mem_repo_ctx.actor,
mem_repo_ctx.client.as_ref(),
args.note.as_deref(),
)
.map_err(CliError::from_engine_op)?;
let mem_changed = engine.take_mem_changed_notices();
(outcome, mem_changed)
}
CliEngine::Filesystem(mut engine) => {
let lookup_id = crate::setup::preflight_id(&mut engine, &id)?;
let expected_hash = resolve_expected_hash(&engine, &lookup_id, &args)?;
let outcome = engine
.retype_entity(
RetypeEntityArgs {
id: id.clone(),
expected_hash,
target_type: args.target_type.clone(),
section_map,
drop_metadata: args.drop_metadata.clone(),
dry_run: args.dry_run,
},
Actor::Cli,
None,
args.note.as_deref(),
)
.map_err(CliError::from_engine_op)?;
(outcome, Vec::new())
}
};
let (outcome, mem_changed) = outcome;
if ctx.json {
let mut body = serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null);
if let Some(obj) = body.as_object_mut() {
obj.insert("dry_run".into(), serde_json::json!(args.dry_run));
}
super::merge_mem_changed_json(&mut body, &mem_changed);
print_json(&body)?;
} else {
let title = if args.dry_run {
"# Retype — dry run, nothing written"
} else {
"# Retyped"
};
let mut body = format!(
"{title}\n\n- `{}`: `{}` → `{}`\n- Path: {} (unchanged)\n- Hash: `{}`{}\n- Edges re-checked: {}\n",
outcome.id,
outcome.old_type,
outcome.new_type,
outcome.file_path,
outcome.content_hash,
outcome
.prospective_hash
.as_deref()
.map(|h| format!(" (would become `{h}`)"))
.unwrap_or_default(),
outcome.edges_rechecked,
);
if !outcome.sections_renamed.is_empty() {
body.push_str("- Sections renamed: ");
body.push_str(
&outcome
.sections_renamed
.iter()
.map(|(a, b)| format!("`{a}` → `{b}`"))
.collect::<Vec<_>>()
.join(", "),
);
body.push('\n');
}
body.push_str(&format!("\n> {}\n", outcome.staleness_note));
if !outcome.warnings.is_empty() {
let parts: Vec<String> = outcome.warnings.iter().map(|w| w.to_string()).collect();
body.push_str(&format!("\n- Warnings: {}\n", parts.join("; ")));
}
body.push_str(&super::render_mem_changed_block(&mem_changed));
print_markdown(&body);
}
Ok(())
}
fn resolve_expected_hash(
engine: &memstead_base::Engine,
id: &EntityId,
args: &Args,
) -> anyhow::Result<Option<String>> {
if args.dry_run {
return Ok(None);
}
if args.auto_hash || args.force {
return Ok(Some(
engine
.get_entity(id)
.ok_or_else(|| {
CliError::new(
ExitKind::NotFound,
"ENTITY_NOT_FOUND",
format!("entity not found: {id}"),
)
.with_details(serde_json::json!({ "id": id.to_string() }))
})?
.content_hash
.clone(),
));
}
args.expected_hash
.clone()
.filter(|h| !h.is_empty())
.map(Some)
.ok_or_else(|| {
CliError::new(
ExitKind::Validation,
crate::HASH_FLAG_REQUIRED_CODE,
"missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
or use --auto-hash / --force / --dry-run.",
)
.into()
})
}