memstead_cli/commands/
rename.rs1use clap::Parser;
7
8use memstead_base::vcs::Actor;
9use memstead_base::{EntityId, RenameEntityArgs};
10
11use crate::CliError;
12use crate::output::{ExitKind, print_json, print_markdown};
13use crate::setup::{CliContext, CliEngine};
14
15#[derive(Parser, Debug)]
16#[command(after_long_help = super::slug_derivation_help())]
17pub struct Args {
18 pub id: String,
23
24 pub new_title: String,
26
27 #[arg(long = "expected-hash", value_name = "HASH")]
29 pub expected_hash: Option<String>,
30
31 #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
33 pub auto_hash: bool,
34
35 #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
37 pub force: bool,
38
39 #[arg(long)]
43 pub note: Option<String>,
44}
45
46pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
47 let id = EntityId::canonical(&args.id);
48 let new_title = args.new_title.clone();
49
50 match ctx.cli_engine()? {
51 #[cfg(feature = "mem-repo")]
52 CliEngine::MemRepo(mut engine) => {
53 let lookup_id = crate::setup::preflight_id(&mut engine, &id)?;
54 let expected_hash = resolve_expected_hash_mem_repo(&engine, &lookup_id, &args)?;
55 let result = engine
56 .rename_entity_with_ctx(
57 &id,
58 &new_title,
59 &expected_hash,
60 &crate::setup::cli_ctx_with_note(args.note.clone()),
61 )
62 .map_err(CliError::from_engine_op)?;
63 let mem_changed = engine.take_mem_changed_notices();
64 if ctx.json {
65 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
66 super::merge_mem_changed_json(&mut body, &mem_changed);
67 print_json(&body)?;
68 } else {
69 let mut body = format!(
70 "# Renamed\n\n- `{}` → `{}`\n- Path: {} → {}\n- Hash: `{}`",
71 result.old_id,
72 result.new_id,
73 result.old_path,
74 result.new_path,
75 result.content_hash,
76 );
77 if !result.warnings.is_empty() {
82 let parts: Vec<String> =
83 result.warnings.iter().map(|w| w.to_string()).collect();
84 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
85 }
86 body.push_str(&super::render_mem_changed_block(&mem_changed));
87 print_markdown(&body);
88 }
89 }
90 CliEngine::Filesystem(mut engine) => {
91 let lookup_id = crate::setup::preflight_id(&mut engine, &id)?;
92 let expected_hash = resolve_expected_hash_filesystem(&engine, &lookup_id, &args)?;
93 let outcome = engine
94 .rename_entity(
95 RenameEntityArgs {
96 id: id.clone(),
97 expected_hash: Some(expected_hash),
98 new_title: new_title.clone(),
99 },
100 Actor::Cli,
101 None,
102 args.note.as_deref(),
103 )
104 .map_err(CliError::from_engine_op)?;
105 if ctx.json {
106 print_json(&serde_json::json!({
107 "old_id": outcome.old_id.as_ref(),
108 "new_id": outcome.new_id.as_ref(),
109 "old_path": outcome.old_path,
110 "new_path": outcome.new_path,
111 "_hash": outcome.content_hash,
112 "write_id": outcome.write_id,
115 "warnings": outcome.warnings,
118 }))?;
119 } else {
120 let mut body = format!(
121 "# Renamed\n\n- `{}` → `{}`\n- Path: {} → {}\n- Hash: `{}`",
122 outcome.old_id,
123 outcome.new_id,
124 outcome.old_path,
125 outcome.new_path,
126 outcome.content_hash,
127 );
128 if !outcome.warnings.is_empty() {
129 let parts: Vec<String> =
130 outcome.warnings.iter().map(|w| w.to_string()).collect();
131 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
132 }
133 print_markdown(&body);
134 }
135 }
136 }
137 Ok(())
138}
139
140#[cfg(feature = "mem-repo")]
146fn resolve_expected_hash_mem_repo(
147 engine: &memstead_base::Engine,
148 id: &EntityId,
149 args: &Args,
150) -> anyhow::Result<String> {
151 if args.auto_hash || args.force {
152 Ok(engine
153 .get_entity(id)
154 .ok_or_else(|| {
155 CliError::new(
156 ExitKind::NotFound,
157 "ENTITY_NOT_FOUND",
158 format!("entity not found: {id}"),
159 )
160 .with_details(serde_json::json!({ "id": id.to_string() }))
161 })?
162 .content_hash
163 .clone())
164 } else {
165 args.expected_hash
166 .clone()
167 .filter(|h| !h.is_empty())
168 .ok_or_else(|| {
169 CliError::new(
170 ExitKind::Validation,
171 crate::HASH_FLAG_REQUIRED_CODE,
172 "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
173 or use --auto-hash / --force.",
174 )
175 .into()
176 })
177 }
178}
179
180fn resolve_expected_hash_filesystem(
181 engine: &memstead_base::Engine,
182 id: &EntityId,
183 args: &Args,
184) -> anyhow::Result<String> {
185 if args.auto_hash || args.force {
186 Ok(engine
187 .get_entity(id)
188 .ok_or_else(|| {
189 CliError::new(
190 ExitKind::NotFound,
191 "ENTITY_NOT_FOUND",
192 format!("entity not found: {id}"),
193 )
194 .with_details(serde_json::json!({ "id": id.to_string() }))
195 })?
196 .content_hash
197 .clone())
198 } else {
199 args.expected_hash
200 .clone()
201 .filter(|h| !h.is_empty())
202 .ok_or_else(|| {
203 CliError::new(
204 ExitKind::Validation,
205 crate::HASH_FLAG_REQUIRED_CODE,
206 "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
207 or use --auto-hash / --force.",
208 )
209 .into()
210 })
211 }
212}