1use anyhow::{anyhow, Result};
6use clap::{Arg, ArgMatches, Command};
7
8use cellstate_core::api_types::{
9 CreateEdgeRequest, EdgeParticipantRequest, EdgeResponse, ListEdgesResponse, ProvenanceRequest,
10 TraverseGraphResponse,
11};
12
13use super::client::ApiClient;
14use super::output::OutputConfig;
15
16const BASE: &str = "/api/v1/edges";
17
18pub fn build_command() -> Command {
19 Command::new("edge")
20 .about("Manage edges (graph relationships between entities)")
21 .subcommand_required(true)
22 .subcommand(
23 Command::new("create")
24 .about("Create a new edge")
25 .arg(
26 Arg::new("edge-type")
27 .long("type")
28 .required(true)
29 .help("Edge type (supports, contradicts, supersedes, derived_from, relates_to, temporal, causal, synthesized_from, grouped, compared)"),
30 )
31 .arg(
32 Arg::new("source-type")
33 .long("source-type")
34 .required(true)
35 .help("Source entity type (trajectory, scope, artifact, note, turn, agent, edge, etc.)"),
36 )
37 .arg(
38 Arg::new("source-id")
39 .long("source-id")
40 .required(true)
41 .help("Source entity ID (UUID)"),
42 )
43 .arg(
44 Arg::new("target-type")
45 .long("target-type")
46 .required(true)
47 .help("Target entity type (trajectory, scope, artifact, note, turn, agent, edge, etc.)"),
48 )
49 .arg(
50 Arg::new("target-id")
51 .long("target-id")
52 .required(true)
53 .help("Target entity ID (UUID)"),
54 )
55 .arg(
56 Arg::new("weight")
57 .long("weight")
58 .help("Relationship weight/strength (0.0-1.0)"),
59 )
60 .arg(
61 Arg::new("trajectory-id")
62 .long("trajectory-id")
63 .help("Trajectory ID for context"),
64 )
65 .arg(
66 Arg::new("source-turn")
67 .long("source-turn")
68 .default_value("0")
69 .help("Source turn number"),
70 )
71 .arg(
72 Arg::new("extraction-method")
73 .long("extraction-method")
74 .default_value("explicit")
75 .help("Extraction method (explicit, inferred, user_provided, llm_extraction, tool_extraction, memory_recall, external_api, unknown)"),
76 )
77 .arg(
78 Arg::new("confidence")
79 .long("confidence")
80 .help("Confidence score (0.0-1.0)"),
81 ),
82 )
83 .subcommand(
84 Command::new("batch")
85 .about("Batch create edges from JSON")
86 .arg(
87 Arg::new("json")
88 .required(true)
89 .help("JSON array of CreateEdgeRequest objects"),
90 ),
91 )
92 .subcommand(
93 Command::new("get")
94 .about("Get edge details")
95 .arg(Arg::new("id").required(true).help("Edge ID")),
96 )
97 .subcommand(
98 Command::new("delete")
99 .about("Delete an edge")
100 .arg(Arg::new("id").required(true).help("Edge ID")),
101 )
102 .subcommand(
103 Command::new("traverse")
104 .about("Traverse the graph from a starting entity")
105 .arg(
106 Arg::new("start-id")
107 .required(true)
108 .help("Start entity ID (UUID)"),
109 )
110 .arg(
111 Arg::new("max-depth")
112 .long("max-depth")
113 .help("Maximum traversal depth"),
114 )
115 .arg(
116 Arg::new("rel-types")
117 .long("rel-types")
118 .help("Comma-separated relation type filter"),
119 )
120 .arg(
121 Arg::new("limit")
122 .long("limit")
123 .help("Maximum returned rows"),
124 ),
125 )
126 .subcommand(
127 Command::new("by-participant")
128 .about("List edges by participant entity")
129 .arg(
130 Arg::new("entity-id")
131 .required(true)
132 .help("Entity ID to find edges for"),
133 ),
134 )
135}
136
137pub async fn dispatch(
138 matches: &ArgMatches,
139 client: &ApiClient,
140 output: &OutputConfig,
141 _session: &crate::session::CliSession,
142) -> Result<()> {
143 match matches.subcommand() {
144 Some(("create", sub)) => {
145 let edge_type = sub
146 .get_one::<String>("edge-type")
147 .unwrap()
148 .parse()
149 .map_err(|e: String| anyhow!(e))?;
150 let source_type = sub
151 .get_one::<String>("source-type")
152 .unwrap()
153 .parse()
154 .map_err(|e: String| anyhow!(e))?;
155 let source_id: uuid::Uuid = sub.get_one::<String>("source-id").unwrap().parse()?;
156 let target_type = sub
157 .get_one::<String>("target-type")
158 .unwrap()
159 .parse()
160 .map_err(|e: String| anyhow!(e))?;
161 let target_id: uuid::Uuid = sub.get_one::<String>("target-id").unwrap().parse()?;
162 let extraction_method = sub
163 .get_one::<String>("extraction-method")
164 .unwrap()
165 .parse()
166 .map_err(|e: String| anyhow!(e))?;
167 let source_turn: i32 = sub.get_one::<String>("source-turn").unwrap().parse()?;
168 let confidence: Option<f32> = sub
169 .get_one::<String>("confidence")
170 .map(|s| s.parse())
171 .transpose()?;
172 let weight: Option<f32> = sub
173 .get_one::<String>("weight")
174 .map(|s| s.parse())
175 .transpose()?;
176
177 let req = CreateEdgeRequest {
178 edge_type,
179 participants: vec![
180 EdgeParticipantRequest {
181 entity_type: source_type,
182 entity_id: source_id,
183 role: Some("source".to_string()),
184 },
185 EdgeParticipantRequest {
186 entity_type: target_type,
187 entity_id: target_id,
188 role: Some("target".to_string()),
189 },
190 ],
191 weight,
192 trajectory_id: sub
193 .get_one::<String>("trajectory-id")
194 .map(|s| s.parse())
195 .transpose()?,
196 provenance: ProvenanceRequest {
197 source_turn,
198 extraction_method,
199 confidence,
200 },
201 metadata: None,
202 };
203 let resp: EdgeResponse = client.post(BASE, &req).await?;
204 output.print(&resp);
205 }
206 Some(("batch", sub)) => {
207 let json_str = sub.get_one::<String>("json").unwrap();
208 let requests: Vec<CreateEdgeRequest> = serde_json::from_str(json_str)?;
209 let resp: ListEdgesResponse = client.post(&format!("{BASE}/batch"), &requests).await?;
210 let total = resp.edges.len() as i64;
211 output.print_list(&resp.edges, total);
212 }
213 Some(("get", sub)) => {
214 let id = sub.get_one::<String>("id").unwrap();
215 let resp: EdgeResponse = client.get(&format!("{BASE}/{id}")).await?;
216 output.print(&resp);
217 }
218 Some(("delete", sub)) => {
219 let id = sub.get_one::<String>("id").unwrap();
220 client.delete(&format!("{BASE}/{id}")).await?;
221 if output.json {
222 println!(r#"{{"deleted": "{id}"}}"#);
223 } else {
224 println!("Deleted edge {id}");
225 }
226 }
227 Some(("traverse", sub)) => {
228 let start_id = sub.get_one::<String>("start-id").unwrap();
229 let mut path = format!("{BASE}/traverse?start_id={start_id}");
230 if let Some(max_depth) = sub.get_one::<String>("max-depth") {
231 path.push_str(&format!("&max_depth={max_depth}"));
232 }
233 if let Some(rel_types) = sub.get_one::<String>("rel-types") {
234 for rt in rel_types.split(',') {
236 path.push_str(&format!("&rel_types={}", rt.trim()));
237 }
238 }
239 if let Some(limit) = sub.get_one::<String>("limit") {
240 path.push_str(&format!("&limit={limit}"));
241 }
242 let resp: TraverseGraphResponse = client.get(&path).await?;
243 let total = resp.links.len() as i64;
244 output.print_list(&resp.links, total);
245 }
246 Some(("by-participant", sub)) => {
247 let entity_id = sub.get_one::<String>("entity-id").unwrap();
248 let resp: ListEdgesResponse = client
249 .get(&format!("{BASE}/by-participant/{entity_id}"))
250 .await?;
251 let total = resp.edges.len() as i64;
252 output.print_list(&resp.edges, total);
253 }
254 _ => unreachable!("subcommand_required(true) prevents this"),
255 }
256 Ok(())
257}