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