Skip to main content

memstead_cli/commands/
relate.rs

1//! `memstead relate` — add or remove a typed relationship between two entities.
2
3use clap::Parser;
4
5use memstead_base::vcs::Actor;
6use memstead_base::{EntityId, RelateEntityArgs};
7
8use crate::CliError;
9use crate::output::{ExitKind, print_json, print_markdown};
10use crate::setup::CliContext;
11
12/// `memstead relate` accepts each argument as a positional OR as a
13/// named flag — the named forms (`--from`, `--rel-type`, `--to`)
14/// bring the command's call style in line with the sister mutation
15/// commands (`memstead create`, `memstead update`), which use `--title`,
16/// `--type`, etc. The positional form continues to work — existing
17/// scripts that pipe positional args don't break.
18#[derive(Parser, Debug)]
19pub struct Args {
20    /// Source entity ID (positional). Flag synonym: `--from`.
21    #[arg(value_name = "FROM")]
22    pub from_pos: Option<String>,
23
24    /// Relationship type (positional). Flag synonym: `--rel-type`.
25    /// UPPER_SNAKE_CASE, e.g. `USES`, `PART_OF`.
26    #[arg(value_name = "REL_TYPE")]
27    pub rel_type_pos: Option<String>,
28
29    /// Target entity ID (positional). Flag synonym: `--to`. Creates
30    /// a stub if the target doesn't exist.
31    #[arg(value_name = "TO")]
32    pub to_pos: Option<String>,
33
34    /// Source entity ID (named flag form).
35    #[arg(long = "from", value_name = "ID")]
36    pub from_flag: Option<String>,
37
38    /// Relationship type (named flag form).
39    #[arg(long = "rel-type", value_name = "REL_TYPE")]
40    pub rel_type_flag: Option<String>,
41
42    /// Target entity ID (named flag form).
43    #[arg(long = "to", value_name = "ID")]
44    pub to_flag: Option<String>,
45
46    /// Remove the relationship instead of creating it.
47    #[arg(long)]
48    pub remove: bool,
49
50    /// Per-edge description applied on add. Validated against the
51    /// rel-type's `per_edge_description` posture; rel-types declared
52    /// `forbidden` reject this flag, `required` reject its absence.
53    #[arg(long)]
54    pub description: Option<String>,
55
56    /// Agent-authored provenance note (≤280 chars). When
57    /// `[mutations].require_notes = true` a missing note adds a
58    /// `NOTE_MISSING` warning.
59    #[arg(long)]
60    pub note: Option<String>,
61
62    /// Rehearse the relate: run the full validation (identical
63    /// refusals and warnings) and report the would-be edge — including
64    /// a would-be auto-stub, which is reported, never created —
65    /// without writing anything. `commit_sha` stays empty (the
66    /// rehearsal marker); `_hash` is the prospective post-write hash.
67    #[arg(long = "dry-run")]
68    pub dry_run: bool,
69}
70
71/// Resolve a per-slot value from the positional-OR-flag pair. Both
72/// supplied is an error (a named flag and a positional would be
73/// ambiguous); neither supplied is also an error (the slot is
74/// required). Either one alone is the success path.
75fn resolve_slot(
76    slot: &str,
77    positional: &Option<String>,
78    flag: &Option<String>,
79) -> Result<String, CliError> {
80    match (positional.as_deref(), flag.as_deref()) {
81        (Some(p), None) => Ok(p.to_string()),
82        (None, Some(f)) => Ok(f.to_string()),
83        (Some(_), Some(_)) => Err(CliError::new(
84            ExitKind::Validation,
85            "INVALID_INPUT",
86            format!("`{slot}` supplied as both positional and flag; pick one form"),
87        )),
88        (None, None) => Err(CliError::new(
89            ExitKind::Validation,
90            "INVALID_INPUT",
91            format!("`{slot}` not supplied — pass either as positional or via the `--{slot}` flag"),
92        )),
93    }
94}
95
96pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
97    let from_str = resolve_slot("from", &args.from_pos, &args.from_flag)?;
98    let rel_type = resolve_slot("rel-type", &args.rel_type_pos, &args.rel_type_flag)?;
99    let to_str = resolve_slot("to", &args.to_pos, &args.to_flag)?;
100    let from = EntityId::canonical(&from_str);
101    let to = EntityId::canonical(&to_str);
102    let remove = args.remove;
103
104    let mut engine = ctx.cli_engine()?.into_base();
105    // Pass the `memstead-cli@<version>` client identity so the relate
106    // commit body carries the same `Client:` provenance trailer as
107    // create / update / rename (which set it via `cli_ctx_with_note`).
108    let client = crate::setup::cli_client_id();
109    let outcome = engine
110        .relate_entity(
111            RelateEntityArgs {
112                source: from.clone(),
113                target: to.clone(),
114                rel_type: rel_type.clone(),
115                remove,
116                expected_hash: None,
117                description: args.description.clone(),
118                dry_run: args.dry_run,
119            },
120            Actor::Cli,
121            Some(&client),
122            args.note.as_deref(),
123        )
124        .map_err(CliError::from_engine_op)?;
125    let mem_changed = engine.take_mem_changed_notices();
126    if ctx.json {
127        // Always surface `orphan_stubs_removed` so agents and scripts branch
128        // uniformly — empty array on add paths and no-op removes,
129        // populated on remove paths that GC'd a stub.
130        let mut body = serde_json::json!({
131            "from": outcome.from.as_ref(),
132            "to": outcome.to.as_ref(),
133            "rel_type": outcome.rel_type,
134            "action": format!("{:?}", outcome.action),
135            "_hash": outcome.content_hash,
136            // Empty on rehearsals (the marker form) and no-op paths;
137            // the real commit's sha otherwise.
138            "commit_sha": outcome.commit_sha,
139            "warnings": outcome.warnings,
140            "orphan_stubs_removed": outcome
141                .orphan_stubs_removed
142                .iter()
143                .map(|i| i.to_string())
144                .collect::<Vec<_>>(),
145        });
146        super::merge_mem_changed_json(&mut body, &mem_changed);
147        print_json(&body)?;
148    } else {
149        let verb = match (args.dry_run, remove) {
150            (true, true) => "Would remove (dry-run)",
151            (true, false) => "Would add (dry-run)",
152            (false, true) => "Removed",
153            (false, false) => "Added",
154        };
155        let warnings_block = if outcome.warnings.is_empty() {
156            String::new()
157        } else {
158            let lines: Vec<String> = outcome
159                .warnings
160                .iter()
161                .map(|w| format!("> - {w}"))
162                .collect();
163            format!("\n\n> warnings:\n{}", lines.join("\n"))
164        };
165        let gc_block = if outcome.orphan_stubs_removed.is_empty() {
166            String::new()
167        } else {
168            let ids: Vec<String> = outcome
169                .orphan_stubs_removed
170                .iter()
171                .map(|i| format!("`{i}`"))
172                .collect();
173            format!("\n\n- orphan stubs GC'd: {}", ids.join(", "))
174        };
175        let mem_changed_block = super::render_mem_changed_block(&mem_changed);
176        print_markdown(&format!(
177            "# {verb} `{}` `{}` → `{}`{gc_block}{warnings_block}{mem_changed_block}",
178            outcome.from, outcome.rel_type, outcome.to,
179        ));
180    }
181    Ok(())
182}