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.
19    pub id: String,
20
21    /// New title. The ID is re-derived from the title.
22    pub new_title: String,
23
24    /// Hash from `memstead entity <id>`. Required unless `--auto-hash` or `--force`.
25    #[arg(long = "expected-hash", value_name = "HASH")]
26    pub expected_hash: Option<String>,
27
28    /// Refetch the current hash immediately before writing.
29    #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
30    pub auto_hash: bool,
31
32    /// Skip the hash check (explicit overwrite).
33    #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
34    pub force: bool,
35
36    /// Agent-authored provenance note (≤280 chars). When
37    /// `[mutations].require_notes = true` a missing note adds a
38    /// `NOTE_MISSING` warning.
39    #[arg(long)]
40    pub note: Option<String>,
41}
42
43pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
44    let id = EntityId::canonical(&args.id);
45    let new_title = args.new_title.clone();
46
47    match ctx.cli_engine()? {
48        #[cfg(feature = "mem-repo")]
49        CliEngine::MemRepo(mut engine) => {
50            let expected_hash = resolve_expected_hash_mem_repo(&engine, &id, &args)?;
51            let result = engine
52                .rename_entity_with_ctx(
53                    &id,
54                    &new_title,
55                    &expected_hash,
56                    &crate::setup::cli_ctx_with_note(args.note.clone()),
57                )
58                .map_err(CliError::from_engine_op)?;
59            let mem_changed = engine.take_mem_changed_notices();
60            if ctx.json {
61                let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
62                super::merge_mem_changed_json(&mut body, &mem_changed);
63                print_json(&body)?;
64            } else {
65                print_markdown(&format!(
66                    "# Renamed\n\n- `{}` → `{}`\n- Path: {} → {}\n- Hash: `{}`{}",
67                    result.old_id,
68                    result.new_id,
69                    result.old_path,
70                    result.new_path,
71                    result.content_hash,
72                    super::render_mem_changed_block(&mem_changed),
73                ));
74            }
75        }
76        CliEngine::Filesystem(mut engine) => {
77            let expected_hash = resolve_expected_hash_filesystem(&engine, &id, &args)?;
78            let outcome = engine
79                .rename_entity(
80                    RenameEntityArgs {
81                        id: id.clone(),
82                        expected_hash: Some(expected_hash),
83                        new_title: new_title.clone(),
84                    },
85                    Actor::Cli,
86                    None,
87                    args.note.as_deref(),
88                )
89                .map_err(CliError::from_engine_op)?;
90            if ctx.json {
91                print_json(&serde_json::json!({
92                    "old_id": outcome.old_id.as_ref(),
93                    "new_id": outcome.new_id.as_ref(),
94                    "old_path": outcome.old_path,
95                    "new_path": outcome.new_path,
96                    "_hash": outcome.content_hash,
97                    // Backend write identity — response-shape parity with
98                    // the MCP filesystem flavour.
99                    "write_id": outcome.write_id,
100                    // Engine-emitted warnings (e.g. `NOTE_MISSING` under
101                    // `[mutations].require_notes`).
102                    "warnings": outcome.warnings,
103                }))?;
104            } else {
105                let mut body = format!(
106                    "# Renamed\n\n- `{}` → `{}`\n- Path: {} → {}\n- Hash: `{}`",
107                    outcome.old_id,
108                    outcome.new_id,
109                    outcome.old_path,
110                    outcome.new_path,
111                    outcome.content_hash,
112                );
113                if !outcome.warnings.is_empty() {
114                    let parts: Vec<String> =
115                        outcome.warnings.iter().map(|w| w.to_string()).collect();
116                    body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
117                }
118                print_markdown(&body);
119            }
120        }
121    }
122    Ok(())
123}
124
125/// Resolve the `expected_hash` for the mem-repo path: either the
126/// flag value, or the live hash from the store under `--auto-hash` /
127/// `--force`. Mirrors the original inline logic — extracted only so
128/// the filesystem path can run the same flag plumbing without
129/// duplicating it.
130#[cfg(feature = "mem-repo")]
131fn resolve_expected_hash_mem_repo(
132    engine: &memstead_base::Engine,
133    id: &EntityId,
134    args: &Args,
135) -> anyhow::Result<String> {
136    if args.auto_hash || args.force {
137        Ok(engine
138            .get_entity(id)
139            .ok_or_else(|| {
140                CliError::new(
141                    ExitKind::NotFound,
142                    "ENTITY_NOT_FOUND",
143                    format!("entity not found: {id}"),
144                )
145                .with_details(serde_json::json!({ "id": id.to_string() }))
146            })?
147            .content_hash
148            .clone())
149    } else {
150        args.expected_hash
151            .clone()
152            .filter(|h| !h.is_empty())
153            .ok_or_else(|| {
154                CliError::new(
155                    ExitKind::Validation,
156                    crate::HASH_FLAG_REQUIRED_CODE,
157                    "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
158                     or use --auto-hash / --force.",
159                )
160                .into()
161            })
162    }
163}
164
165fn resolve_expected_hash_filesystem(
166    engine: &memstead_base::Engine,
167    id: &EntityId,
168    args: &Args,
169) -> anyhow::Result<String> {
170    if args.auto_hash || args.force {
171        Ok(engine
172            .get_entity(id)
173            .ok_or_else(|| {
174                CliError::new(
175                    ExitKind::NotFound,
176                    "ENTITY_NOT_FOUND",
177                    format!("entity not found: {id}"),
178                )
179                .with_details(serde_json::json!({ "id": id.to_string() }))
180            })?
181            .content_hash
182            .clone())
183    } else {
184        args.expected_hash
185            .clone()
186            .filter(|h| !h.is_empty())
187            .ok_or_else(|| {
188                CliError::new(
189                    ExitKind::Validation,
190                    crate::HASH_FLAG_REQUIRED_CODE,
191                    "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
192                     or use --auto-hash / --force.",
193                )
194                .into()
195            })
196    }
197}