1use clap::Parser;
15use indexmap::IndexMap;
16
17use memstead_base::vcs::Actor;
18use memstead_base::{EntityId, RetypeEntityArgs};
19
20use crate::CliError;
21use crate::output::{ExitKind, print_json, print_markdown};
22use crate::setup::{CliContext, CliEngine};
23
24#[derive(Parser, Debug)]
39pub struct Args {
40 pub id: String,
44
45 #[arg(long = "type", value_name = "TYPE")]
47 pub target_type: String,
48
49 #[arg(long = "section-map", value_name = "OLD=NEW", value_delimiter = ',')]
52 pub section_map: Vec<String>,
53
54 #[arg(long = "drop-metadata", value_name = "KEY", value_delimiter = ',')]
59 pub drop_metadata: Vec<String>,
60
61 #[arg(long = "expected-hash", value_name = "HASH")]
64 pub expected_hash: Option<String>,
65
66 #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
68 pub auto_hash: bool,
69
70 #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
72 pub force: bool,
73
74 #[arg(long)]
77 pub dry_run: bool,
78
79 #[arg(long)]
83 pub note: Option<String>,
84}
85
86fn parse_section_map(raw: &[String]) -> anyhow::Result<IndexMap<String, String>> {
87 let mut map = IndexMap::new();
88 for entry in raw {
89 let Some((from, to)) = entry.split_once('=') else {
90 return Err(CliError::new(
91 ExitKind::Validation,
92 "INVALID_INPUT",
93 format!("--section-map entry `{entry}` is not `old=new`"),
94 )
95 .into());
96 };
97 let (from, to) = (from.trim(), to.trim());
98 if from.is_empty() || to.is_empty() {
99 return Err(CliError::new(
100 ExitKind::Validation,
101 "INVALID_INPUT",
102 format!("--section-map entry `{entry}` has an empty side"),
103 )
104 .into());
105 }
106 map.insert(from.to_string(), to.to_string());
107 }
108 Ok(map)
109}
110
111pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
112 let id = EntityId::canonical(&args.id);
113 let section_map = parse_section_map(&args.section_map)?;
114
115 let outcome = match ctx.cli_engine()? {
116 #[cfg(feature = "mem-repo")]
117 CliEngine::MemRepo(mut engine) => {
118 let lookup_id = crate::setup::preflight_id(&mut engine, &id)?;
124 let expected_hash = resolve_expected_hash(&engine, &lookup_id, &args)?;
125 let mem_repo_ctx = crate::setup::cli_ctx_with_note(args.note.clone());
126 engine.set_role(mem_repo_ctx.role);
127 engine.set_identity(mem_repo_ctx.identity.clone());
128 let outcome = engine
129 .retype_entity(
130 RetypeEntityArgs {
131 id: id.clone(),
132 expected_hash,
133 target_type: args.target_type.clone(),
134 section_map: section_map.clone(),
135 drop_metadata: args.drop_metadata.clone(),
136 dry_run: args.dry_run,
137 },
138 mem_repo_ctx.actor,
139 mem_repo_ctx.client.as_ref(),
140 args.note.as_deref(),
141 )
142 .map_err(CliError::from_engine_op)?;
143 let mem_changed = engine.take_mem_changed_notices();
144 (outcome, mem_changed)
145 }
146 CliEngine::Filesystem(mut engine) => {
147 let lookup_id = crate::setup::preflight_id(&mut engine, &id)?;
148 let expected_hash = resolve_expected_hash(&engine, &lookup_id, &args)?;
149 let outcome = engine
150 .retype_entity(
151 RetypeEntityArgs {
152 id: id.clone(),
153 expected_hash,
154 target_type: args.target_type.clone(),
155 section_map,
156 drop_metadata: args.drop_metadata.clone(),
157 dry_run: args.dry_run,
158 },
159 Actor::Cli,
160 None,
161 args.note.as_deref(),
162 )
163 .map_err(CliError::from_engine_op)?;
164 (outcome, Vec::new())
165 }
166 };
167 let (outcome, mem_changed) = outcome;
168
169 if ctx.json {
170 let mut body = serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null);
171 if let Some(obj) = body.as_object_mut() {
172 obj.insert("dry_run".into(), serde_json::json!(args.dry_run));
173 }
174 super::merge_mem_changed_json(&mut body, &mem_changed);
175 print_json(&body)?;
176 } else {
177 let title = if args.dry_run {
178 "# Retype — dry run, nothing written"
179 } else {
180 "# Retyped"
181 };
182 let mut body = format!(
183 "{title}\n\n- `{}`: `{}` → `{}`\n- Path: {} (unchanged)\n- Hash: `{}`{}\n- Edges re-checked: {}\n",
184 outcome.id,
185 outcome.old_type,
186 outcome.new_type,
187 outcome.file_path,
188 outcome.content_hash,
189 outcome
190 .prospective_hash
191 .as_deref()
192 .map(|h| format!(" (would become `{h}`)"))
193 .unwrap_or_default(),
194 outcome.edges_rechecked,
195 );
196 if !outcome.sections_renamed.is_empty() {
197 body.push_str("- Sections renamed: ");
198 body.push_str(
199 &outcome
200 .sections_renamed
201 .iter()
202 .map(|(a, b)| format!("`{a}` → `{b}`"))
203 .collect::<Vec<_>>()
204 .join(", "),
205 );
206 body.push('\n');
207 }
208 body.push_str(&format!("\n> {}\n", outcome.staleness_note));
209 if !outcome.warnings.is_empty() {
210 let parts: Vec<String> = outcome.warnings.iter().map(|w| w.to_string()).collect();
211 body.push_str(&format!("\n- Warnings: {}\n", parts.join("; ")));
212 }
213 body.push_str(&super::render_mem_changed_block(&mem_changed));
214 print_markdown(&body);
215 }
216 Ok(())
217}
218
219fn resolve_expected_hash(
223 engine: &memstead_base::Engine,
224 id: &EntityId,
225 args: &Args,
226) -> anyhow::Result<Option<String>> {
227 if args.dry_run {
228 return Ok(None);
229 }
230 if args.auto_hash || args.force {
231 return Ok(Some(
232 engine
233 .get_entity(id)
234 .ok_or_else(|| {
235 CliError::new(
236 ExitKind::NotFound,
237 "ENTITY_NOT_FOUND",
238 format!("entity not found: {id}"),
239 )
240 .with_details(serde_json::json!({ "id": id.to_string() }))
241 })?
242 .content_hash
243 .clone(),
244 ));
245 }
246 args.expected_hash
247 .clone()
248 .filter(|h| !h.is_empty())
249 .map(Some)
250 .ok_or_else(|| {
251 CliError::new(
252 ExitKind::Validation,
253 crate::HASH_FLAG_REQUIRED_CODE,
254 "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
255 or use --auto-hash / --force / --dry-run.",
256 )
257 .into()
258 })
259}