memstead_cli/commands/batch_relate.rs
1//! `memstead batch-relate --from <file.json>` — apply many edge changes
2//! in one call: one workspace load, one commit per touched mem,
3//! all-or-nothing with report-all refusals.
4//!
5//! One list carries both additions and removals, **applied in order**
6//! (a later entry sees the effect of an earlier one — an add followed
7//! by a remove of the same edge nets to no edge). Each entry mirrors
8//! what `memstead relate` accepts, plus its own provenance `note` —
9//! per-entry, like the rest of the batch family; there is no
10//! batch-level note flag.
11//!
12//! ```json
13//! { "relates": [
14//! { "from": "specs--alpha", "rel_type": "USES", "to": "specs--beta" },
15//! { "from": "specs--alpha", "rel_type": "USES", "to": "specs--gamma",
16//! "remove": true, "note": "rehang: gamma superseded" },
17//! { "from": "specs--beta", "rel_type": "PART_OF", "to": "specs--suite",
18//! "description": "core member" }
19//! ] }
20//! ```
21
22use std::path::PathBuf;
23
24use clap::Parser;
25use serde::Deserialize;
26
27use memstead_base::vcs::Actor;
28use memstead_base::{EntityId, RelateEntityArgs};
29
30use crate::CliError;
31use crate::output::{ExitKind, print_json, print_markdown};
32use crate::setup::CliContext;
33
34#[derive(Parser, Debug)]
35pub struct Args {
36 /// JSON file with a top-level `relates: [...]` array.
37 #[arg(long = "from", value_name = "FILE")]
38 pub from: PathBuf,
39 /// Rehearse the whole batch: run the full in-order validation
40 /// (identical refusals, report-all) and report the would-be
41 /// receipt, committing nothing — no edge, no stub. `write_id`
42 /// stays empty (the rehearsal marker).
43 #[arg(long = "dry-run")]
44 pub dry_run: bool,
45}
46
47/// Per-entry payload — the `memstead relate` argument set, per entry:
48/// `from` / `rel_type` / `to`, optional `remove` (default add), optional
49/// per-edge `description` (add path only), optional per-entry `note`.
50#[derive(Debug, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct EntryPayload {
53 /// Source entity id.
54 from: String,
55 /// Rel-type (UPPER_SNAKE_CASE; engine canonicalises).
56 rel_type: String,
57 /// Target entity id.
58 to: String,
59 /// `false` (default) adds the edge; `true` removes it.
60 #[serde(default)]
61 remove: bool,
62 /// Per-edge description applied on add — validated against the
63 /// rel-type's `per_edge_description` posture, like single relate.
64 #[serde(default)]
65 description: Option<String>,
66 /// Agent-authored provenance note for THIS entry's commit record —
67 /// mirrors the family's per-entry note handling exactly.
68 #[serde(default)]
69 note: Option<String>,
70}
71
72pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
73 let entry_values = super::batch::parse_batch_envelope(&args.from, "relates")?;
74
75 let mut relates: Vec<(RelateEntityArgs, Option<String>)> =
76 Vec::with_capacity(entry_values.len());
77 for (idx, entry_value) in entry_values.into_iter().enumerate() {
78 let entry = match serde_json::from_value::<EntryPayload>(entry_value) {
79 Ok(entry) => entry,
80 Err(e) => {
81 return Err(CliError::new(
82 ExitKind::Validation,
83 "INVALID_INPUT",
84 format!("entry {idx}: invalid shape — {e}"),
85 )
86 .with_details(serde_json::json!({
87 "entry_index": idx,
88 "parser_error": e.to_string(),
89 }))
90 .into());
91 }
92 };
93 relates.push((
94 RelateEntityArgs {
95 source: EntityId::canonical(&entry.from),
96 expected_hash: None,
97 rel_type: entry.rel_type,
98 target: EntityId::canonical(&entry.to),
99 remove: entry.remove,
100 description: entry.description,
101 dry_run: false,
102 },
103 entry.note,
104 ));
105 }
106
107 let mut engine = crate::setup::full_engine(ctx)?;
108 let result = engine
109 .batch_relate(
110 relates,
111 Actor::Cli,
112 Some(&crate::setup::cli_client_id()),
113 args.dry_run,
114 )
115 .map_err(CliError::from_engine_op)?;
116 // Reload-before-op runs inside `batch_relate` for every mem the
117 // batch touches; drain any `mem_changed` notice it stashed.
118 let mem_changed = engine.take_mem_changed_notices();
119
120 if result.applied {
121 if ctx.json {
122 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
123 crate::commands::merge_mem_changed_json(&mut body, &mem_changed);
124 print_json(&body)?;
125 } else {
126 let mut md = super::batch::render_batch_markdown("relate", &result, args.dry_run);
127 md.push_str(&crate::commands::render_mem_changed_block(&mem_changed));
128 print_markdown(&md);
129 }
130 return Ok(());
131 }
132
133 // Refused batch: standard error envelope with the full result on
134 // `details` — same contract as `batch-update` (CLI F12).
135 if !ctx.json {
136 print_markdown(&super::batch::render_batch_markdown(
137 "relate",
138 &result,
139 args.dry_run,
140 ));
141 }
142 Err(super::batch::batch_refused_error("relate", &result).into())
143}