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, CliEngine};
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
63fn 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 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 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}