1use crate::cli::GraphExportFormat;
4use crate::entity_type::EntityType;
5use crate::errors::AppError;
6use crate::output;
7use crate::paths::AppPaths;
8use crate::storage::connection::open_ro;
9use crate::storage::entities;
10use serde::Serialize;
11use std::collections::HashMap;
12use std::fs;
13use std::path::PathBuf;
14use std::time::Instant;
15
16#[derive(clap::Subcommand)]
19pub enum GraphSubcommand {
20 Traverse(GraphTraverseArgs),
22 Stats(GraphStatsArgs),
24 Entities(GraphEntitiesArgs),
26}
27
28#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
29pub enum GraphTraverseFormat {
30 Json,
31}
32
33#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
34pub enum GraphStatsFormat {
35 Json,
36 Text,
37}
38
39#[derive(clap::Args)]
40#[command(after_long_help = "EXAMPLES:\n \
41 # Export full entity snapshot as JSON (default)\n \
42 sqlite-graphrag graph\n\n \
43 # Traverse relationships from a starting entity\n \
44 sqlite-graphrag graph traverse --from acme-corp --depth 2\n\n \
45 # Show graph statistics as structured JSON\n \
46 sqlite-graphrag graph stats --format json\n\n \
47 # List entities filtered by type\n \
48 sqlite-graphrag graph entities --entity-type person\n\n \
49 # Export full snapshot in DOT format for Graphviz\n \
50 sqlite-graphrag graph --format dot --output graph.dot\n\n \
51NOTES:\n \
52 Without a subcommand, exports the full entity+edge snapshot.\n \
53 Use `traverse`, `stats`, or `entities` for targeted queries.")]
54pub struct GraphArgs {
55 #[command(subcommand)]
57 pub subcommand: Option<GraphSubcommand>,
58 #[arg(long)]
60 pub namespace: Option<String>,
61 #[arg(long, value_enum, default_value = "json")]
63 pub format: GraphExportFormat,
64 #[arg(long)]
66 pub output: Option<PathBuf>,
67 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
68 pub json: bool,
69 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
70 pub db: Option<String>,
71}
72
73#[derive(clap::Args)]
74#[command(after_long_help = "EXAMPLES:\n \
75 # Traverse relationships from an entity with default depth (2)\n \
76 sqlite-graphrag graph traverse --from acme-corp\n\n \
77 # Increase traversal depth to 3 hops\n \
78 sqlite-graphrag graph traverse --from acme-corp --depth 3\n\n \
79 # Traverse within a specific namespace\n \
80 sqlite-graphrag graph traverse --from acme-corp --namespace project-x\n\n \
81NOTES:\n \
82 Output is always JSON. The `hops` array contains each reachable entity\n \
83 with its relation, direction (inbound/outbound), weight, and depth level.")]
84pub struct GraphTraverseArgs {
85 #[arg(long)]
87 pub from: String,
88 #[arg(long, default_value_t = 2u32)]
90 pub depth: u32,
91 #[arg(long)]
92 pub namespace: Option<String>,
93 #[arg(long, value_enum, default_value = "json")]
94 pub format: GraphTraverseFormat,
95 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
96 pub json: bool,
97 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
98 pub db: Option<String>,
99}
100
101#[derive(clap::Args)]
102#[command(after_long_help = "EXAMPLES:\n \
103 # Show stats for all namespaces (human-readable text)\n \
104 sqlite-graphrag graph stats --format text\n\n \
105 # Show stats as structured JSON\n \
106 sqlite-graphrag graph stats --format json\n\n \
107 # Show stats for a specific namespace\n \
108 sqlite-graphrag graph stats --namespace project-x --format text\n\n \
109NOTES:\n \
110 Reports node_count, edge_count, avg_degree, and max_degree.\n \
111 Default format is JSON. Use `--format text` for a compact single-line summary.")]
112pub struct GraphStatsArgs {
113 #[arg(long)]
114 pub namespace: Option<String>,
115 #[arg(long, value_enum, default_value = "json")]
117 pub format: GraphStatsFormat,
118 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
119 pub json: bool,
120 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
121 pub db: Option<String>,
122}
123
124#[derive(clap::Args)]
125#[command(after_long_help = "EXAMPLES:\n \
126 # List all entities (default limit applies)\n \
127 sqlite-graphrag graph entities\n\n \
128 # Filter by entity type\n \
129 sqlite-graphrag graph entities --entity-type person\n\n \
130 # Filter by namespace and type\n \
131 sqlite-graphrag graph entities --namespace project-x --entity-type concept\n\n \
132 # Paginate results (skip first 20, return next 10)\n \
133 sqlite-graphrag graph entities --offset 20 --limit 10\n\n \
134NOTES:\n \
135 Output is always JSON with `entities`, `total_count`, `limit`, and `offset` fields.\n \
136 Entity types are strings extracted by GLiNER NER (e.g. `person`, `organization`, `location`).")]
137pub struct GraphEntitiesArgs {
138 #[arg(long)]
139 pub namespace: Option<String>,
140 #[arg(long, value_enum)]
142 pub entity_type: Option<EntityType>,
143 #[arg(long, default_value_t = crate::constants::K_GRAPH_ENTITIES_DEFAULT_LIMIT)]
145 pub limit: usize,
146 #[arg(long, default_value_t = 0usize)]
148 pub offset: usize,
149 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
150 pub json: bool,
151 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
152 pub db: Option<String>,
153}
154
155#[derive(Serialize)]
156struct NodeOut {
157 id: i64,
158 name: String,
159 namespace: String,
160 kind: String,
163 #[serde(rename = "type")]
166 r#type: String,
167}
168
169#[derive(Serialize)]
170struct EdgeOut {
171 from: String,
172 to: String,
173 relation: String,
174 weight: f64,
175}
176
177#[derive(Serialize)]
178struct GraphSnapshot {
179 nodes: Vec<NodeOut>,
180 edges: Vec<EdgeOut>,
181 elapsed_ms: u64,
182}
183
184#[derive(Serialize)]
185struct TraverseHop {
186 entity: String,
187 relation: String,
188 direction: String,
189 weight: f64,
190 depth: u32,
191}
192
193#[derive(Serialize)]
194struct GraphTraverseResponse {
195 from: String,
196 namespace: String,
197 depth: u32,
198 hops: Vec<TraverseHop>,
199 elapsed_ms: u64,
200}
201
202#[derive(Serialize)]
203struct GraphStatsResponse {
204 namespace: Option<String>,
205 node_count: i64,
206 edge_count: i64,
207 avg_degree: f64,
208 max_degree: i64,
209 elapsed_ms: u64,
210}
211
212#[derive(Serialize)]
213struct EntityItem {
214 id: i64,
215 name: String,
216 entity_type: String,
217 namespace: String,
218 created_at: String,
219}
220
221#[derive(Serialize)]
222struct GraphEntitiesResponse {
223 entities: Vec<EntityItem>,
224 total_count: i64,
225 limit: usize,
226 offset: usize,
227 namespace: Option<String>,
228 elapsed_ms: u64,
229}
230
231pub fn run(args: GraphArgs) -> Result<(), AppError> {
232 match args.subcommand {
233 None => run_entities_snapshot(
234 args.db.as_deref(),
235 args.namespace.as_deref(),
236 args.format,
237 args.json,
238 args.output.as_deref(),
239 ),
240 Some(GraphSubcommand::Traverse(a)) => run_traverse(a),
241 Some(GraphSubcommand::Stats(a)) => run_stats(a),
242 Some(GraphSubcommand::Entities(a)) => run_entities(a),
243 }
244}
245
246fn run_entities_snapshot(
247 db: Option<&str>,
248 namespace: Option<&str>,
249 format: GraphExportFormat,
250 json: bool,
251 output_path: Option<&std::path::Path>,
252) -> Result<(), AppError> {
253 let inicio = Instant::now();
254 let paths = AppPaths::resolve(db)?;
255
256 crate::storage::connection::ensure_db_ready(&paths)?;
257
258 let conn = open_ro(&paths.db)?;
259
260 let nodes_raw = entities::list_entities(&conn, namespace)?;
261 let edges_raw = entities::list_relationships_by_namespace(&conn, namespace)?;
262
263 let id_to_name: HashMap<i64, String> =
264 nodes_raw.iter().map(|n| (n.id, n.name.clone())).collect();
265
266 let nodes: Vec<NodeOut> = nodes_raw
267 .into_iter()
268 .map(|n| NodeOut {
269 id: n.id,
270 name: n.name,
271 namespace: n.namespace,
272 r#type: n.kind.clone(),
273 kind: n.kind,
274 })
275 .collect();
276
277 let mut edges: Vec<EdgeOut> = Vec::with_capacity(edges_raw.len());
278 let mut orphan_edges: usize = 0;
279 for r in edges_raw {
280 let from = match id_to_name.get(&r.source_id) {
281 Some(n) => n.clone(),
282 None => {
283 orphan_edges += 1;
284 tracing::warn!(source_id = r.source_id, relation = %r.relation, "edge skipped: source entity not found in id_to_name map");
285 continue;
286 }
287 };
288 let to = match id_to_name.get(&r.target_id) {
289 Some(n) => n.clone(),
290 None => {
291 orphan_edges += 1;
292 tracing::warn!(target_id = r.target_id, relation = %r.relation, "edge skipped: target entity not found in id_to_name map");
293 continue;
294 }
295 };
296 edges.push(EdgeOut {
297 from,
298 to,
299 relation: r.relation,
300 weight: r.weight,
301 });
302 }
303 if orphan_edges > 0 {
304 tracing::warn!(
305 count = orphan_edges,
306 "edges skipped due to orphaned entity references"
307 );
308 }
309
310 let effective_format = if json {
311 GraphExportFormat::Json
312 } else {
313 format
314 };
315
316 let rendered = match effective_format {
317 GraphExportFormat::Json => render_json(&GraphSnapshot {
318 nodes,
319 edges,
320 elapsed_ms: inicio.elapsed().as_millis() as u64,
321 })?,
322 GraphExportFormat::Dot => render_dot(&nodes, &edges),
323 GraphExportFormat::Mermaid => render_mermaid(&nodes, &edges),
324 };
325
326 if let Some(path) = output_path.filter(|_| !json) {
327 fs::write(path, &rendered)?;
328 output::emit_progress(&format!("wrote {}", path.display()));
329 } else {
330 output::emit_text(&rendered);
331 }
332
333 Ok(())
334}
335
336fn run_traverse(args: GraphTraverseArgs) -> Result<(), AppError> {
337 let inicio = Instant::now();
338 let _ = args.format;
339 let paths = AppPaths::resolve(args.db.as_deref())?;
340
341 crate::storage::connection::ensure_db_ready(&paths)?;
342
343 let conn = open_ro(&paths.db)?;
344 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
345
346 let from_id = entities::find_entity_id(&conn, &namespace, &args.from)?
347 .ok_or_else(|| AppError::NotFound(format!("entity '{}' not found", args.from)))?;
348
349 let all_rels = entities::list_relationships_by_namespace(&conn, Some(&namespace))?;
350 let all_entities = entities::list_entities(&conn, Some(&namespace))?;
351 let id_to_name: HashMap<i64, String> = all_entities
352 .iter()
353 .map(|e| (e.id, e.name.clone()))
354 .collect();
355
356 let mut hops: Vec<TraverseHop> = Vec::new();
357 let mut visited: std::collections::HashSet<i64> = std::collections::HashSet::new();
358 let mut frontier: Vec<(i64, u32)> = vec![(from_id, 0)];
359
360 while let Some((current_id, current_depth)) = frontier.pop() {
361 if current_depth >= args.depth || visited.contains(¤t_id) {
362 continue;
363 }
364 visited.insert(current_id);
365
366 for rel in &all_rels {
367 if rel.source_id == current_id {
368 if let Some(target_name) = id_to_name.get(&rel.target_id) {
369 hops.push(TraverseHop {
370 entity: target_name.clone(),
371 relation: rel.relation.clone(),
372 direction: "outbound".to_string(),
373 weight: rel.weight,
374 depth: current_depth + 1,
375 });
376 frontier.push((rel.target_id, current_depth + 1));
377 }
378 } else if rel.target_id == current_id {
379 if let Some(source_name) = id_to_name.get(&rel.source_id) {
380 hops.push(TraverseHop {
381 entity: source_name.clone(),
382 relation: rel.relation.clone(),
383 direction: "inbound".to_string(),
384 weight: rel.weight,
385 depth: current_depth + 1,
386 });
387 frontier.push((rel.source_id, current_depth + 1));
388 }
389 }
390 }
391 }
392
393 output::emit_json(&GraphTraverseResponse {
394 from: args.from,
395 namespace,
396 depth: args.depth,
397 hops,
398 elapsed_ms: inicio.elapsed().as_millis() as u64,
399 })?;
400
401 Ok(())
402}
403
404fn run_stats(args: GraphStatsArgs) -> Result<(), AppError> {
405 let inicio = Instant::now();
406 let paths = AppPaths::resolve(args.db.as_deref())?;
407
408 crate::storage::connection::ensure_db_ready(&paths)?;
409
410 let conn = open_ro(&paths.db)?;
411 let ns = args.namespace.as_deref();
412
413 let node_count: i64 = if let Some(n) = ns {
414 conn.query_row(
415 "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
416 rusqlite::params![n],
417 |r| r.get(0),
418 )?
419 } else {
420 conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?
421 };
422
423 let edge_count: i64 = if let Some(n) = ns {
424 conn.query_row(
425 "SELECT COUNT(*) FROM relationships r
426 JOIN entities s ON s.id = r.source_id
427 WHERE s.namespace = ?1",
428 rusqlite::params![n],
429 |r| r.get(0),
430 )?
431 } else {
432 conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?
433 };
434
435 let max_degree: i64 = if let Some(n) = ns {
436 conn.query_row(
437 "SELECT COALESCE(MAX(degree), 0) FROM entities WHERE namespace = ?1",
438 rusqlite::params![n],
439 |r| r.get(0),
440 )?
441 } else {
442 conn.query_row("SELECT COALESCE(MAX(degree), 0) FROM entities", [], |r| {
443 r.get(0)
444 })?
445 };
446
447 let avg_degree = if node_count > 0 {
449 2.0 * (edge_count as f64) / (node_count as f64)
450 } else {
451 0.0
452 };
453
454 let resp = GraphStatsResponse {
455 namespace: args.namespace,
456 node_count,
457 edge_count,
458 avg_degree,
459 max_degree,
460 elapsed_ms: inicio.elapsed().as_millis() as u64,
461 };
462
463 let effective_format = if args.json {
464 GraphStatsFormat::Json
465 } else {
466 args.format
467 };
468
469 match effective_format {
470 GraphStatsFormat::Json => output::emit_json(&resp)?,
471 GraphStatsFormat::Text => {
472 output::emit_text(&format!(
473 "nodes={} edges={} avg_degree={:.2} max_degree={} namespace={}",
474 resp.node_count,
475 resp.edge_count,
476 resp.avg_degree,
477 resp.max_degree,
478 resp.namespace.as_deref().unwrap_or("all"),
479 ));
480 }
481 }
482
483 Ok(())
484}
485
486fn run_entities(args: GraphEntitiesArgs) -> Result<(), AppError> {
487 let inicio = Instant::now();
488 let paths = AppPaths::resolve(args.db.as_deref())?;
489
490 crate::storage::connection::ensure_db_ready(&paths)?;
491
492 let conn = open_ro(&paths.db)?;
493
494 let row_to_item = |r: &rusqlite::Row<'_>| -> rusqlite::Result<EntityItem> {
495 let ts: i64 = r.get(4)?;
496 let created_at = chrono::DateTime::from_timestamp(ts, 0)
497 .unwrap_or_default()
498 .format("%Y-%m-%dT%H:%M:%SZ")
499 .to_string();
500 Ok(EntityItem {
501 id: r.get(0)?,
502 name: r.get(1)?,
503 entity_type: r.get(2)?,
504 namespace: r.get(3)?,
505 created_at,
506 })
507 };
508
509 let limit_i = args.limit as i64;
510 let offset_i = args.offset as i64;
511
512 let (total_count, items) = match (
513 args.namespace.as_deref(),
514 args.entity_type.map(|et| et.as_str()),
515 ) {
516 (Some(ns), Some(et)) => {
517 let count: i64 = conn.query_row(
518 "SELECT COUNT(*) FROM entities WHERE namespace = ?1 AND type = ?2",
519 rusqlite::params![ns, et],
520 |r| r.get(0),
521 )?;
522 let mut stmt = conn.prepare(
523 "SELECT id, name, COALESCE(type, ''), namespace, created_at FROM entities
524 WHERE namespace = ?1 AND type = ?2
525 ORDER BY name ASC LIMIT ?3 OFFSET ?4",
526 )?;
527 let rows = stmt
528 .query_map(rusqlite::params![ns, et, limit_i, offset_i], row_to_item)?
529 .collect::<rusqlite::Result<Vec<_>>>()?;
530 (count, rows)
531 }
532 (Some(ns), None) => {
533 let count: i64 = conn.query_row(
534 "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
535 rusqlite::params![ns],
536 |r| r.get(0),
537 )?;
538 let mut stmt = conn.prepare(
539 "SELECT id, name, COALESCE(type, ''), namespace, created_at FROM entities
540 WHERE namespace = ?1
541 ORDER BY name ASC LIMIT ?2 OFFSET ?3",
542 )?;
543 let rows = stmt
544 .query_map(rusqlite::params![ns, limit_i, offset_i], row_to_item)?
545 .collect::<rusqlite::Result<Vec<_>>>()?;
546 (count, rows)
547 }
548 (None, Some(et)) => {
549 let count: i64 = conn.query_row(
550 "SELECT COUNT(*) FROM entities WHERE type = ?1",
551 rusqlite::params![et],
552 |r| r.get(0),
553 )?;
554 let mut stmt = conn.prepare(
555 "SELECT id, name, COALESCE(type, ''), namespace, created_at FROM entities
556 WHERE type = ?1
557 ORDER BY name ASC LIMIT ?2 OFFSET ?3",
558 )?;
559 let rows = stmt
560 .query_map(rusqlite::params![et, limit_i, offset_i], row_to_item)?
561 .collect::<rusqlite::Result<Vec<_>>>()?;
562 (count, rows)
563 }
564 (None, None) => {
565 let count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
566 let mut stmt = conn.prepare(
567 "SELECT id, name, COALESCE(type, ''), namespace, created_at FROM entities
568 ORDER BY name ASC LIMIT ?1 OFFSET ?2",
569 )?;
570 let rows = stmt
571 .query_map(rusqlite::params![limit_i, offset_i], row_to_item)?
572 .collect::<rusqlite::Result<Vec<_>>>()?;
573 (count, rows)
574 }
575 };
576
577 output::emit_json(&GraphEntitiesResponse {
578 entities: items,
579 total_count,
580 limit: args.limit,
581 offset: args.offset,
582 namespace: args.namespace,
583 elapsed_ms: inicio.elapsed().as_millis() as u64,
584 })
585}
586
587fn render_json(snapshot: &GraphSnapshot) -> Result<String, AppError> {
588 Ok(serde_json::to_string_pretty(snapshot)?)
589}
590
591fn sanitize_dot_id(raw: &str) -> String {
592 raw.chars()
593 .map(|c| {
594 if c.is_ascii_alphanumeric() || c == '_' {
595 c
596 } else {
597 '_'
598 }
599 })
600 .collect()
601}
602
603fn render_dot(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
604 let mut out = String::new();
605 out.push_str("digraph sqlite-graphrag {\n");
606 for node in nodes {
607 let node_id = sanitize_dot_id(&node.name);
608 let escaped = node.name.replace('"', "\\\"");
609 out.push_str(&format!(" {node_id} [label=\"{escaped}\"];\n"));
610 }
611 for edge in edges {
612 let from = sanitize_dot_id(&edge.from);
613 let to = sanitize_dot_id(&edge.to);
614 let label = edge.relation.replace('"', "\\\"");
615 out.push_str(&format!(" {from} -> {to} [label=\"{label}\"];\n"));
616 }
617 out.push_str("}\n");
618 out
619}
620
621fn sanitize_mermaid_id(raw: &str) -> String {
622 raw.chars()
623 .map(|c| {
624 if c.is_ascii_alphanumeric() || c == '_' {
625 c
626 } else {
627 '_'
628 }
629 })
630 .collect()
631}
632
633fn render_mermaid(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
634 let mut out = String::new();
635 out.push_str("graph LR\n");
636 for node in nodes {
637 let id = sanitize_mermaid_id(&node.name);
638 let escaped = node.name.replace('"', "\\\"");
639 out.push_str(&format!(" {id}[\"{escaped}\"]\n"));
640 }
641 for edge in edges {
642 let from = sanitize_mermaid_id(&edge.from);
643 let to = sanitize_mermaid_id(&edge.to);
644 let label = edge.relation.replace('|', "\\|");
645 out.push_str(&format!(" {from} -->|{label}| {to}\n"));
646 }
647 out
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use crate::cli::{Cli, Commands};
654 use clap::Parser;
655
656 fn make_node(kind: &str) -> NodeOut {
657 NodeOut {
658 id: 1,
659 name: "test-entity".to_string(),
660 namespace: "default".to_string(),
661 kind: kind.to_string(),
662 r#type: kind.to_string(),
663 }
664 }
665
666 #[test]
667 fn node_out_type_duplicates_kind() {
668 let node = make_node("agent");
669 let json = serde_json::to_value(&node).expect("serialization must work");
670 assert_eq!(json["kind"], json["type"]);
671 assert_eq!(json["kind"], "agent");
672 assert_eq!(json["type"], "agent");
673 }
674
675 #[test]
676 fn node_out_serializes_all_fields() {
677 let node = make_node("document");
678 let json = serde_json::to_value(&node).expect("serialization must work");
679 assert!(json.get("id").is_some());
680 assert!(json.get("name").is_some());
681 assert!(json.get("namespace").is_some());
682 assert!(json.get("kind").is_some());
683 assert!(json.get("type").is_some());
684 }
685
686 #[test]
687 fn graph_snapshot_serializes_nodes_with_type() {
688 let node = make_node("concept");
689 let snapshot = GraphSnapshot {
690 nodes: vec![node],
691 edges: vec![],
692 elapsed_ms: 0,
693 };
694 let json_str = render_json(&snapshot).expect("rendering must work");
695 let json: serde_json::Value = serde_json::from_str(&json_str).expect("valid json");
696 let first_node = &json["nodes"][0];
697 assert_eq!(first_node["kind"], first_node["type"]);
698 assert_eq!(first_node["type"], "concept");
699 }
700
701 #[test]
702 fn graph_traverse_response_serializes_correctly() {
703 let resp = GraphTraverseResponse {
704 from: "entity-a".to_string(),
705 namespace: "global".to_string(),
706 depth: 2,
707 hops: vec![TraverseHop {
708 entity: "entity-b".to_string(),
709 relation: "uses".to_string(),
710 direction: "outbound".to_string(),
711 weight: 1.0,
712 depth: 1,
713 }],
714 elapsed_ms: 5,
715 };
716 let json = serde_json::to_value(&resp).unwrap();
717 assert_eq!(json["from"], "entity-a");
718 assert_eq!(json["depth"], 2);
719 assert!(json["hops"].is_array());
720 assert_eq!(json["hops"][0]["direction"], "outbound");
721 }
722
723 #[test]
724 fn graph_stats_response_serializes_correctly() {
725 let resp = GraphStatsResponse {
726 namespace: Some("global".to_string()),
727 node_count: 10,
728 edge_count: 15,
729 avg_degree: 3.0,
730 max_degree: 7,
731 elapsed_ms: 2,
732 };
733 let json = serde_json::to_value(&resp).unwrap();
734 assert_eq!(json["node_count"], 10);
735 assert_eq!(json["edge_count"], 15);
736 assert_eq!(json["avg_degree"], 3.0);
737 assert_eq!(json["max_degree"], 7);
738 }
739
740 fn compute_avg_degree(node_count: i64, edge_count: i64) -> f64 {
741 if node_count > 0 {
742 2.0 * (edge_count as f64) / (node_count as f64)
743 } else {
744 0.0
745 }
746 }
747
748 #[test]
749 fn avg_degree_is_zero_when_no_nodes() {
750 assert_eq!(compute_avg_degree(0, 0), 0.0);
751 }
752
753 #[test]
754 fn avg_degree_is_zero_when_nodes_but_no_edges() {
755 assert_eq!(compute_avg_degree(2, 0), 0.0);
757 }
758
759 #[test]
760 fn avg_degree_is_two_when_triangle() {
761 assert_eq!(compute_avg_degree(3, 3), 2.0);
763 }
764
765 #[test]
766 fn graph_entities_response_serializes_required_fields() {
767 let resp = GraphEntitiesResponse {
768 entities: vec![EntityItem {
769 id: 1,
770 name: "claude-code".to_string(),
771 entity_type: "agent".to_string(),
772 namespace: "global".to_string(),
773 created_at: "2026-01-01T00:00:00Z".to_string(),
774 }],
775 total_count: 1,
776 limit: 50,
777 offset: 0,
778 namespace: Some("global".to_string()),
779 elapsed_ms: 3,
780 };
781 let json = serde_json::to_value(&resp).unwrap();
782 assert!(json["entities"].is_array());
783 assert_eq!(json["entities"][0]["name"], "claude-code");
784 assert_eq!(json["entities"][0]["entity_type"], "agent");
785 assert_eq!(json["total_count"], 1);
786 assert_eq!(json["limit"], 50);
787 assert_eq!(json["offset"], 0);
788 assert_eq!(json["namespace"], "global");
789 }
790
791 #[test]
792 fn entity_item_serializes_all_fields() {
793 let item = EntityItem {
794 id: 42,
795 name: "test-entity".to_string(),
796 entity_type: "concept".to_string(),
797 namespace: "project-a".to_string(),
798 created_at: "2026-04-19T12:00:00Z".to_string(),
799 };
800 let json = serde_json::to_value(&item).unwrap();
801 assert_eq!(json["id"], 42);
802 assert_eq!(json["name"], "test-entity");
803 assert_eq!(json["entity_type"], "concept");
804 assert_eq!(json["namespace"], "project-a");
805 assert_eq!(json["created_at"], "2026-04-19T12:00:00Z");
806 }
807
808 #[test]
809 fn entity_item_entity_type_is_never_null() {
810 let item = EntityItem {
812 id: 1,
813 name: "sem-tipo".to_string(),
814 entity_type: String::new(),
815 namespace: "ns".to_string(),
816 created_at: "2026-01-01T00:00:00Z".to_string(),
817 };
818 let json = serde_json::to_value(&item).unwrap();
819 assert!(
820 !json["entity_type"].is_null(),
821 "entity_type must not be null"
822 );
823 assert!(json["entity_type"].is_string());
824 }
825
826 #[test]
827 fn graph_traverse_cli_rejects_format_dot() {
828 let parsed = Cli::try_parse_from([
829 "sqlite-graphrag",
830 "graph",
831 "traverse",
832 "--from",
833 "AuthDecision",
834 "--format",
835 "dot",
836 ]);
837 assert!(parsed.is_err(), "graph traverse must reject format=dot");
838 }
839
840 #[test]
841 fn graph_stats_cli_accepts_format_text() {
842 let parsed = Cli::try_parse_from(["sqlite-graphrag", "graph", "stats", "--format", "text"])
843 .expect("graph stats --format text must be accepted");
844
845 match parsed.command {
846 Commands::Graph(args) => match args.subcommand {
847 Some(GraphSubcommand::Stats(stats)) => {
848 assert_eq!(stats.format, GraphStatsFormat::Text);
849 }
850 _ => unreachable!("unexpected subcommand"),
851 },
852 _ => unreachable!("unexpected command"),
853 }
854 }
855
856 #[test]
857 fn graph_stats_cli_rejects_format_mermaid() {
858 let parsed =
859 Cli::try_parse_from(["sqlite-graphrag", "graph", "stats", "--format", "mermaid"]);
860 assert!(parsed.is_err(), "graph stats must reject format=mermaid");
861 }
862
863 #[test]
864 fn graph_entities_response_has_no_items_key() {
865 let resp = GraphEntitiesResponse {
866 entities: vec![],
867 total_count: 0,
868 limit: 50,
869 offset: 0,
870 namespace: None,
871 elapsed_ms: 0,
872 };
873 let json = serde_json::to_value(&resp).unwrap();
874 assert!(
875 json.get("items").is_none(),
876 "legacy 'items' key must not appear"
877 );
878 assert!(
879 json.get("entities").is_some(),
880 "'entities' key must be present"
881 );
882 }
883}