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::output;
11use crate::paths::AppPaths;
12use crate::storage::connection::open_ro;
13use crate::storage::entities;
14use serde::Serialize;
15use std::collections::HashMap;
16use std::fs;
17use std::time::Instant;
18
19pub fn run(args: GraphArgs) -> Result<(), AppError> {
21 match args.subcommand {
22 None => run_entities_snapshot(
23 args.db.as_deref(),
24 args.namespace.as_deref(),
25 args.format,
26 args.json,
27 args.output.as_deref(),
28 ),
29 Some(GraphSubcommand::Traverse(mut a)) => {
30 if a.db.is_none() {
31 a.db = args.db;
32 }
33 if a.namespace.is_none() {
34 a.namespace = args.namespace;
35 }
36 run_traverse(a)
37 }
38 Some(GraphSubcommand::Stats(mut a)) => {
39 if a.db.is_none() {
40 a.db = args.db;
41 }
42 if a.namespace.is_none() {
43 a.namespace = args.namespace;
44 }
45 run_stats(a)
46 }
47 Some(GraphSubcommand::Entities(mut a)) => {
48 if a.db.is_none() {
49 a.db = args.db;
50 }
51 if a.namespace.is_none() {
52 a.namespace = args.namespace;
53 }
54 run_entities(a)
55 }
56 Some(GraphSubcommand::RecomputeDegree(mut a)) => {
57 if a.db.is_none() {
58 a.db = args.db;
59 }
60 if a.namespace.is_none() {
61 a.namespace = args.namespace;
62 }
63 run_recompute_degree(a)
64 }
65 }
66}
67
68#[derive(Debug, Serialize, PartialEq, Eq)]
74pub(crate) struct RecomputeDegreeSummary {
75 pub(crate) total: i64,
76 pub(crate) updated: i64,
77 pub(crate) zeroed: i64,
78 pub(crate) unchanged: i64,
79}
80
81#[derive(Serialize)]
82struct RecomputeDegreeResponse {
83 namespace: Option<String>,
84 dry_run: bool,
85 total: i64,
86 updated: i64,
87 zeroed: i64,
88 unchanged: i64,
89 elapsed_ms: u64,
90}
91
92pub(crate) fn recompute_degrees(
101 conn: &mut rusqlite::Connection,
102 namespace: Option<&str>,
103 dry_run: bool,
104) -> Result<RecomputeDegreeSummary, AppError> {
105 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
106
107 const SELECT_BASE: &str = "SELECT e.id, e.degree, \
108 (SELECT COUNT(*) FROM relationships r \
109 WHERE r.source_id = e.id OR r.target_id = e.id) \
110 FROM entities e";
111 let rows: Vec<(i64, i64, i64)> = if let Some(ns) = namespace {
112 let mut stmt = tx.prepare(&format!("{SELECT_BASE} WHERE e.namespace = ?1"))?;
113 let r = stmt
114 .query_map(rusqlite::params![ns], |r| {
115 Ok((r.get(0)?, r.get(1)?, r.get(2)?))
116 })?
117 .collect::<Result<Vec<_>, _>>()?;
118 r
119 } else {
120 let mut stmt = tx.prepare(SELECT_BASE)?;
121 let r = stmt
122 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
123 .collect::<Result<Vec<_>, _>>()?;
124 r
125 };
126
127 let mut summary = RecomputeDegreeSummary {
128 total: rows.len() as i64,
129 updated: 0,
130 zeroed: 0,
131 unchanged: 0,
132 };
133 for (id, stored, real) in rows {
134 if stored == real {
135 summary.unchanged += 1;
136 continue;
137 }
138 if !dry_run {
139 tx.execute(
140 "UPDATE entities SET degree = ?1, updated_at = unixepoch() WHERE id = ?2",
141 rusqlite::params![real, id],
142 )?;
143 }
144 if real == 0 {
145 summary.zeroed += 1;
146 } else {
147 summary.updated += 1;
148 }
149 }
150
151 if dry_run {
152 drop(tx);
154 } else {
155 tx.commit()?;
156 }
157 Ok(summary)
158}
159
160pub(crate) fn run_recompute_degree(args: GraphRecomputeDegreeArgs) -> Result<(), AppError> {
161 let inicio = Instant::now();
162 let paths = AppPaths::resolve(args.db.as_deref())?;
163 crate::storage::connection::ensure_db_ready(&paths)?;
164 let mut conn = crate::storage::connection::open_rw(&paths.db)?;
165
166 let summary = recompute_degrees(&mut conn, args.namespace.as_deref(), args.dry_run)?;
167
168 output::emit_json(&RecomputeDegreeResponse {
169 namespace: args.namespace,
170 dry_run: args.dry_run,
171 total: summary.total,
172 updated: summary.updated,
173 zeroed: summary.zeroed,
174 unchanged: summary.unchanged,
175 elapsed_ms: inicio.elapsed().as_millis() as u64,
176 })?;
177 Ok(())
178}
179
180pub(crate) fn run_entities_snapshot(
181 db: Option<&str>,
182 namespace: Option<&str>,
183 format: GraphExportFormat,
184 json: bool,
185 output_path: Option<&std::path::Path>,
186) -> Result<(), AppError> {
187 let inicio = Instant::now();
188 let paths = AppPaths::resolve(db)?;
189
190 crate::storage::connection::ensure_db_ready(&paths)?;
191
192 let conn = open_ro(&paths.db)?;
193
194 let nodes_raw = entities::list_entities(&conn, namespace)?;
195 let edges_raw = entities::list_relationships_by_namespace(&conn, namespace)?;
196
197 let id_to_name: HashMap<i64, String> =
198 nodes_raw.iter().map(|n| (n.id, n.name.clone())).collect();
199
200 let nodes: Vec<NodeOut> = nodes_raw
201 .into_iter()
202 .map(|n| NodeOut {
203 id: n.id,
204 name: n.name,
205 namespace: n.namespace,
206 r#type: n.kind.clone(),
207 kind: n.kind,
208 })
209 .collect();
210
211 let mut edges: Vec<EdgeOut> = Vec::with_capacity(edges_raw.len());
212 let mut orphan_edges: usize = 0;
213 for r in edges_raw {
214 let from = match id_to_name.get(&r.source_id) {
215 Some(n) => n.clone(),
216 None => {
217 orphan_edges += 1;
218 tracing::warn!(target: "graph_export", source_id = r.source_id, relation = %r.relation, "edge skipped: source entity not found in id_to_name map");
219 continue;
220 }
221 };
222 let to = match id_to_name.get(&r.target_id) {
223 Some(n) => n.clone(),
224 None => {
225 orphan_edges += 1;
226 tracing::warn!(target: "graph_export", target_id = r.target_id, relation = %r.relation, "edge skipped: target entity not found in id_to_name map");
227 continue;
228 }
229 };
230 edges.push(EdgeOut {
231 from,
232 to,
233 relation: r.relation,
234 weight: r.weight,
235 });
236 }
237 if orphan_edges > 0 {
238 tracing::warn!(target: "graph_export",
239 count = orphan_edges,
240 "edges skipped due to orphaned entity references"
241 );
242 }
243
244 let effective_format = if json {
245 GraphExportFormat::Json
246 } else {
247 format
248 };
249
250 if effective_format == GraphExportFormat::Ndjson {
251 let elapsed_ms = inicio.elapsed().as_millis() as u64;
252 render_ndjson_streaming(&nodes, &edges, elapsed_ms, output_path)?;
253 return Ok(());
254 }
255
256 let rendered = match effective_format {
257 GraphExportFormat::Json => {
258 let entities = nodes.clone();
259 render_json(&GraphSnapshot {
260 nodes,
261 entities,
262 edges,
263 elapsed_ms: inicio.elapsed().as_millis() as u64,
264 })?
265 }
266 GraphExportFormat::Dot => render_dot(&nodes, &edges),
267 GraphExportFormat::Mermaid => render_mermaid(&nodes, &edges),
268 GraphExportFormat::Ndjson => unreachable!("ndjson handled above"),
269 };
270
271 if let Some(path) = output_path.filter(|_| !json) {
272 fs::write(path, &rendered)?;
273 output::emit_progress(&format!("wrote {}", path.display()));
274 } else {
275 output::emit_text(&rendered);
276 }
277
278 Ok(())
279}
280
281pub(crate) fn run_traverse(args: GraphTraverseArgs) -> Result<(), AppError> {
282 let inicio = Instant::now();
283 let _ = args.format;
284 let paths = AppPaths::resolve(args.db.as_deref())?;
285
286 crate::storage::connection::ensure_db_ready(&paths)?;
287
288 let conn = open_ro(&paths.db)?;
289 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
290
291 let (from_id, resolved_name) =
294 match entities::resolve_entity_fuzzy(&conn, &namespace, &args.from, args.fuzzy)? {
295 Some((id, name, was_fuzzy)) => {
296 if was_fuzzy {
297 tracing::warn!(
298 target: "graph_export",
299 query = %args.from,
300 resolved = %name,
301 "traverse: fuzzy-resolved entity name"
302 );
303 }
304 (id, name)
305 }
306 None => {
307 return Err(entities::entity_not_found_with_suggestions(
308 &conn, &namespace, &args.from,
309 ));
310 }
311 };
312
313 let all_rels = entities::list_relationships_by_namespace(&conn, Some(&namespace))?;
314 let all_entities = entities::list_entities(&conn, Some(&namespace))?;
315 let id_to_name: HashMap<i64, String> = all_entities
316 .iter()
317 .map(|e| (e.id, e.name.clone()))
318 .collect();
319
320 let mut hops: Vec<TraverseHop> = Vec::with_capacity(16);
321 let mut visited: std::collections::HashSet<i64> =
322 std::collections::HashSet::with_capacity(args.depth as usize * 10);
323 let mut frontier: Vec<(i64, u32)> = vec![(from_id, 0)];
324
325 while let Some((current_id, current_depth)) = frontier.pop() {
326 if current_depth >= args.depth || visited.contains(¤t_id) {
327 continue;
328 }
329 visited.insert(current_id);
330
331 for rel in &all_rels {
332 if rel.source_id == current_id {
333 if let Some(target_name) = id_to_name.get(&rel.target_id) {
334 hops.push(TraverseHop {
335 entity: target_name.clone(),
336 relation: rel.relation.clone(),
337 direction: "outbound".to_string(),
338 weight: rel.weight,
339 depth: current_depth + 1,
340 });
341 frontier.push((rel.target_id, current_depth + 1));
342 }
343 } else if rel.target_id == current_id {
344 if let Some(source_name) = id_to_name.get(&rel.source_id) {
345 hops.push(TraverseHop {
346 entity: source_name.clone(),
347 relation: rel.relation.clone(),
348 direction: "inbound".to_string(),
349 weight: rel.weight,
350 depth: current_depth + 1,
351 });
352 frontier.push((rel.source_id, current_depth + 1));
353 }
354 }
355 }
356 }
357
358 output::emit_json(&GraphTraverseResponse {
359 from: resolved_name,
360 namespace,
361 depth: args.depth,
362 hops,
363 elapsed_ms: inicio.elapsed().as_millis() as u64,
364 })?;
365
366 Ok(())
367}
368
369pub(crate) fn run_stats(args: GraphStatsArgs) -> Result<(), AppError> {
370 let inicio = Instant::now();
371 let paths = AppPaths::resolve(args.db.as_deref())?;
372
373 crate::storage::connection::ensure_db_ready(&paths)?;
374
375 let conn = open_ro(&paths.db)?;
376 let ns = args.namespace.as_deref();
377
378 let node_count: i64 = if let Some(n) = ns {
379 conn.query_row(
380 "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
381 rusqlite::params![n],
382 |r| r.get(0),
383 )?
384 } else {
385 conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?
386 };
387
388 let edge_count: i64 = if let Some(n) = ns {
389 conn.query_row(
390 "SELECT COUNT(*) FROM relationships r
391 JOIN entities s ON s.id = r.source_id
392 WHERE s.namespace = ?1",
393 rusqlite::params![n],
394 |r| r.get(0),
395 )?
396 } else {
397 conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?
398 };
399
400 let max_degree: i64 = if let Some(n) = ns {
401 conn.query_row(
402 "SELECT COALESCE(MAX(degree), 0) FROM entities WHERE namespace = ?1",
403 rusqlite::params![n],
404 |r| r.get(0),
405 )?
406 } else {
407 conn.query_row("SELECT COALESCE(MAX(degree), 0) FROM entities", [], |r| {
408 r.get(0)
409 })?
410 };
411
412 let avg_degree = if node_count > 0 {
414 2.0 * (edge_count as f64) / (node_count as f64)
415 } else {
416 0.0
417 };
418
419 let resp = GraphStatsResponse {
420 namespace: args.namespace,
421 node_count,
422 edge_count,
423 avg_degree,
424 max_degree,
425 elapsed_ms: inicio.elapsed().as_millis() as u64,
426 };
427
428 let effective_format = if args.json {
429 GraphStatsFormat::Json
430 } else {
431 args.format
432 };
433
434 match effective_format {
435 GraphStatsFormat::Json => output::emit_json(&resp)?,
436 GraphStatsFormat::Text => {
437 output::emit_text(&format!(
438 "nodes={} edges={} avg_degree={:.2} max_degree={} namespace={}",
439 resp.node_count,
440 resp.edge_count,
441 resp.avg_degree,
442 resp.max_degree,
443 resp.namespace.as_deref().unwrap_or("all"),
444 ));
445 }
446 }
447
448 Ok(())
449}
450
451pub(crate) fn build_order_by(sort_by: Option<EntitySortField>, order: SortOrder) -> &'static str {
455 match (sort_by, order) {
459 (None, SortOrder::Asc) | (Some(EntitySortField::Name), SortOrder::Asc) => {
460 "ORDER BY e.name ASC"
461 }
462 (Some(EntitySortField::Name), SortOrder::Desc) => "ORDER BY e.name DESC",
463 (Some(EntitySortField::Degree), SortOrder::Asc) => "ORDER BY degree ASC",
464 (Some(EntitySortField::Degree), SortOrder::Desc) => "ORDER BY degree DESC",
465 (Some(EntitySortField::CreatedAt), SortOrder::Asc) => "ORDER BY e.created_at ASC",
466 (Some(EntitySortField::CreatedAt), SortOrder::Desc) => "ORDER BY e.created_at DESC",
467 (None, SortOrder::Desc) => "ORDER BY e.name DESC",
469 }
470}
471
472pub(crate) fn run_entities(args: GraphEntitiesArgs) -> Result<(), AppError> {
473 let inicio = Instant::now();
474 let paths = AppPaths::resolve(args.db.as_deref())?;
475
476 crate::storage::connection::ensure_db_ready(&paths)?;
477
478 let conn = open_ro(&paths.db)?;
479
480 let row_to_item = |r: &rusqlite::Row<'_>| -> rusqlite::Result<EntityItem> {
481 let ts: i64 = r.get(4)?;
482 let created_at = chrono::DateTime::from_timestamp(ts, 0)
483 .unwrap_or_default()
484 .format("%Y-%m-%dT%H:%M:%SZ")
485 .to_string();
486 Ok(EntityItem {
487 id: r.get(0)?,
488 name: r.get(1)?,
489 entity_type: r.get(2)?,
490 namespace: r.get(3)?,
491 created_at,
492 degree: r.get(5)?,
493 description: r.get(6)?,
494 })
495 };
496
497 let limit_i = args.limit as i64;
498 let offset_i = args.offset as i64;
499 let order_clause = build_order_by(args.sort_by, args.order);
500
501 let base_select = "SELECT e.id, e.name, COALESCE(e.type, ''), e.namespace, e.created_at,
502 (SELECT COUNT(*) FROM relationships r
503 WHERE r.source_id = e.id OR r.target_id = e.id) AS degree,
504 e.description
505 FROM entities e";
506
507 let (total_count, items) = match (
508 args.namespace.as_deref(),
509 args.entity_type.map(|et| et.as_str()),
510 ) {
511 (Some(ns), Some(et)) => {
512 let count: i64 = conn.query_row(
513 "SELECT COUNT(*) FROM entities WHERE namespace = ?1 AND type = ?2",
514 rusqlite::params![ns, et],
515 |r| r.get(0),
516 )?;
517 let sql = format!(
518 "{base_select} WHERE e.namespace = ?1 AND e.type = ?2 {order_clause} LIMIT ?3 OFFSET ?4"
519 );
520 let mut stmt = conn.prepare(&sql)?;
521 let rows = stmt
522 .query_map(rusqlite::params![ns, et, limit_i, offset_i], row_to_item)?
523 .collect::<rusqlite::Result<Vec<_>>>()?;
524 (count, rows)
525 }
526 (Some(ns), None) => {
527 let count: i64 = conn.query_row(
528 "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
529 rusqlite::params![ns],
530 |r| r.get(0),
531 )?;
532 let sql =
533 format!("{base_select} WHERE e.namespace = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
534 let mut stmt = conn.prepare(&sql)?;
535 let rows = stmt
536 .query_map(rusqlite::params![ns, limit_i, offset_i], row_to_item)?
537 .collect::<rusqlite::Result<Vec<_>>>()?;
538 (count, rows)
539 }
540 (None, Some(et)) => {
541 let count: i64 = conn.query_row(
542 "SELECT COUNT(*) FROM entities WHERE type = ?1",
543 rusqlite::params![et],
544 |r| r.get(0),
545 )?;
546 let sql = format!("{base_select} WHERE e.type = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
547 let mut stmt = conn.prepare(&sql)?;
548 let rows = stmt
549 .query_map(rusqlite::params![et, limit_i, offset_i], row_to_item)?
550 .collect::<rusqlite::Result<Vec<_>>>()?;
551 (count, rows)
552 }
553 (None, None) => {
554 let count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
555 let sql = format!("{base_select} {order_clause} LIMIT ?1 OFFSET ?2");
556 let mut stmt = conn.prepare(&sql)?;
557 let rows = stmt
558 .query_map(rusqlite::params![limit_i, offset_i], row_to_item)?
559 .collect::<rusqlite::Result<Vec<_>>>()?;
560 (count, rows)
561 }
562 };
563
564 output::emit_json(&GraphEntitiesResponse {
565 entities: items,
566 total_count,
567 limit: args.limit,
568 offset: args.offset,
569 namespace: args.namespace,
570 elapsed_ms: inicio.elapsed().as_millis() as u64,
571 })
572}