Skip to main content

memstead_cli/commands/
rename.rs

1//! `memstead rename` — change an entity's title, ID, file path, and every incoming wiki-link.
2//!
3//! Hash handling matches `memstead update`: strict by default, `--auto-hash`
4//! refetches from the store, `--force` explicitly accepts the overwrite.
5
6use 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    /// Current entity ID. A bare slug without the `mem--` prefix resolves
19    /// when exactly one mounted mem carries it (announced as
20    /// `SHORT_ID_RESOLVED`); otherwise refuses `ENTITY_ID_MISSING_MEM` naming
21    /// the candidates.
22    pub id: String,
23
24    /// New title. The ID is re-derived from the title.
25    pub new_title: String,
26
27    /// Hash from `memstead entity <id>`. Required unless `--auto-hash` or `--force`.
28    #[arg(long = "expected-hash", value_name = "HASH")]
29    pub expected_hash: Option<String>,
30
31    /// Refetch the current hash immediately before writing.
32    #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
33    pub auto_hash: bool,
34
35    /// Skip the hash check (explicit overwrite).
36    #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
37    pub force: bool,
38
39    /// Agent-authored provenance note (≤280 chars). When
40    /// `[mutations].require_notes = true` a missing note adds a
41    /// `NOTE_MISSING` warning.
42    #[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                // The same warnings line the filesystem branch and the
78                // sibling verbs render: a `SHORT_ID_RESOLVED` or
79                // `NOTE_MISSING` hint must not be visible only under
80                // `--json`.
81                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                    // Backend write identity — response-shape parity with
113                    // the MCP filesystem flavour.
114                    "write_id": outcome.write_id,
115                    // Engine-emitted warnings (e.g. `NOTE_MISSING` under
116                    // `[mutations].require_notes`).
117                    "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/// Resolve the `expected_hash` for the mem-repo path: either the
141/// flag value, or the live hash from the store under `--auto-hash` /
142/// `--force`. Mirrors the original inline logic — extracted only so
143/// the filesystem path can run the same flag plumbing without
144/// duplicating it.
145#[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}