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`).
41    pub id: String,
42
43    /// The target type, as declared by the mem's schema.
44    #[arg(long = "type", value_name = "TYPE")]
45    pub target_type: String,
46
47    /// Section key renames applied before validation: `old=new`,
48    /// repeatable or comma-separated (`statement=conclusion,notes=context`).
49    #[arg(long = "section-map", value_name = "OLD=NEW", value_delimiter = ',')]
50    pub section_map: Vec<String>,
51
52    /// Metadata keys to drop explicitly, comma-separated or repeatable:
53    /// fields the current type declares and the target does not (a spec's
54    /// `level` on the way to a memo). Never inferred — an undeclared field
55    /// that is not listed refuses `UNKNOWN_METADATA_FIELD`.
56    #[arg(long = "drop-metadata", value_name = "KEY", value_delimiter = ',')]
57    pub drop_metadata: Vec<String>,
58
59    /// Hash from `memstead entity <id>`. Required unless `--auto-hash`,
60    /// `--force`, or `--dry-run`.
61    #[arg(long = "expected-hash", value_name = "HASH")]
62    pub expected_hash: Option<String>,
63
64    /// Refetch the current hash immediately before writing.
65    #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
66    pub auto_hash: bool,
67
68    /// Skip the hash check (explicit overwrite).
69    #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
70    pub force: bool,
71
72    /// Validate everything and report the prospective hash without
73    /// writing, committing, or changing the store.
74    #[arg(long)]
75    pub dry_run: bool,
76
77    /// Agent-authored provenance note (≤280 chars). When
78    /// `[mutations].require_notes = true` a missing note adds a
79    /// `NOTE_MISSING` warning.
80    #[arg(long)]
81    pub note: Option<String>,
82}
83
84fn parse_section_map(raw: &[String]) -> anyhow::Result<IndexMap<String, String>> {
85    let mut map = IndexMap::new();
86    for entry in raw {
87        let Some((from, to)) = entry.split_once('=') else {
88            return Err(CliError::new(
89                ExitKind::Validation,
90                "INVALID_INPUT",
91                format!("--section-map entry `{entry}` is not `old=new`"),
92            )
93            .into());
94        };
95        let (from, to) = (from.trim(), to.trim());
96        if from.is_empty() || to.is_empty() {
97            return Err(CliError::new(
98                ExitKind::Validation,
99                "INVALID_INPUT",
100                format!("--section-map entry `{entry}` has an empty side"),
101            )
102            .into());
103        }
104        map.insert(from.to_string(), to.to_string());
105    }
106    Ok(map)
107}
108
109pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
110    let id = EntityId::canonical(&args.id);
111    let section_map = parse_section_map(&args.section_map)?;
112
113    let outcome = match ctx.cli_engine()? {
114        #[cfg(feature = "mem-repo")]
115        CliEngine::MemRepo(mut engine) => {
116            let expected_hash = resolve_expected_hash(&engine, &id, &args)?;
117            let mem_repo_ctx = crate::setup::cli_ctx_with_note(args.note.clone());
118            engine.set_role(mem_repo_ctx.role);
119            engine.set_identity(mem_repo_ctx.identity.clone());
120            let outcome = engine
121                .retype_entity(
122                    RetypeEntityArgs {
123                        id: id.clone(),
124                        expected_hash,
125                        target_type: args.target_type.clone(),
126                        section_map: section_map.clone(),
127                        drop_metadata: args.drop_metadata.clone(),
128                        dry_run: args.dry_run,
129                    },
130                    mem_repo_ctx.actor,
131                    mem_repo_ctx.client.as_ref(),
132                    args.note.as_deref(),
133                )
134                .map_err(CliError::from_engine_op)?;
135            let mem_changed = engine.take_mem_changed_notices();
136            (outcome, mem_changed)
137        }
138        CliEngine::Filesystem(mut engine) => {
139            let expected_hash = resolve_expected_hash(&engine, &id, &args)?;
140            let outcome = engine
141                .retype_entity(
142                    RetypeEntityArgs {
143                        id: id.clone(),
144                        expected_hash,
145                        target_type: args.target_type.clone(),
146                        section_map,
147                        drop_metadata: args.drop_metadata.clone(),
148                        dry_run: args.dry_run,
149                    },
150                    Actor::Cli,
151                    None,
152                    args.note.as_deref(),
153                )
154                .map_err(CliError::from_engine_op)?;
155            (outcome, Vec::new())
156        }
157    };
158    let (outcome, mem_changed) = outcome;
159
160    if ctx.json {
161        let mut body = serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null);
162        if let Some(obj) = body.as_object_mut() {
163            obj.insert("dry_run".into(), serde_json::json!(args.dry_run));
164        }
165        super::merge_mem_changed_json(&mut body, &mem_changed);
166        print_json(&body)?;
167    } else {
168        let title = if args.dry_run {
169            "# Retype — dry run, nothing written"
170        } else {
171            "# Retyped"
172        };
173        let mut body = format!(
174            "{title}\n\n- `{}`: `{}` → `{}`\n- Path: {} (unchanged)\n- Hash: `{}`{}\n- Edges re-checked: {}\n",
175            outcome.id,
176            outcome.old_type,
177            outcome.new_type,
178            outcome.file_path,
179            outcome.content_hash,
180            outcome
181                .prospective_hash
182                .as_deref()
183                .map(|h| format!(" (would become `{h}`)"))
184                .unwrap_or_default(),
185            outcome.edges_rechecked,
186        );
187        if !outcome.sections_renamed.is_empty() {
188            body.push_str("- Sections renamed: ");
189            body.push_str(
190                &outcome
191                    .sections_renamed
192                    .iter()
193                    .map(|(a, b)| format!("`{a}` → `{b}`"))
194                    .collect::<Vec<_>>()
195                    .join(", "),
196            );
197            body.push('\n');
198        }
199        body.push_str(&format!("\n> {}\n", outcome.staleness_note));
200        if !outcome.warnings.is_empty() {
201            let parts: Vec<String> = outcome.warnings.iter().map(|w| w.to_string()).collect();
202            body.push_str(&format!("\n- Warnings: {}\n", parts.join("; ")));
203        }
204        body.push_str(&super::render_mem_changed_block(&mem_changed));
205        print_markdown(&body);
206    }
207    Ok(())
208}
209
210/// The `expected_hash` for the write: the flag value, or the live hash
211/// under `--auto-hash` / `--force`; `None` on a dry run, which skips the
212/// optimistic lock by contract.
213fn resolve_expected_hash(
214    engine: &memstead_base::Engine,
215    id: &EntityId,
216    args: &Args,
217) -> anyhow::Result<Option<String>> {
218    if args.dry_run {
219        return Ok(None);
220    }
221    if args.auto_hash || args.force {
222        return Ok(Some(
223            engine
224                .get_entity(id)
225                .ok_or_else(|| {
226                    CliError::new(
227                        ExitKind::NotFound,
228                        "ENTITY_NOT_FOUND",
229                        format!("entity not found: {id}"),
230                    )
231                    .with_details(serde_json::json!({ "id": id.to_string() }))
232                })?
233                .content_hash
234                .clone(),
235        ));
236    }
237    args.expected_hash
238        .clone()
239        .filter(|h| !h.is_empty())
240        .map(Some)
241        .ok_or_else(|| {
242            CliError::new(
243                ExitKind::Validation,
244                crate::HASH_FLAG_REQUIRED_CODE,
245                "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
246                 or use --auto-hash / --force / --dry-run.",
247            )
248            .into()
249        })
250}