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", "type": "USES", "to": "specs--beta" },
15//! { "from": "specs--alpha", "type": "USES", "to": "specs--gamma",
16//! "remove": true, "note": "rehang: gamma superseded" },
17//! { "from": "specs--beta", "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. `commit_sha`
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` / `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 #[serde(rename = "type")]
57 rel_type: String,
58 /// Target entity id.
59 to: String,
60 /// `false` (default) adds the edge; `true` removes it.
61 #[serde(default)]
62 remove: bool,
63 /// Per-edge description applied on add — validated against the
64 /// rel-type's `per_edge_description` posture, like single relate.
65 #[serde(default)]
66 description: Option<String>,
67 /// Agent-authored provenance note for THIS entry's commit record —
68 /// mirrors the family's per-entry note handling exactly.
69 #[serde(default)]
70 note: Option<String>,
71}
72
73pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
74 let entry_values = super::batch::parse_batch_envelope(&args.from, "relates")?;
75
76 let mut relates: Vec<(RelateEntityArgs, Option<String>)> =
77 Vec::with_capacity(entry_values.len());
78 for (idx, entry_value) in entry_values.into_iter().enumerate() {
79 let entry = match serde_json::from_value::<EntryPayload>(entry_value) {
80 Ok(entry) => entry,
81 Err(e) => {
82 return Err(CliError::new(
83 ExitKind::Validation,
84 "INVALID_INPUT",
85 format!("entry {idx}: invalid shape — {e}"),
86 )
87 .with_details(serde_json::json!({
88 "entry_index": idx,
89 "parser_error": e.to_string(),
90 }))
91 .into());
92 }
93 };
94 relates.push((
95 RelateEntityArgs {
96 source: EntityId::canonical(&entry.from),
97 expected_hash: None,
98 rel_type: entry.rel_type,
99 target: EntityId::canonical(&entry.to),
100 remove: entry.remove,
101 description: entry.description,
102 dry_run: false,
103 },
104 entry.note,
105 ));
106 }
107
108 let mut engine = crate::setup::full_engine(ctx)?;
109 let result = engine
110 .batch_relate(
111 relates,
112 Actor::Cli,
113 Some(&crate::setup::cli_client_id()),
114 args.dry_run,
115 )
116 .map_err(CliError::from_engine_op)?;
117 // Reload-before-op runs inside `batch_relate` for every mem the
118 // batch touches; drain any `mem_changed` notice it stashed.
119 let mem_changed = engine.take_mem_changed_notices();
120
121 if result.applied {
122 if ctx.json {
123 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
124 crate::commands::merge_mem_changed_json(&mut body, &mem_changed);
125 print_json(&body)?;
126 } else {
127 let mut md = super::batch::render_batch_markdown("relate", &result);
128 md.push_str(&crate::commands::render_mem_changed_block(&mem_changed));
129 print_markdown(&md);
130 }
131 return Ok(());
132 }
133
134 // Refused batch: standard error envelope with the full result on
135 // `details` — same contract as `batch-update` (CLI F12).
136 if !ctx.json {
137 print_markdown(&super::batch::render_batch_markdown("relate", &result));
138 }
139 Err(super::batch::batch_refused_error("relate", &result).into())
140}