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