1use super::args::*;
4use super::formats::{
5 render_dot, render_json, render_mermaid, render_ndjson_streaming, EdgeOut, GraphSnapshot,
6 NodeOut,
7};
8use crate::cli::GraphExportFormat;
9use crate::errors::AppError;
10use crate::graph::{GraphWalk, InMemoryNeighbors, MemoryEdge, WalkDirection};
11use crate::output;
12use crate::paths::AppPaths;
13use crate::storage::connection::open_ro;
14use crate::storage::entities;
15use serde::Serialize;
16use std::collections::HashMap;
17use std::fs;
18use std::time::Instant;
19
20pub fn run(args: GraphArgs) -> Result<(), AppError> {
22 match args.subcommand {
23 None => run_entities_snapshot(
24 args.db.as_deref(),
25 args.namespace.as_deref(),
26 args.format,
27 args.json,
28 args.output.as_deref(),
29 ),
30 Some(GraphSubcommand::Traverse(mut a)) => {
31 if a.db.is_none() {
32 a.db = args.db;
33 }
34 if a.namespace.is_none() {
35 a.namespace = args.namespace;
36 }
37 run_traverse(a)
38 }
39 Some(GraphSubcommand::Stats(mut a)) => {
40 if a.db.is_none() {
41 a.db = args.db;
42 }
43 if a.namespace.is_none() {
44 a.namespace = args.namespace;
45 }
46 run_stats(a)
47 }
48 Some(GraphSubcommand::Entities(mut a)) => {
49 if a.db.is_none() {
50 a.db = args.db;
51 }
52 if a.namespace.is_none() {
53 a.namespace = args.namespace;
54 }
55 run_entities(a)
56 }
57 Some(GraphSubcommand::EntityTypes(mut a)) => {
58 if a.db.is_none() {
59 a.db = args.db;
60 }
61 if a.namespace.is_none() {
62 a.namespace = args.namespace;
63 }
64 run_entity_types(a)
65 }
66 Some(GraphSubcommand::RecomputeDegree(mut a)) => {
67 if a.db.is_none() {
68 a.db = args.db;
69 }
70 if a.namespace.is_none() {
71 a.namespace = args.namespace;
72 }
73 run_recompute_degree(a)
74 }
75 }
76}
77
78#[derive(Debug, Serialize, PartialEq, Eq)]
84pub(crate) struct RecomputeDegreeSummary {
85 pub(crate) total: i64,
86 pub(crate) updated: i64,
87 pub(crate) zeroed: i64,
88 pub(crate) unchanged: i64,
89}
90
91#[derive(Serialize)]
92struct RecomputeDegreeResponse {
93 namespace: Option<String>,
94 dry_run: bool,
95 total: i64,
96 updated: i64,
97 zeroed: i64,
98 unchanged: i64,
99 elapsed_ms: u64,
100}
101
102pub(crate) fn recompute_degrees(
111 conn: &mut rusqlite::Connection,
112 namespace: Option<&str>,
113 dry_run: bool,
114) -> Result<RecomputeDegreeSummary, AppError> {
115 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
116
117 const SELECT_BASE: &str = "SELECT e.id, e.degree, \
118 (SELECT COUNT(*) FROM relationships r \
119 WHERE r.source_id = e.id OR r.target_id = e.id) \
120 FROM entities e";
121 let rows: Vec<(i64, i64, i64)> = if let Some(ns) = namespace {
122 let mut stmt = tx.prepare(&format!("{SELECT_BASE} WHERE e.namespace = ?1"))?;
123 let r = stmt
124 .query_map(rusqlite::params![ns], |r| {
125 Ok((r.get(0)?, r.get(1)?, r.get(2)?))
126 })?
127 .collect::<Result<Vec<_>, _>>()?;
128 r
129 } else {
130 let mut stmt = tx.prepare(SELECT_BASE)?;
131 let r = stmt
132 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
133 .collect::<Result<Vec<_>, _>>()?;
134 r
135 };
136
137 let mut summary = RecomputeDegreeSummary {
138 total: rows.len() as i64,
139 updated: 0,
140 zeroed: 0,
141 unchanged: 0,
142 };
143 for (id, stored, real) in rows {
144 if stored == real {
145 summary.unchanged += 1;
146 continue;
147 }
148 if !dry_run {
149 tx.execute(
150 "UPDATE entities SET degree = ?1, updated_at = unixepoch() WHERE id = ?2",
151 rusqlite::params![real, id],
152 )?;
153 }
154 if real == 0 {
155 summary.zeroed += 1;
156 } else {
157 summary.updated += 1;
158 }
159 }
160
161 if dry_run {
162 drop(tx);
164 } else {
165 tx.commit()?;
166 }
167 Ok(summary)
168}
169
170pub(crate) fn run_recompute_degree(args: GraphRecomputeDegreeArgs) -> Result<(), AppError> {
171 let started = Instant::now();
172 let paths = AppPaths::resolve(args.db.as_deref())?;
173 crate::storage::connection::ensure_db_ready(&paths)?;
174 let mut conn = crate::storage::connection::open_rw(&paths.db)?;
175
176 let summary = recompute_degrees(&mut conn, args.namespace.as_deref(), args.dry_run)?;
177
178 output::emit_json(&RecomputeDegreeResponse {
179 namespace: args.namespace,
180 dry_run: args.dry_run,
181 total: summary.total,
182 updated: summary.updated,
183 zeroed: summary.zeroed,
184 unchanged: summary.unchanged,
185 elapsed_ms: started.elapsed().as_millis() as u64,
186 })?;
187 Ok(())
188}
189
190pub(crate) fn run_entities_snapshot(
191 db: Option<&str>,
192 namespace: Option<&str>,
193 format: GraphExportFormat,
194 json: bool,
195 output_path: Option<&std::path::Path>,
196) -> Result<(), AppError> {
197 let started = Instant::now();
198 let paths = AppPaths::resolve(db)?;
199
200 crate::storage::connection::ensure_db_ready(&paths)?;
201
202 let conn = open_ro(&paths.db)?;
203
204 let nodes_raw = entities::list_entities(&conn, namespace)?;
205 let edges_raw = entities::list_relationships_by_namespace(&conn, namespace)?;
206
207 let id_to_name: HashMap<i64, String> =
208 nodes_raw.iter().map(|n| (n.id, n.name.clone())).collect();
209
210 let nodes: Vec<NodeOut> = nodes_raw
211 .into_iter()
212 .map(|n| NodeOut {
213 id: n.id,
214 name: n.name,
215 namespace: n.namespace,
216 r#type: n.kind.clone(),
217 kind: n.kind,
218 description: n.description,
219 })
220 .collect();
221
222 let mut edges: Vec<EdgeOut> = Vec::with_capacity(edges_raw.len());
223 let mut orphan_edges: usize = 0;
224 for r in edges_raw {
225 let from = match id_to_name.get(&r.source_id) {
226 Some(n) => n.clone(),
227 None => {
228 orphan_edges += 1;
229 tracing::warn!(target: "graph_export", source_id = r.source_id, relation = %r.relation, "edge skipped: source entity not found in id_to_name map");
230 continue;
231 }
232 };
233 let to = match id_to_name.get(&r.target_id) {
234 Some(n) => n.clone(),
235 None => {
236 orphan_edges += 1;
237 tracing::warn!(target: "graph_export", target_id = r.target_id, relation = %r.relation, "edge skipped: target entity not found in id_to_name map");
238 continue;
239 }
240 };
241 edges.push(EdgeOut {
242 from,
243 to,
244 relation: r.relation,
245 weight: r.weight,
246 });
247 }
248 if orphan_edges > 0 {
249 tracing::warn!(target: "graph_export",
250 count = orphan_edges,
251 "edges skipped due to orphaned entity references"
252 );
253 }
254
255 let effective_format = if json {
256 GraphExportFormat::Json
257 } else {
258 format
259 };
260
261 if effective_format == GraphExportFormat::Ndjson {
262 let elapsed_ms = started.elapsed().as_millis() as u64;
263 render_ndjson_streaming(&nodes, &edges, elapsed_ms, output_path)?;
264 return Ok(());
265 }
266
267 if effective_format == GraphExportFormat::Json {
279 let entities = nodes.clone();
280 let snapshot = GraphSnapshot {
281 nodes,
282 entities,
283 edges,
284 elapsed_ms: started.elapsed().as_millis() as u64,
285 };
286 if let Some(path) = output_path.filter(|_| !json) {
287 fs::write(path, render_json(&snapshot)?)?;
288 output::emit_progress(&format!("wrote {}", path.display()));
289 } else {
290 output::emit_json(&snapshot)?;
291 }
292 return Ok(());
293 }
294
295 let rendered = match effective_format {
296 GraphExportFormat::Dot => render_dot(&nodes, &edges),
297 GraphExportFormat::Mermaid => render_mermaid(&nodes, &edges),
298 GraphExportFormat::Json => unreachable!("json handled above"),
299 GraphExportFormat::Ndjson => unreachable!("ndjson handled above"),
300 };
301
302 if let Some(path) = output_path.filter(|_| !json) {
303 fs::write(path, &rendered)?;
304 output::emit_progress(&format!("wrote {}", path.display()));
305 } else {
306 output::emit_text(&rendered);
307 }
308
309 Ok(())
310}
311
312pub(super) fn traverse_hops(
326 edges: &[MemoryEdge],
327 id_to_name: &HashMap<i64, String>,
328 from_id: i64,
329 depth: u32,
330) -> Result<Vec<TraverseHop>, AppError> {
331 let mut hops: Vec<TraverseHop> = Vec::with_capacity(16);
332 let walk = GraphWalk {
333 direction: WalkDirection::Bidirectional,
334 weight_floor: None,
335 max_hops: depth,
336 max_neighbors_per_hop: None,
337 relation_filter: None,
338 };
339 walk.run_observed(
340 &InMemoryNeighbors::new(edges, id_to_name),
341 &[from_id],
342 |edge, hop_depth| {
343 let (entity, direction) = if edge.inbound {
344 (edge.source_name.clone(), "inbound")
345 } else {
346 (edge.target_name.clone(), "outbound")
347 };
348 hops.push(TraverseHop {
349 entity: entity.unwrap_or_default(),
350 relation: edge.relation.clone(),
351 direction: direction.to_string(),
352 weight: edge.weight,
353 depth: hop_depth,
354 });
355 },
356 )?;
357 Ok(hops)
358}
359
360pub(crate) fn run_traverse(args: GraphTraverseArgs) -> Result<(), AppError> {
361 let started = Instant::now();
362 let _ = args.format;
363 let paths = AppPaths::resolve(args.db.as_deref())?;
364
365 crate::storage::connection::ensure_db_ready(&paths)?;
366
367 let conn = open_ro(&paths.db)?;
368 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
369
370 let (from_id, resolved_name) =
373 match entities::resolve_entity_fuzzy(&conn, &namespace, &args.from, args.fuzzy)? {
374 Some((id, name, was_fuzzy)) => {
375 if was_fuzzy {
376 tracing::warn!(
377 target: "graph_export",
378 query = %args.from,
379 resolved = %name,
380 "traverse: fuzzy-resolved entity name"
381 );
382 }
383 (id, name)
384 }
385 None => {
386 return Err(entities::entity_not_found_with_suggestions(
387 &conn, &namespace, &args.from,
388 ));
389 }
390 };
391
392 let all_rels = entities::list_relationships_by_namespace(&conn, Some(&namespace))?;
393 let all_entities = entities::list_entities(&conn, Some(&namespace))?;
394 let id_to_name: HashMap<i64, String> = all_entities
395 .iter()
396 .map(|e| (e.id, e.name.clone()))
397 .collect();
398
399 let edges: Vec<MemoryEdge> = all_rels
400 .iter()
401 .map(|rel| MemoryEdge {
402 source_id: rel.source_id,
403 target_id: rel.target_id,
404 relation: rel.relation.clone(),
405 weight: rel.weight,
406 })
407 .collect();
408
409 let hops = traverse_hops(&edges, &id_to_name, from_id, args.depth)?;
410
411 output::emit_json(&GraphTraverseResponse {
412 from: resolved_name,
413 namespace,
414 depth: args.depth,
415 hops,
416 elapsed_ms: started.elapsed().as_millis() as u64,
417 })?;
418
419 Ok(())
420}
421
422pub(crate) fn measure_max_degree(
435 conn: &rusqlite::Connection,
436 ns: Option<&str>,
437) -> Result<i64, AppError> {
438 let degree = match ns {
439 Some(n) => conn.query_row(
440 "SELECT COALESCE(MAX(deg), 0) FROM ( \
441 SELECT COUNT(r.id) AS deg FROM entities e \
442 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
443 WHERE e.namespace = ?1 \
444 GROUP BY e.id \
445 )",
446 rusqlite::params![n],
447 |r| r.get(0),
448 )?,
449 None => conn.query_row(
450 "SELECT COALESCE(MAX(deg), 0) FROM ( \
451 SELECT COUNT(r.id) AS deg FROM entities e \
452 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
453 GROUP BY e.id \
454 )",
455 [],
456 |r| r.get(0),
457 )?,
458 };
459 Ok(degree)
460}
461
462pub(crate) fn run_stats(args: GraphStatsArgs) -> Result<(), AppError> {
463 let started = Instant::now();
464 let paths = AppPaths::resolve(args.db.as_deref())?;
465
466 crate::storage::connection::ensure_db_ready(&paths)?;
467
468 let conn = open_ro(&paths.db)?;
469 let ns = args.namespace.as_deref();
470
471 let node_count: i64 = if let Some(n) = ns {
472 conn.query_row(
473 "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
474 rusqlite::params![n],
475 |r| r.get(0),
476 )?
477 } else {
478 conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?
479 };
480
481 let edge_count: i64 = if let Some(n) = ns {
482 conn.query_row(
483 "SELECT COUNT(*) FROM relationships r
484 JOIN entities s ON s.id = r.source_id
485 WHERE s.namespace = ?1",
486 rusqlite::params![n],
487 |r| r.get(0),
488 )?
489 } else {
490 conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?
491 };
492
493 let max_degree = measure_max_degree(&conn, ns)?;
494
495 let avg_degree = if node_count > 0 {
497 2.0 * (edge_count as f64) / (node_count as f64)
498 } else {
499 0.0
500 };
501
502 let resp = GraphStatsResponse {
503 namespace: args.namespace,
504 node_count,
505 edge_count,
506 avg_degree,
507 max_degree,
508 elapsed_ms: started.elapsed().as_millis() as u64,
509 };
510
511 let effective_format = if args.json {
512 GraphStatsFormat::Json
513 } else {
514 args.format
515 };
516
517 match effective_format {
518 GraphStatsFormat::Json => output::emit_json(&resp)?,
519 GraphStatsFormat::Text => {
520 output::emit_text(&format!(
521 "nodes={} edges={} avg_degree={:.2} max_degree={} namespace={}",
522 resp.node_count,
523 resp.edge_count,
524 resp.avg_degree,
525 resp.max_degree,
526 resp.namespace.as_deref().unwrap_or("all"),
527 ));
528 }
529 }
530
531 Ok(())
532}
533
534pub(crate) fn build_order_by(sort_by: Option<EntitySortField>, order: SortOrder) -> &'static str {
538 match (sort_by, order) {
542 (None, SortOrder::Asc) | (Some(EntitySortField::Name), SortOrder::Asc) => {
543 "ORDER BY e.name ASC"
544 }
545 (Some(EntitySortField::Name), SortOrder::Desc) => "ORDER BY e.name DESC",
546 (Some(EntitySortField::Degree), SortOrder::Asc) => "ORDER BY degree ASC",
547 (Some(EntitySortField::Degree), SortOrder::Desc) => "ORDER BY degree DESC",
548 (Some(EntitySortField::CreatedAt), SortOrder::Asc) => "ORDER BY e.created_at ASC",
549 (Some(EntitySortField::CreatedAt), SortOrder::Desc) => "ORDER BY e.created_at DESC",
550 (None, SortOrder::Desc) => "ORDER BY e.name DESC",
552 }
553}
554
555pub(crate) fn run_entities(args: GraphEntitiesArgs) -> Result<(), AppError> {
556 let started = Instant::now();
557 let paths = AppPaths::resolve(args.db.as_deref())?;
558
559 crate::storage::connection::ensure_db_ready(&paths)?;
560
561 let conn = open_ro(&paths.db)?;
562
563 let row_to_item = |r: &rusqlite::Row<'_>| -> rusqlite::Result<EntityItem> {
564 let ts: i64 = r.get(4)?;
565 let created_at = chrono::DateTime::from_timestamp(ts, 0)
566 .unwrap_or_default()
567 .format("%Y-%m-%dT%H:%M:%SZ")
568 .to_string();
569 Ok(EntityItem {
570 id: r.get(0)?,
571 name: r.get(1)?,
572 entity_type: r.get(2)?,
573 namespace: r.get(3)?,
574 created_at,
575 degree: r.get(5)?,
576 description: r.get(6)?,
577 })
578 };
579
580 let limit_i = args.limit as i64;
581 let offset_i = args.offset as i64;
582 let order_clause = build_order_by(args.sort_by, args.order);
583
584 let base_select = "SELECT e.id, e.name, COALESCE(e.type, ''), e.namespace, e.created_at,
585 (SELECT COUNT(*) FROM relationships r
586 WHERE r.source_id = e.id OR r.target_id = e.id) AS degree,
587 e.description
588 FROM entities e";
589
590 let (total_count, items) = match (args.namespace.as_deref(), args.entity_type.as_deref()) {
591 (Some(ns), Some(et)) => {
592 let count: i64 = conn.query_row(
593 "SELECT COUNT(*) FROM entities WHERE namespace = ?1 AND type = ?2",
594 rusqlite::params![ns, et],
595 |r| r.get(0),
596 )?;
597 let sql = format!(
598 "{base_select} WHERE e.namespace = ?1 AND e.type = ?2 {order_clause} LIMIT ?3 OFFSET ?4"
599 );
600 let mut stmt = conn.prepare(&sql)?;
601 let rows = stmt
602 .query_map(rusqlite::params![ns, et, limit_i, offset_i], row_to_item)?
603 .collect::<rusqlite::Result<Vec<_>>>()?;
604 (count, rows)
605 }
606 (Some(ns), None) => {
607 let count: i64 = conn.query_row(
608 "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
609 rusqlite::params![ns],
610 |r| r.get(0),
611 )?;
612 let sql =
613 format!("{base_select} WHERE e.namespace = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
614 let mut stmt = conn.prepare(&sql)?;
615 let rows = stmt
616 .query_map(rusqlite::params![ns, limit_i, offset_i], row_to_item)?
617 .collect::<rusqlite::Result<Vec<_>>>()?;
618 (count, rows)
619 }
620 (None, Some(et)) => {
621 let count: i64 = conn.query_row(
622 "SELECT COUNT(*) FROM entities WHERE type = ?1",
623 rusqlite::params![et],
624 |r| r.get(0),
625 )?;
626 let sql = format!("{base_select} WHERE e.type = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
627 let mut stmt = conn.prepare(&sql)?;
628 let rows = stmt
629 .query_map(rusqlite::params![et, limit_i, offset_i], row_to_item)?
630 .collect::<rusqlite::Result<Vec<_>>>()?;
631 (count, rows)
632 }
633 (None, None) => {
634 let count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
635 let sql = format!("{base_select} {order_clause} LIMIT ?1 OFFSET ?2");
636 let mut stmt = conn.prepare(&sql)?;
637 let rows = stmt
638 .query_map(rusqlite::params![limit_i, offset_i], row_to_item)?
639 .collect::<rusqlite::Result<Vec<_>>>()?;
640 (count, rows)
641 }
642 };
643
644 crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
655 applied: args.limit,
656 offset: args.offset,
657 source: if args.limit == crate::constants::K_GRAPH_ENTITIES_DEFAULT_LIMIT {
658 crate::agent_surface::universe::CeilingSource::Default
659 } else {
660 crate::agent_surface::universe::CeilingSource::Flag
661 },
662 kind: crate::agent_surface::universe::CeilingKind::Pagination,
663 universe_total: usize::try_from(total_count).ok(),
664 });
665
666 output::emit_json(&GraphEntitiesResponse {
667 entities: items,
668 total_count,
669 limit: args.limit,
670 offset: args.offset,
671 namespace: args.namespace,
672 elapsed_ms: started.elapsed().as_millis() as u64,
673 })
674}
675
676pub(crate) fn run_entity_types(args: GraphEntityTypesArgs) -> Result<(), AppError> {
684 let started = Instant::now();
685 let paths = AppPaths::resolve(args.db.as_deref())?;
686
687 crate::storage::connection::ensure_db_ready(&paths)?;
688
689 let conn = open_ro(&paths.db)?;
690
691 let mut stmt = conn.prepare(
694 "SELECT COALESCE(type, ''), COUNT(*) AS count
695 FROM entities
696 WHERE (?1 IS NULL OR namespace = ?1)
697 GROUP BY type
698 ORDER BY count DESC, type ASC",
699 )?;
700 let types = stmt
701 .query_map(rusqlite::params![args.namespace.as_deref()], |r| {
702 let entity_type: String = r.get(0)?;
703 let count: i64 = r.get(1)?;
704 Ok(EntityTypeCount {
705 canonical: crate::entity_type::is_canonical_entity_type(&entity_type),
706 entity_type,
707 count,
708 })
709 })?
710 .collect::<rusqlite::Result<Vec<_>>>()?;
711
712 let total_types = types.len();
713 let total_entities = types.iter().map(|t| t.count).sum();
714
715 let response = GraphEntityTypesResponse {
716 types,
717 total_types,
718 total_entities,
719 namespace: args.namespace,
720 elapsed_ms: started.elapsed().as_millis() as u64,
721 };
722
723 match args.format {
724 GraphEntityTypesFormat::Json => output::emit_json(&response),
725 GraphEntityTypesFormat::Text => {
726 let lines: Vec<String> = response
727 .types
728 .iter()
729 .map(|t| {
730 let mark = if t.canonical { "canonical" } else { "custom" };
731 format!("{:>8} {} [{}]", t.count, t.entity_type, mark)
732 })
733 .collect();
734 output::emit_text(&format!(
735 "{}\n{} types, {} entities",
736 lines.join("\n"),
737 response.total_types,
738 response.total_entities
739 ));
740 Ok(())
741 }
742 }
743}