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", "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}