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                    // Engine-emitted warnings (e.g. `NOTE_MISSING` under
98                    // `[mutations].require_notes`).
99                    "warnings": outcome.warnings,
100                }))?;
101            } else {
102                let mut body = format!(
103                    "# Renamed\n\n- `{}` → `{}`\n- Path: {} → {}\n- Hash: `{}`",
104                    outcome.old_id,
105                    outcome.new_id,
106                    outcome.old_path,
107                    outcome.new_path,
108                    outcome.content_hash,
109                );
110                if !outcome.warnings.is_empty() {
111                    let parts: Vec<String> =
112                        outcome.warnings.iter().map(|w| w.to_string()).collect();
113                    body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
114                }
115                print_markdown(&body);
116            }
117        }
118    }
119    Ok(())
120}
121
122/// Resolve the `expected_hash` for the mem-repo path: either the
123/// flag value, or the live hash from the store under `--auto-hash` /
124/// `--force`. Mirrors the original inline logic — extracted only so
125/// the filesystem path can run the same flag plumbing without
126/// duplicating it.
127#[cfg(feature = "mem-repo")]
128fn resolve_expected_hash_mem_repo(
129    engine: &memstead_base::Engine,
130    id: &EntityId,
131    args: &Args,
132) -> anyhow::Result<String> {
133    if args.auto_hash || args.force {
134        Ok(engine
135            .get_entity(id)
136            .ok_or_else(|| {
137                CliError::new(
138                    ExitKind::NotFound,
139                    "ENTITY_NOT_FOUND",
140                    format!("entity not found: {id}"),
141                )
142                .with_details(serde_json::json!({ "id": id.to_string() }))
143            })?
144            .content_hash
145            .clone())
146    } else {
147        args.expected_hash
148            .clone()
149            .filter(|h| !h.is_empty())
150            .ok_or_else(|| {
151                CliError::new(
152                    ExitKind::Validation,
153                    crate::HASH_FLAG_REQUIRED_CODE,
154                    "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
155                     or use --auto-hash / --force.",
156                )
157                .into()
158            })
159    }
160}
161
162fn resolve_expected_hash_filesystem(
163    engine: &memstead_base::Engine,
164    id: &EntityId,
165    args: &Args,
166) -> anyhow::Result<String> {
167    if args.auto_hash || args.force {
168        Ok(engine
169            .get_entity(id)
170            .ok_or_else(|| {
171                CliError::new(
172                    ExitKind::NotFound,
173                    "ENTITY_NOT_FOUND",
174                    format!("entity not found: {id}"),
175                )
176                .with_details(serde_json::json!({ "id": id.to_string() }))
177            })?
178            .content_hash
179            .clone())
180    } else {
181        args.expected_hash
182            .clone()
183            .filter(|h| !h.is_empty())
184            .ok_or_else(|| {
185                CliError::new(
186                    ExitKind::Validation,
187                    crate::HASH_FLAG_REQUIRED_CODE,
188                    "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
189                     or use --auto-hash / --force.",
190                )
191                .into()
192            })
193    }
194}