Skip to main content

memstead_cli/commands/
retype.rs

1//! `memstead retype <id> --type <target>` — change an entity's type in
2//! place. The id, file path, and every incoming edge stay; the existing
3//! sections and metadata are validated against the target type with a
4//! report-all refusal, every edge touching the entity is re-checked
5//! against the target's relationship pins, and one commit lands with its
6//! own `retype` provenance kind. Because the content hash moves, the
7//! response states that check records and derivation baselines on the
8//! entity are stale.
9//!
10//! Hash handling matches `memstead update`: strict by default, `--auto-hash`
11//! refetches from the store, `--force` explicitly accepts the overwrite;
12//! `--dry-run` previews without a hash.
13
14use 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/// Change an entity's type in place. The existing sections and metadata
25/// must satisfy the target type; `--section-map old=new` renames section
26/// keys on the way (a section the target does not declare refuses
27/// `UNKNOWN_SECTION` with the target's declared sections and a proposed
28/// map in the details). Every incoming and outgoing edge, cross-mem
29/// included, is re-checked against the target type's relationship pins
30/// and a violation refuses `INVALID_REL_SHAPE` listing the offending
31/// edges; every problem is reported together in one refusal (mixed
32/// classes carry `RETYPE_REFUSED`). The id, file path, and incoming edges
33/// stay; one commit lands with the `retype` provenance kind; check records
34/// and derivation baselines on the entity become stale because its
35/// content hash moves, and the response says so. Referrers in a lazy
36/// (unloaded) mem are probed through storage; a mem that cannot be probed
37/// refuses `RETYPE_REFERRER_UNPROBEABLE` naming it.
38#[derive(Parser, Debug)]
39pub struct Args {
40    /// Entity ID (`mem--slug`). A bare slug resolves when exactly one mounted
41    /// mem carries it (announced as `SHORT_ID_RESOLVED`); otherwise refuses
42    /// `ENTITY_ID_MISSING_MEM` naming the candidates.
43    pub id: String,
44
45    /// The target type, as declared by the mem's schema.
46    #[arg(long = "type", value_name = "TYPE")]
47    pub target_type: String,
48
49    /// Section key renames applied before validation: `old=new`,
50    /// repeatable or comma-separated (`statement=conclusion,notes=context`).
51    #[arg(long = "section-map", value_name = "OLD=NEW", value_delimiter = ',')]
52    pub section_map: Vec<String>,
53
54    /// Metadata keys to drop explicitly, comma-separated or repeatable:
55    /// fields the current type declares and the target does not (a spec's
56    /// `level` on the way to a memo). Never inferred — an undeclared field
57    /// that is not listed refuses `UNKNOWN_METADATA_FIELD`.
58    #[arg(long = "drop-metadata", value_name = "KEY", value_delimiter = ',')]
59    pub drop_metadata: Vec<String>,
60
61    /// Hash from `memstead entity <id>`. Required unless `--auto-hash`,
62    /// `--force`, or `--dry-run`.
63    #[arg(long = "expected-hash", value_name = "HASH")]
64    pub expected_hash: Option<String>,
65
66    /// Refetch the current hash immediately before writing.
67    #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
68    pub auto_hash: bool,
69
70    /// Skip the hash check (explicit overwrite).
71    #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
72    pub force: bool,
73
74    /// Validate everything and report the prospective hash without
75    /// writing, committing, or changing the store.
76    #[arg(long)]
77    pub dry_run: bool,
78
79    /// Agent-authored provenance note (≤280 chars). When
80    /// `[mutations].require_notes = true` a missing note adds a
81    /// `NOTE_MISSING` warning.
82    #[arg(long)]
83    pub note: Option<String>,
84}
85
86fn parse_section_map(raw: &[String]) -> anyhow::Result<IndexMap<String, String>> {
87    let mut map = IndexMap::new();
88    for entry in raw {
89        let Some((from, to)) = entry.split_once('=') else {
90            return Err(CliError::new(
91                ExitKind::Validation,
92                "INVALID_INPUT",
93                format!("--section-map entry `{entry}` is not `old=new`"),
94            )
95            .into());
96        };
97        let (from, to) = (from.trim(), to.trim());
98        if from.is_empty() || to.is_empty() {
99            return Err(CliError::new(
100                ExitKind::Validation,
101                "INVALID_INPUT",
102                format!("--section-map entry `{entry}` has an empty side"),
103            )
104            .into());
105        }
106        map.insert(from.to_string(), to.to_string());
107    }
108    Ok(map)
109}
110
111pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
112    let id = EntityId::canonical(&args.id);
113    let section_map = parse_section_map(&args.section_map)?;
114
115    let outcome = match ctx.cli_engine()? {
116        #[cfg(feature = "mem-repo")]
117        CliEngine::MemRepo(mut engine) => {
118            // The hash preflight reads the entity the verb will act on,
119            // so a bare slug has to be resolved through the engine's one
120            // rule first — same seam `update`, `delete` and `rename` use.
121            // Reading the raw id here refused a short id with
122            // `ENTITY_NOT_FOUND` before the resolver ever ran.
123            let lookup_id = crate::setup::preflight_id(&mut engine, &id)?;
124            let expected_hash = resolve_expected_hash(&engine, &lookup_id, &args)?;
125            let mem_repo_ctx = crate::setup::cli_ctx_with_note(args.note.clone());
126            engine.set_role(mem_repo_ctx.role);
127            engine.set_identity(mem_repo_ctx.identity.clone());
128            let outcome = engine
129                .retype_entity(
130                    RetypeEntityArgs {
131                        id: id.clone(),
132                        expected_hash,
133                        target_type: args.target_type.clone(),
134                        section_map: section_map.clone(),
135                        drop_metadata: args.drop_metadata.clone(),
136                        dry_run: args.dry_run,
137                    },
138                    mem_repo_ctx.actor,
139                    mem_repo_ctx.client.as_ref(),
140                    args.note.as_deref(),
141                )
142                .map_err(CliError::from_engine_op)?;
143            let mem_changed = engine.take_mem_changed_notices();
144            (outcome, mem_changed)
145        }
146        CliEngine::Filesystem(mut engine) => {
147            let lookup_id = crate::setup::preflight_id(&mut engine, &id)?;
148            let expected_hash = resolve_expected_hash(&engine, &lookup_id, &args)?;
149            let outcome = engine
150                .retype_entity(
151                    RetypeEntityArgs {
152                        id: id.clone(),
153                        expected_hash,
154                        target_type: args.target_type.clone(),
155                        section_map,
156                        drop_metadata: args.drop_metadata.clone(),
157                        dry_run: args.dry_run,
158                    },
159                    Actor::Cli,
160                    None,
161                    args.note.as_deref(),
162                )
163                .map_err(CliError::from_engine_op)?;
164            (outcome, Vec::new())
165        }
166    };
167    let (outcome, mem_changed) = outcome;
168
169    if ctx.json {
170        let mut body = serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null);
171        if let Some(obj) = body.as_object_mut() {
172            obj.insert("dry_run".into(), serde_json::json!(args.dry_run));
173        }
174        super::merge_mem_changed_json(&mut body, &mem_changed);
175        print_json(&body)?;
176    } else {
177        let title = if args.dry_run {
178            "# Retype — dry run, nothing written"
179        } else {
180            "# Retyped"
181        };
182        let mut body = format!(
183            "{title}\n\n- `{}`: `{}` → `{}`\n- Path: {} (unchanged)\n- Hash: `{}`{}\n- Edges re-checked: {}\n",
184            outcome.id,
185            outcome.old_type,
186            outcome.new_type,
187            outcome.file_path,
188            outcome.content_hash,
189            outcome
190                .prospective_hash
191                .as_deref()
192                .map(|h| format!(" (would become `{h}`)"))
193                .unwrap_or_default(),
194            outcome.edges_rechecked,
195        );
196        if !outcome.sections_renamed.is_empty() {
197            body.push_str("- Sections renamed: ");
198            body.push_str(
199                &outcome
200                    .sections_renamed
201                    .iter()
202                    .map(|(a, b)| format!("`{a}` → `{b}`"))
203                    .collect::<Vec<_>>()
204                    .join(", "),
205            );
206            body.push('\n');
207        }
208        body.push_str(&format!("\n> {}\n", outcome.staleness_note));
209        if !outcome.warnings.is_empty() {
210            let parts: Vec<String> = outcome.warnings.iter().map(|w| w.to_string()).collect();
211            body.push_str(&format!("\n- Warnings: {}\n", parts.join("; ")));
212        }
213        body.push_str(&super::render_mem_changed_block(&mem_changed));
214        print_markdown(&body);
215    }
216    Ok(())
217}
218
219/// The `expected_hash` for the write: the flag value, or the live hash
220/// under `--auto-hash` / `--force`; `None` on a dry run, which skips the
221/// optimistic lock by contract.
222fn resolve_expected_hash(
223    engine: &memstead_base::Engine,
224    id: &EntityId,
225    args: &Args,
226) -> anyhow::Result<Option<String>> {
227    if args.dry_run {
228        return Ok(None);
229    }
230    if args.auto_hash || args.force {
231        return Ok(Some(
232            engine
233                .get_entity(id)
234                .ok_or_else(|| {
235                    CliError::new(
236                        ExitKind::NotFound,
237                        "ENTITY_NOT_FOUND",
238                        format!("entity not found: {id}"),
239                    )
240                    .with_details(serde_json::json!({ "id": id.to_string() }))
241                })?
242                .content_hash
243                .clone(),
244        ));
245    }
246    args.expected_hash
247        .clone()
248        .filter(|h| !h.is_empty())
249        .map(Some)
250        .ok_or_else(|| {
251            CliError::new(
252                ExitKind::Validation,
253                crate::HASH_FLAG_REQUIRED_CODE,
254                "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
255                 or use --auto-hash / --force / --dry-run.",
256            )
257            .into()
258        })
259}