Skip to main content

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}