cellstate/entity/
edge.rs

1//! `cellstate edge` — Manage edges (graph relationships between entities).
2//!
3//! Subcommands: create, batch, get, delete, traverse, by-participant.
4
5use 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                // Pass each rel_type as a separate query param for Vec deserialization
235                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}