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")]
25 pub from_pos: Option<String>,
26
27 #[arg(value_name = "REL_TYPE")]
30 pub rel_type_pos: Option<String>,
31
32 #[arg(value_name = "TO")]
35 pub to_pos: Option<String>,
36
37 #[arg(long = "from", value_name = "ID")]
39 pub from_flag: Option<String>,
40
41 #[arg(long = "rel-type", value_name = "REL_TYPE")]
43 pub rel_type_flag: Option<String>,
44
45 #[arg(long = "to", value_name = "ID")]
47 pub to_flag: Option<String>,
48
49 #[arg(long)]
51 pub remove: bool,
52
53 #[arg(long)]
57 pub description: Option<String>,
58
59 #[arg(long)]
63 pub note: Option<String>,
64
65 #[arg(long = "dry-run")]
71 pub dry_run: bool,
72}
73
74fn 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 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 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 "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}