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, CliEngine};
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
63/// Resolve a per-slot value from the positional-OR-flag pair. Both
64/// supplied is an error (a named flag and a positional would be
65/// ambiguous); neither supplied is also an error (the slot is
66/// required). Either one alone is the success path.
67fn resolve_slot(
68    slot: &str,
69    positional: &Option<String>,
70    flag: &Option<String>,
71) -> Result<String, CliError> {
72    match (positional.as_deref(), flag.as_deref()) {
73        (Some(p), None) => Ok(p.to_string()),
74        (None, Some(f)) => Ok(f.to_string()),
75        (Some(_), Some(_)) => Err(CliError::new(
76            ExitKind::Validation,
77            "INVALID_INPUT",
78            format!("`{slot}` supplied as both positional and flag; pick one form"),
79        )),
80        (None, None) => Err(CliError::new(
81            ExitKind::Validation,
82            "INVALID_INPUT",
83            format!("`{slot}` not supplied — pass either as positional or via the `--{slot}` flag"),
84        )),
85    }
86}
87
88pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
89    let from_str = resolve_slot("from", &args.from_pos, &args.from_flag)?;
90    let rel_type = resolve_slot("rel-type", &args.rel_type_pos, &args.rel_type_flag)?;
91    let to_str = resolve_slot("to", &args.to_pos, &args.to_flag)?;
92    let from = EntityId::canonical(&from_str);
93    let to = EntityId::canonical(&to_str);
94    let remove = args.remove;
95
96    let mut engine = match ctx.cli_engine()? {
97        #[cfg(feature = "mem-repo")]
98        CliEngine::MemRepo(engine) => engine,
99        CliEngine::Filesystem(engine) => engine,
100    };
101    // Pass the `memstead-cli@<version>` client identity so the relate
102    // commit body carries the same `Client:` provenance trailer as
103    // create / update / rename (which set it via `cli_ctx_with_note`).
104    let client = crate::setup::cli_client_id();
105    let outcome = engine
106        .relate_entity(
107            RelateEntityArgs {
108                source: from.clone(),
109                target: to.clone(),
110                rel_type: rel_type.clone(),
111                remove,
112                expected_hash: None,
113                description: args.description.clone(),
114            },
115            Actor::Cli,
116            Some(&client),
117            args.note.as_deref(),
118        )
119        .map_err(CliError::from_engine_op)?;
120    let mem_changed = engine.take_mem_changed_notices();
121    if ctx.json {
122        // Always surface `orphan_stubs_removed` so agents and scripts branch
123        // uniformly — empty array on add paths and no-op removes,
124        // populated on remove paths that GC'd a stub.
125        let mut body = serde_json::json!({
126            "from": outcome.from.as_ref(),
127            "to": outcome.to.as_ref(),
128            "rel_type": outcome.rel_type,
129            "action": format!("{:?}", outcome.action),
130            "_hash": outcome.content_hash,
131            "warnings": outcome.warnings,
132            "orphan_stubs_removed": outcome
133                .orphan_stubs_removed
134                .iter()
135                .map(|i| i.to_string())
136                .collect::<Vec<_>>(),
137        });
138        super::merge_mem_changed_json(&mut body, &mem_changed);
139        print_json(&body)?;
140    } else {
141        let verb = if remove { "Removed" } else { "Added" };
142        let warnings_block = if outcome.warnings.is_empty() {
143            String::new()
144        } else {
145            let lines: Vec<String> = outcome
146                .warnings
147                .iter()
148                .map(|w| format!("> - {w}"))
149                .collect();
150            format!("\n\n> warnings:\n{}", lines.join("\n"))
151        };
152        let gc_block = if outcome.orphan_stubs_removed.is_empty() {
153            String::new()
154        } else {
155            let ids: Vec<String> = outcome
156                .orphan_stubs_removed
157                .iter()
158                .map(|i| format!("`{i}`"))
159                .collect();
160            format!("\n\n- orphan stubs GC'd: {}", ids.join(", "))
161        };
162        let mem_changed_block = super::render_mem_changed_block(&mem_changed);
163        print_markdown(&format!(
164            "# {verb} `{}` `{}` → `{}`{gc_block}{warnings_block}{mem_changed_block}",
165            outcome.from, outcome.rel_type, outcome.to,
166        ));
167    }
168    Ok(())
169}