memstead_cli/commands/
relate.rs1use 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#[derive(Parser, Debug)]
19pub struct Args {
20 #[arg(value_name = "FROM")]
22 pub from_pos: Option<String>,
23
24 #[arg(value_name = "REL_TYPE")]
27 pub rel_type_pos: Option<String>,
28
29 #[arg(value_name = "TO")]
32 pub to_pos: Option<String>,
33
34 #[arg(long = "from", value_name = "ID")]
36 pub from_flag: Option<String>,
37
38 #[arg(long = "rel-type", value_name = "REL_TYPE")]
40 pub rel_type_flag: Option<String>,
41
42 #[arg(long = "to", value_name = "ID")]
44 pub to_flag: Option<String>,
45
46 #[arg(long)]
48 pub remove: bool,
49
50 #[arg(long)]
54 pub description: Option<String>,
55
56 #[arg(long)]
60 pub note: Option<String>,
61
62 #[arg(long = "dry-run")]
68 pub dry_run: bool,
69}
70
71fn 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 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 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 "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}