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