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 RecomputeDegree(GraphRecomputeDegreeArgs),
28}
29
30#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
31pub enum GraphTraverseFormat {
32 Json,
33}
34
35#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
36pub enum GraphStatsFormat {
37 Json,
38 Text,
39}
40
41#[derive(clap::Args)]
42#[command(after_long_help = "EXAMPLES:\n \
43 # Export full entity snapshot as JSON (default)\n \
44 sqlite-graphrag graph\n\n \
45 # Traverse relationships from a starting entity\n \
46 sqlite-graphrag graph traverse --from acme-corp --depth 2\n\n \
47 # Show graph statistics as structured JSON\n \
48 sqlite-graphrag graph stats --format json\n\n \
49 # List entities filtered by type\n \
50 sqlite-graphrag graph entities --entity-type person\n\n \
51 # Export full snapshot in DOT format for Graphviz\n \
52 sqlite-graphrag graph --format dot --output graph.dot\n\n \
53NOTES:\n \
54 Without a subcommand, exports the full entity+edge snapshot.\n \
55 Use `traverse`, `stats`, or `entities` for targeted queries.")]
56pub struct GraphArgs {
57 #[command(subcommand)]
59 pub subcommand: Option<GraphSubcommand>,
60 #[arg(long)]
62 pub namespace: Option<String>,
63 #[arg(long, value_enum, default_value = "json")]
65 pub format: GraphExportFormat,
66 #[arg(long)]
68 pub output: Option<PathBuf>,
69 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
70 pub json: bool,
71 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
72 pub db: Option<String>,
73}
74
75#[derive(clap::Args)]
76#[command(after_long_help = "EXAMPLES:\n \
77 # Traverse relationships from an entity with default depth (2)\n \
78 sqlite-graphrag graph traverse --from acme-corp\n\n \
79 # Increase traversal depth to 3 hops\n \
80 sqlite-graphrag graph traverse --from acme-corp --depth 3\n\n \
81 # Traverse within a specific namespace\n \
82 sqlite-graphrag graph traverse --from acme-corp --namespace project-x\n\n \
83NOTES:\n \
84 Output is always JSON. The `hops` array contains each reachable entity\n \
85 with its relation, direction (inbound/outbound), weight, and depth level.\n \
86 Short nicknames (e.g. `danilo` vs `danilo-aguiar-teixeira`) do not exact-match;\n \
87 without `--fuzzy` the error includes ranked suggestions (v1.1.05). With\n \
88 `--fuzzy`, a clear single winner is auto-resolved and warned on stderr.")]
89pub struct GraphTraverseArgs {
90 #[arg(long)]
92 pub from: String,
93 #[arg(long, default_value_t = 2u32)]
95 pub depth: u32,
96 #[arg(long, default_value_t = false)]
100 pub fuzzy: bool,
101 #[arg(long)]
102 pub namespace: Option<String>,
103 #[arg(long, value_enum, default_value = "json")]
104 pub format: GraphTraverseFormat,
105 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
106 pub json: bool,
107 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
108 pub db: Option<String>,
109}
110
111#[derive(clap::Args)]
112#[command(after_long_help = "EXAMPLES:\n \
113 # Show stats for all namespaces (human-readable text)\n \
114 sqlite-graphrag graph stats --format text\n\n \
115 # Show stats as structured JSON\n \
116 sqlite-graphrag graph stats --format json\n\n \
117 # Show stats for a specific namespace\n \
118 sqlite-graphrag graph stats --namespace project-x --format text\n\n \
119NOTES:\n \
120 Reports node_count, edge_count, avg_degree, and max_degree.\n \
121 Default format is JSON. Use `--format text` for a compact single-line summary.")]
122pub struct GraphStatsArgs {
123 #[arg(long)]
124 pub namespace: Option<String>,
125 #[arg(long, value_enum, default_value = "json")]
127 pub format: GraphStatsFormat,
128 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
129 pub json: bool,
130 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
131 pub db: Option<String>,
132}
133
134#[derive(Debug, Clone, Copy, clap::ValueEnum)]
136pub enum EntitySortField {
137 Name,
139 Degree,
141 CreatedAt,
143}
144
145#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
147pub enum SortOrder {
148 #[default]
149 Asc,
150 Desc,
151}
152
153#[derive(clap::Args)]
154#[command(after_long_help = "EXAMPLES:\n \
155 # List all entities (default limit applies)\n \
156 sqlite-graphrag graph entities\n\n \
157 # Filter by entity type\n \
158 sqlite-graphrag graph entities --entity-type person\n\n \
159 # Filter by namespace and type\n \
160 sqlite-graphrag graph entities --namespace project-x --entity-type concept\n\n \
161 # Paginate results (skip first 20, return next 10)\n \
162 sqlite-graphrag graph entities --offset 20 --limit 10\n\n \
163 # Sort by degree descending (most connected first)\n \
164 sqlite-graphrag graph entities --sort-by degree --order desc\n\n \
165 # Sort by creation date ascending\n \
166 sqlite-graphrag graph entities --sort-by created-at --order asc\n\n \
167NOTES:\n \
168 Output is always JSON with `entities`, `total_count`, `limit`, and `offset` fields.\n \
169 Entity types are canonical strings (e.g. `person`, `organization`, `location`).")]
170pub struct GraphEntitiesArgs {
171 #[arg(long)]
172 pub namespace: Option<String>,
173 #[arg(long, value_enum)]
175 pub entity_type: Option<EntityType>,
176 #[arg(long, default_value_t = crate::constants::K_GRAPH_ENTITIES_DEFAULT_LIMIT)]
178 pub limit: usize,
179 #[arg(long, default_value_t = 0usize)]
181 pub offset: usize,
182 #[arg(long, value_enum, help = "Sort entities by field")]
184 pub sort_by: Option<EntitySortField>,
185 #[arg(long, value_enum, default_value_t = SortOrder::Asc, help = "Sort order")]
187 pub order: SortOrder,
188 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
189 pub json: bool,
190 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
191 pub db: Option<String>,
192}
193
194#[derive(clap::Args)]
195#[command(after_long_help = "EXAMPLES:\n \
196 # Preview divergences without writing (recommended first run)\n \
197 sqlite-graphrag graph recompute-degree --dry-run\n\n \
198 # Reconcile every namespace\n \
199 sqlite-graphrag graph recompute-degree\n\n \
200 # Reconcile a single namespace\n \
201 sqlite-graphrag graph recompute-degree --namespace project-x\n\n\
202NOTES:\n \
203 `entities.degree` is a derived cache (incremented on link, recalculated\n \
204 on merge/delete) that drifts when edges are written by paths that skip\n \
205 the recalculation. This command recomputes every entity's degree from\n \
206 the real `relationships` rows (same semantics as the canonical\n \
207 `recalculate_degree` helper: COUNT(*) WHERE source_id = id OR\n \
208 target_id = id) inside one transaction. Entities left with zero edges\n \
209 are zeroed. Envelope: {total, updated, zeroed, unchanged}.")]
210pub struct GraphRecomputeDegreeArgs {
211 #[arg(long)]
213 pub namespace: Option<String>,
214 #[arg(long)]
216 pub dry_run: bool,
217 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
218 pub json: bool,
219 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
220 pub db: Option<String>,
221}
222
223#[derive(Serialize, Clone)]
224struct NodeOut {
225 id: i64,
226 name: String,
227 namespace: String,
228 kind: String,
231 #[serde(rename = "type")]
234 r#type: String,
235}
236
237#[derive(Serialize)]
238struct EdgeOut {
239 from: String,
240 to: String,
241 relation: String,
242 weight: f64,
243}
244
245#[derive(Serialize)]
246struct GraphSnapshot {
247 nodes: Vec<NodeOut>,
248 entities: Vec<NodeOut>,
249 edges: Vec<EdgeOut>,
250 elapsed_ms: u64,
251}
252
253#[derive(Serialize)]
254struct TraverseHop {
255 entity: String,
256 relation: String,
257 direction: String,
258 weight: f64,
259 depth: u32,
260}
261
262#[derive(Serialize)]
263struct GraphTraverseResponse {
264 from: String,
265 namespace: String,
266 depth: u32,
267 hops: Vec<TraverseHop>,
268 elapsed_ms: u64,
269}
270
271#[derive(Serialize)]
272struct GraphStatsResponse {
273 namespace: Option<String>,
274 node_count: i64,
275 edge_count: i64,
276 avg_degree: f64,
277 max_degree: i64,
278 elapsed_ms: u64,
279}
280
281#[derive(Serialize)]
282struct EntityItem {
283 id: i64,
284 name: String,
285 entity_type: String,
286 namespace: String,
287 created_at: String,
288 degree: u32,
290 #[serde(skip_serializing_if = "Option::is_none")]
291 description: Option<String>,
292}
293
294#[derive(Serialize)]
295struct GraphEntitiesResponse {
296 entities: Vec<EntityItem>,
297 total_count: i64,
298 limit: usize,
299 offset: usize,
300 namespace: Option<String>,
301 elapsed_ms: u64,
302}
303
304pub fn run(args: GraphArgs) -> Result<(), AppError> {
305 match args.subcommand {
306 None => run_entities_snapshot(
307 args.db.as_deref(),
308 args.namespace.as_deref(),
309 args.format,
310 args.json,
311 args.output.as_deref(),
312 ),
313 Some(GraphSubcommand::Traverse(mut a)) => {
314 if a.db.is_none() {
315 a.db = args.db;
316 }
317 if a.namespace.is_none() {
318 a.namespace = args.namespace;
319 }
320 run_traverse(a)
321 }
322 Some(GraphSubcommand::Stats(mut a)) => {
323 if a.db.is_none() {
324 a.db = args.db;
325 }
326 if a.namespace.is_none() {
327 a.namespace = args.namespace;
328 }
329 run_stats(a)
330 }
331 Some(GraphSubcommand::Entities(mut a)) => {
332 if a.db.is_none() {
333 a.db = args.db;
334 }
335 if a.namespace.is_none() {
336 a.namespace = args.namespace;
337 }
338 run_entities(a)
339 }
340 Some(GraphSubcommand::RecomputeDegree(mut a)) => {
341 if a.db.is_none() {
342 a.db = args.db;
343 }
344 if a.namespace.is_none() {
345 a.namespace = args.namespace;
346 }
347 run_recompute_degree(a)
348 }
349 }
350}
351
352#[derive(Debug, Serialize, PartialEq, Eq)]
358struct RecomputeDegreeSummary {
359 total: i64,
360 updated: i64,
361 zeroed: i64,
362 unchanged: i64,
363}
364
365#[derive(Serialize)]
366struct RecomputeDegreeResponse {
367 namespace: Option<String>,
368 dry_run: bool,
369 total: i64,
370 updated: i64,
371 zeroed: i64,
372 unchanged: i64,
373 elapsed_ms: u64,
374}
375
376fn recompute_degrees(
385 conn: &mut rusqlite::Connection,
386 namespace: Option<&str>,
387 dry_run: bool,
388) -> Result<RecomputeDegreeSummary, AppError> {
389 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
390
391 const SELECT_BASE: &str = "SELECT e.id, e.degree, \
392 (SELECT COUNT(*) FROM relationships r \
393 WHERE r.source_id = e.id OR r.target_id = e.id) \
394 FROM entities e";
395 let rows: Vec<(i64, i64, i64)> = if let Some(ns) = namespace {
396 let mut stmt = tx.prepare(&format!("{SELECT_BASE} WHERE e.namespace = ?1"))?;
397 let r = stmt
398 .query_map(rusqlite::params![ns], |r| {
399 Ok((r.get(0)?, r.get(1)?, r.get(2)?))
400 })?
401 .collect::<Result<Vec<_>, _>>()?;
402 r
403 } else {
404 let mut stmt = tx.prepare(SELECT_BASE)?;
405 let r = stmt
406 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
407 .collect::<Result<Vec<_>, _>>()?;
408 r
409 };
410
411 let mut summary = RecomputeDegreeSummary {
412 total: rows.len() as i64,
413 updated: 0,
414 zeroed: 0,
415 unchanged: 0,
416 };
417 for (id, stored, real) in rows {
418 if stored == real {
419 summary.unchanged += 1;
420 continue;
421 }
422 if !dry_run {
423 tx.execute(
424 "UPDATE entities SET degree = ?1, updated_at = unixepoch() WHERE id = ?2",
425 rusqlite::params![real, id],
426 )?;
427 }
428 if real == 0 {
429 summary.zeroed += 1;
430 } else {
431 summary.updated += 1;
432 }
433 }
434
435 if dry_run {
436 drop(tx);
438 } else {
439 tx.commit()?;
440 }
441 Ok(summary)
442}
443
444fn run_recompute_degree(args: GraphRecomputeDegreeArgs) -> Result<(), AppError> {
445 let inicio = Instant::now();
446 let paths = AppPaths::resolve(args.db.as_deref())?;
447 crate::storage::connection::ensure_db_ready(&paths)?;
448 let mut conn = crate::storage::connection::open_rw(&paths.db)?;
449
450 let summary = recompute_degrees(&mut conn, args.namespace.as_deref(), args.dry_run)?;
451
452 output::emit_json(&RecomputeDegreeResponse {
453 namespace: args.namespace,
454 dry_run: args.dry_run,
455 total: summary.total,
456 updated: summary.updated,
457 zeroed: summary.zeroed,
458 unchanged: summary.unchanged,
459 elapsed_ms: inicio.elapsed().as_millis() as u64,
460 })?;
461 Ok(())
462}
463
464fn run_entities_snapshot(
465 db: Option<&str>,
466 namespace: Option<&str>,
467 format: GraphExportFormat,
468 json: bool,
469 output_path: Option<&std::path::Path>,
470) -> Result<(), AppError> {
471 let inicio = Instant::now();
472 let paths = AppPaths::resolve(db)?;
473
474 crate::storage::connection::ensure_db_ready(&paths)?;
475
476 let conn = open_ro(&paths.db)?;
477
478 let nodes_raw = entities::list_entities(&conn, namespace)?;
479 let edges_raw = entities::list_relationships_by_namespace(&conn, namespace)?;
480
481 let id_to_name: HashMap<i64, String> =
482 nodes_raw.iter().map(|n| (n.id, n.name.clone())).collect();
483
484 let nodes: Vec<NodeOut> = nodes_raw
485 .into_iter()
486 .map(|n| NodeOut {
487 id: n.id,
488 name: n.name,
489 namespace: n.namespace,
490 r#type: n.kind.clone(),
491 kind: n.kind,
492 })
493 .collect();
494
495 let mut edges: Vec<EdgeOut> = Vec::with_capacity(edges_raw.len());
496 let mut orphan_edges: usize = 0;
497 for r in edges_raw {
498 let from = match id_to_name.get(&r.source_id) {
499 Some(n) => n.clone(),
500 None => {
501 orphan_edges += 1;
502 tracing::warn!(target: "graph_export", source_id = r.source_id, relation = %r.relation, "edge skipped: source entity not found in id_to_name map");
503 continue;
504 }
505 };
506 let to = match id_to_name.get(&r.target_id) {
507 Some(n) => n.clone(),
508 None => {
509 orphan_edges += 1;
510 tracing::warn!(target: "graph_export", target_id = r.target_id, relation = %r.relation, "edge skipped: target entity not found in id_to_name map");
511 continue;
512 }
513 };
514 edges.push(EdgeOut {
515 from,
516 to,
517 relation: r.relation,
518 weight: r.weight,
519 });
520 }
521 if orphan_edges > 0 {
522 tracing::warn!(target: "graph_export",
523 count = orphan_edges,
524 "edges skipped due to orphaned entity references"
525 );
526 }
527
528 let effective_format = if json {
529 GraphExportFormat::Json
530 } else {
531 format
532 };
533
534 if effective_format == GraphExportFormat::Ndjson {
535 let elapsed_ms = inicio.elapsed().as_millis() as u64;
536 render_ndjson_streaming(&nodes, &edges, elapsed_ms, output_path)?;
537 return Ok(());
538 }
539
540 let rendered = match effective_format {
541 GraphExportFormat::Json => {
542 let entities = nodes.clone();
543 render_json(&GraphSnapshot {
544 nodes,
545 entities,
546 edges,
547 elapsed_ms: inicio.elapsed().as_millis() as u64,
548 })?
549 }
550 GraphExportFormat::Dot => render_dot(&nodes, &edges),
551 GraphExportFormat::Mermaid => render_mermaid(&nodes, &edges),
552 GraphExportFormat::Ndjson => unreachable!("ndjson handled above"),
553 };
554
555 if let Some(path) = output_path.filter(|_| !json) {
556 fs::write(path, &rendered)?;
557 output::emit_progress(&format!("wrote {}", path.display()));
558 } else {
559 output::emit_text(&rendered);
560 }
561
562 Ok(())
563}
564
565fn run_traverse(args: GraphTraverseArgs) -> Result<(), AppError> {
566 let inicio = Instant::now();
567 let _ = args.format;
568 let paths = AppPaths::resolve(args.db.as_deref())?;
569
570 crate::storage::connection::ensure_db_ready(&paths)?;
571
572 let conn = open_ro(&paths.db)?;
573 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
574
575 let (from_id, resolved_name) =
578 match entities::resolve_entity_fuzzy(&conn, &namespace, &args.from, args.fuzzy)? {
579 Some((id, name, was_fuzzy)) => {
580 if was_fuzzy {
581 tracing::warn!(
582 target: "graph_export",
583 query = %args.from,
584 resolved = %name,
585 "traverse: fuzzy-resolved entity name"
586 );
587 }
588 (id, name)
589 }
590 None => {
591 return Err(entities::entity_not_found_with_suggestions(
592 &conn, &namespace, &args.from,
593 ));
594 }
595 };
596
597 let all_rels = entities::list_relationships_by_namespace(&conn, Some(&namespace))?;
598 let all_entities = entities::list_entities(&conn, Some(&namespace))?;
599 let id_to_name: HashMap<i64, String> = all_entities
600 .iter()
601 .map(|e| (e.id, e.name.clone()))
602 .collect();
603
604 let mut hops: Vec<TraverseHop> = Vec::with_capacity(16);
605 let mut visited: std::collections::HashSet<i64> =
606 std::collections::HashSet::with_capacity(args.depth as usize * 10);
607 let mut frontier: Vec<(i64, u32)> = vec![(from_id, 0)];
608
609 while let Some((current_id, current_depth)) = frontier.pop() {
610 if current_depth >= args.depth || visited.contains(¤t_id) {
611 continue;
612 }
613 visited.insert(current_id);
614
615 for rel in &all_rels {
616 if rel.source_id == current_id {
617 if let Some(target_name) = id_to_name.get(&rel.target_id) {
618 hops.push(TraverseHop {
619 entity: target_name.clone(),
620 relation: rel.relation.clone(),
621 direction: "outbound".to_string(),
622 weight: rel.weight,
623 depth: current_depth + 1,
624 });
625 frontier.push((rel.target_id, current_depth + 1));
626 }
627 } else if rel.target_id == current_id {
628 if let Some(source_name) = id_to_name.get(&rel.source_id) {
629 hops.push(TraverseHop {
630 entity: source_name.clone(),
631 relation: rel.relation.clone(),
632 direction: "inbound".to_string(),
633 weight: rel.weight,
634 depth: current_depth + 1,
635 });
636 frontier.push((rel.source_id, current_depth + 1));
637 }
638 }
639 }
640 }
641
642 output::emit_json(&GraphTraverseResponse {
643 from: resolved_name,
644 namespace,
645 depth: args.depth,
646 hops,
647 elapsed_ms: inicio.elapsed().as_millis() as u64,
648 })?;
649
650 Ok(())
651}
652
653fn run_stats(args: GraphStatsArgs) -> Result<(), AppError> {
654 let inicio = Instant::now();
655 let paths = AppPaths::resolve(args.db.as_deref())?;
656
657 crate::storage::connection::ensure_db_ready(&paths)?;
658
659 let conn = open_ro(&paths.db)?;
660 let ns = args.namespace.as_deref();
661
662 let node_count: i64 = if let Some(n) = ns {
663 conn.query_row(
664 "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
665 rusqlite::params![n],
666 |r| r.get(0),
667 )?
668 } else {
669 conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?
670 };
671
672 let edge_count: i64 = if let Some(n) = ns {
673 conn.query_row(
674 "SELECT COUNT(*) FROM relationships r
675 JOIN entities s ON s.id = r.source_id
676 WHERE s.namespace = ?1",
677 rusqlite::params![n],
678 |r| r.get(0),
679 )?
680 } else {
681 conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?
682 };
683
684 let max_degree: i64 = if let Some(n) = ns {
685 conn.query_row(
686 "SELECT COALESCE(MAX(degree), 0) FROM entities WHERE namespace = ?1",
687 rusqlite::params![n],
688 |r| r.get(0),
689 )?
690 } else {
691 conn.query_row("SELECT COALESCE(MAX(degree), 0) FROM entities", [], |r| {
692 r.get(0)
693 })?
694 };
695
696 let avg_degree = if node_count > 0 {
698 2.0 * (edge_count as f64) / (node_count as f64)
699 } else {
700 0.0
701 };
702
703 let resp = GraphStatsResponse {
704 namespace: args.namespace,
705 node_count,
706 edge_count,
707 avg_degree,
708 max_degree,
709 elapsed_ms: inicio.elapsed().as_millis() as u64,
710 };
711
712 let effective_format = if args.json {
713 GraphStatsFormat::Json
714 } else {
715 args.format
716 };
717
718 match effective_format {
719 GraphStatsFormat::Json => output::emit_json(&resp)?,
720 GraphStatsFormat::Text => {
721 output::emit_text(&format!(
722 "nodes={} edges={} avg_degree={:.2} max_degree={} namespace={}",
723 resp.node_count,
724 resp.edge_count,
725 resp.avg_degree,
726 resp.max_degree,
727 resp.namespace.as_deref().unwrap_or("all"),
728 ));
729 }
730 }
731
732 Ok(())
733}
734
735fn build_order_by(sort_by: Option<EntitySortField>, order: SortOrder) -> &'static str {
739 match (sort_by, order) {
743 (None, SortOrder::Asc) | (Some(EntitySortField::Name), SortOrder::Asc) => {
744 "ORDER BY e.name ASC"
745 }
746 (Some(EntitySortField::Name), SortOrder::Desc) => "ORDER BY e.name DESC",
747 (Some(EntitySortField::Degree), SortOrder::Asc) => "ORDER BY degree ASC",
748 (Some(EntitySortField::Degree), SortOrder::Desc) => "ORDER BY degree DESC",
749 (Some(EntitySortField::CreatedAt), SortOrder::Asc) => "ORDER BY e.created_at ASC",
750 (Some(EntitySortField::CreatedAt), SortOrder::Desc) => "ORDER BY e.created_at DESC",
751 (None, SortOrder::Desc) => "ORDER BY e.name DESC",
753 }
754}
755
756fn run_entities(args: GraphEntitiesArgs) -> Result<(), AppError> {
757 let inicio = Instant::now();
758 let paths = AppPaths::resolve(args.db.as_deref())?;
759
760 crate::storage::connection::ensure_db_ready(&paths)?;
761
762 let conn = open_ro(&paths.db)?;
763
764 let row_to_item = |r: &rusqlite::Row<'_>| -> rusqlite::Result<EntityItem> {
765 let ts: i64 = r.get(4)?;
766 let created_at = chrono::DateTime::from_timestamp(ts, 0)
767 .unwrap_or_default()
768 .format("%Y-%m-%dT%H:%M:%SZ")
769 .to_string();
770 Ok(EntityItem {
771 id: r.get(0)?,
772 name: r.get(1)?,
773 entity_type: r.get(2)?,
774 namespace: r.get(3)?,
775 created_at,
776 degree: r.get(5)?,
777 description: r.get(6)?,
778 })
779 };
780
781 let limit_i = args.limit as i64;
782 let offset_i = args.offset as i64;
783 let order_clause = build_order_by(args.sort_by, args.order);
784
785 let base_select = "SELECT e.id, e.name, COALESCE(e.type, ''), e.namespace, e.created_at,
786 (SELECT COUNT(*) FROM relationships r
787 WHERE r.source_id = e.id OR r.target_id = e.id) AS degree,
788 e.description
789 FROM entities e";
790
791 let (total_count, items) = match (
792 args.namespace.as_deref(),
793 args.entity_type.map(|et| et.as_str()),
794 ) {
795 (Some(ns), Some(et)) => {
796 let count: i64 = conn.query_row(
797 "SELECT COUNT(*) FROM entities WHERE namespace = ?1 AND type = ?2",
798 rusqlite::params![ns, et],
799 |r| r.get(0),
800 )?;
801 let sql = format!(
802 "{base_select} WHERE e.namespace = ?1 AND e.type = ?2 {order_clause} LIMIT ?3 OFFSET ?4"
803 );
804 let mut stmt = conn.prepare(&sql)?;
805 let rows = stmt
806 .query_map(rusqlite::params![ns, et, limit_i, offset_i], row_to_item)?
807 .collect::<rusqlite::Result<Vec<_>>>()?;
808 (count, rows)
809 }
810 (Some(ns), None) => {
811 let count: i64 = conn.query_row(
812 "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
813 rusqlite::params![ns],
814 |r| r.get(0),
815 )?;
816 let sql =
817 format!("{base_select} WHERE e.namespace = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
818 let mut stmt = conn.prepare(&sql)?;
819 let rows = stmt
820 .query_map(rusqlite::params![ns, limit_i, offset_i], row_to_item)?
821 .collect::<rusqlite::Result<Vec<_>>>()?;
822 (count, rows)
823 }
824 (None, Some(et)) => {
825 let count: i64 = conn.query_row(
826 "SELECT COUNT(*) FROM entities WHERE type = ?1",
827 rusqlite::params![et],
828 |r| r.get(0),
829 )?;
830 let sql = format!("{base_select} WHERE e.type = ?1 {order_clause} LIMIT ?2 OFFSET ?3");
831 let mut stmt = conn.prepare(&sql)?;
832 let rows = stmt
833 .query_map(rusqlite::params![et, limit_i, offset_i], row_to_item)?
834 .collect::<rusqlite::Result<Vec<_>>>()?;
835 (count, rows)
836 }
837 (None, None) => {
838 let count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
839 let sql = format!("{base_select} {order_clause} LIMIT ?1 OFFSET ?2");
840 let mut stmt = conn.prepare(&sql)?;
841 let rows = stmt
842 .query_map(rusqlite::params![limit_i, offset_i], row_to_item)?
843 .collect::<rusqlite::Result<Vec<_>>>()?;
844 (count, rows)
845 }
846 };
847
848 output::emit_json(&GraphEntitiesResponse {
849 entities: items,
850 total_count,
851 limit: args.limit,
852 offset: args.offset,
853 namespace: args.namespace,
854 elapsed_ms: inicio.elapsed().as_millis() as u64,
855 })
856}
857
858fn render_json(snapshot: &GraphSnapshot) -> Result<String, AppError> {
859 Ok(serde_json::to_string_pretty(snapshot)?)
860}
861
862fn render_ndjson_streaming(
867 nodes: &[NodeOut],
868 edges: &[EdgeOut],
869 elapsed_ms: u64,
870 output_path: Option<&std::path::Path>,
871) -> Result<(), AppError> {
872 #[derive(serde::Serialize)]
873 struct NdjsonNode<'a> {
874 kind: &'static str,
875 id: i64,
876 name: &'a str,
877 namespace: &'a str,
878 #[serde(rename = "type")]
879 r#type: &'a str,
880 }
881 #[derive(serde::Serialize)]
882 struct NdjsonEdge<'a> {
883 kind: &'static str,
884 from: &'a str,
885 to: &'a str,
886 relation: &'a str,
887 weight: f64,
888 }
889 #[derive(serde::Serialize)]
890 struct NdjsonSummary {
891 kind: &'static str,
892 nodes: usize,
893 edges: usize,
894 elapsed_ms: u64,
895 }
896
897 use std::io::Write as IoWrite;
898
899 let mut buf: Vec<u8> = Vec::with_capacity(4096);
900
901 let emit_line =
902 |buf: &mut Vec<u8>, line: &str, path: Option<&std::path::Path>| -> Result<(), AppError> {
903 buf.clear();
904 buf.extend_from_slice(line.as_bytes());
905 buf.push(b'\n');
906 if let Some(p) = path {
907 let mut f = std::fs::OpenOptions::new()
908 .create(true)
909 .append(true)
910 .open(p)
911 .map_err(AppError::Io)?;
912 f.write_all(buf).map_err(AppError::Io)?;
913 } else {
914 output::emit_text(line);
915 }
916 Ok(())
917 };
918
919 if let Some(p) = output_path {
921 fs::write(p, b"")?;
922 }
923
924 for node in nodes {
925 let obj = NdjsonNode {
926 kind: "node",
927 id: node.id,
928 name: &node.name,
929 namespace: &node.namespace,
930 r#type: &node.r#type,
931 };
932 let line = serde_json::to_string(&obj)?;
933 emit_line(&mut buf, &line, output_path)?;
934 }
935
936 for edge in edges {
937 let obj = NdjsonEdge {
938 kind: "edge",
939 from: &edge.from,
940 to: &edge.to,
941 relation: &edge.relation,
942 weight: edge.weight,
943 };
944 let line = serde_json::to_string(&obj)?;
945 emit_line(&mut buf, &line, output_path)?;
946 }
947
948 let summary = NdjsonSummary {
949 kind: "summary",
950 nodes: nodes.len(),
951 edges: edges.len(),
952 elapsed_ms,
953 };
954 let line = serde_json::to_string(&summary)?;
955 emit_line(&mut buf, &line, output_path)?;
956
957 Ok(())
958}
959
960fn sanitize_dot_id(raw: &str) -> String {
961 raw.chars()
962 .map(|c| {
963 if c.is_ascii_alphanumeric() || c == '_' {
964 c
965 } else {
966 '_'
967 }
968 })
969 .collect()
970}
971
972fn render_dot(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
973 use std::fmt::Write;
974 let mut out = String::with_capacity(nodes.len() * 80 + edges.len() * 60 + 300);
975 out.push_str("digraph sqlite_graphrag {\n");
976 out.push_str(" graph [bgcolor=\"white\", fontname=\"Helvetica Neue\", fontsize=12, rankdir=LR, nodesep=0.8, ranksep=1.2];\n");
977 out.push_str(" node [shape=box, style=\"filled,rounded\", fillcolor=\"#F2F2F7\", fontname=\"Helvetica Neue\", fontsize=11, color=\"#C7C7CC\"];\n");
978 out.push_str(" edge [fontname=\"Helvetica Neue\", fontsize=9, color=\"#8E8E93\"];\n");
979 for node in nodes {
980 let node_id = sanitize_dot_id(&node.name);
981 let escaped = node.name.replace('"', "\\\"");
982 let _ = writeln!(out, " {node_id} [label=\"{escaped}\"];");
983 }
984 for edge in edges {
985 let from = sanitize_dot_id(&edge.from);
986 let to = sanitize_dot_id(&edge.to);
987 let label = edge.relation.replace('"', "\\\"");
988 let _ = writeln!(out, " {from} -> {to} [label=\"{label}\"];");
989 }
990 out.push_str("}\n");
991 out
992}
993
994fn sanitize_mermaid_id(raw: &str) -> String {
995 raw.chars()
996 .map(|c| {
997 if c.is_ascii_alphanumeric() || c == '_' {
998 c
999 } else {
1000 '_'
1001 }
1002 })
1003 .collect()
1004}
1005
1006fn render_mermaid(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
1007 use std::fmt::Write;
1008 let mut out = String::with_capacity(nodes.len() * 50 + edges.len() * 40 + 200);
1009 out.push_str("%%{init: {'theme': 'neutral', 'themeVariables': {'primaryColor': '#F2F2F7', 'primaryTextColor': '#1C1C1E', 'primaryBorderColor': '#C7C7CC', 'lineColor': '#8E8E93'}}}%%\n");
1010 out.push_str("graph LR\n");
1011 for node in nodes {
1012 let id = sanitize_mermaid_id(&node.name);
1013 let escaped = node.name.replace('"', "\\\"");
1014 let _ = writeln!(out, " {id}[\"{escaped}\"]");
1015 }
1016 for edge in edges {
1017 let from = sanitize_mermaid_id(&edge.from);
1018 let to = sanitize_mermaid_id(&edge.to);
1019 let label = edge.relation.replace('|', "\\|");
1020 let _ = writeln!(out, " {from} -->|{label}| {to}");
1021 }
1022 out
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use crate::cli::{Cli, Commands};
1029 use clap::Parser;
1030
1031 fn make_node(kind: &str) -> NodeOut {
1032 NodeOut {
1033 id: 1,
1034 name: "test-entity".to_string(),
1035 namespace: "default".to_string(),
1036 kind: kind.to_string(),
1037 r#type: kind.to_string(),
1038 }
1039 }
1040
1041 #[test]
1042 fn node_out_type_duplicates_kind() {
1043 let node = make_node("agent");
1044 let json = serde_json::to_value(&node).expect("serialization must work");
1045 assert_eq!(json["kind"], json["type"]);
1046 assert_eq!(json["kind"], "agent");
1047 assert_eq!(json["type"], "agent");
1048 }
1049
1050 #[test]
1051 fn node_out_serializes_all_fields() {
1052 let node = make_node("document");
1053 let json = serde_json::to_value(&node).expect("serialization must work");
1054 assert!(json.get("id").is_some());
1055 assert!(json.get("name").is_some());
1056 assert!(json.get("namespace").is_some());
1057 assert!(json.get("kind").is_some());
1058 assert!(json.get("type").is_some());
1059 }
1060
1061 #[test]
1062 fn graph_snapshot_serializes_nodes_with_type() {
1063 let node = make_node("concept");
1064 let entities = vec![make_node("concept")];
1065 let snapshot = GraphSnapshot {
1066 nodes: vec![node],
1067 entities,
1068 edges: vec![],
1069 elapsed_ms: 0,
1070 };
1071 let json_str = render_json(&snapshot).expect("rendering must work");
1072 let json: serde_json::Value = serde_json::from_str(&json_str).expect("valid json");
1073 let first_node = &json["nodes"][0];
1074 assert_eq!(first_node["kind"], first_node["type"]);
1075 assert_eq!(first_node["type"], "concept");
1076 }
1077
1078 #[test]
1079 fn graph_traverse_response_serializes_correctly() {
1080 let resp = GraphTraverseResponse {
1081 from: "entity-a".to_string(),
1082 namespace: "global".to_string(),
1083 depth: 2,
1084 hops: vec![TraverseHop {
1085 entity: "entity-b".to_string(),
1086 relation: "uses".to_string(),
1087 direction: "outbound".to_string(),
1088 weight: 1.0,
1089 depth: 1,
1090 }],
1091 elapsed_ms: 5,
1092 };
1093 let json = serde_json::to_value(&resp).unwrap();
1094 assert_eq!(json["from"], "entity-a");
1095 assert_eq!(json["depth"], 2);
1096 assert!(json["hops"].is_array());
1097 assert_eq!(json["hops"][0]["direction"], "outbound");
1098 }
1099
1100 #[test]
1101 fn graph_stats_response_serializes_correctly() {
1102 let resp = GraphStatsResponse {
1103 namespace: Some("global".to_string()),
1104 node_count: 10,
1105 edge_count: 15,
1106 avg_degree: 3.0,
1107 max_degree: 7,
1108 elapsed_ms: 2,
1109 };
1110 let json = serde_json::to_value(&resp).unwrap();
1111 assert_eq!(json["node_count"], 10);
1112 assert_eq!(json["edge_count"], 15);
1113 assert_eq!(json["avg_degree"], 3.0);
1114 assert_eq!(json["max_degree"], 7);
1115 }
1116
1117 fn compute_avg_degree(node_count: i64, edge_count: i64) -> f64 {
1118 if node_count > 0 {
1119 2.0 * (edge_count as f64) / (node_count as f64)
1120 } else {
1121 0.0
1122 }
1123 }
1124
1125 #[test]
1126 fn avg_degree_is_zero_when_no_nodes() {
1127 assert_eq!(compute_avg_degree(0, 0), 0.0);
1128 }
1129
1130 #[test]
1131 fn avg_degree_is_zero_when_nodes_but_no_edges() {
1132 assert_eq!(compute_avg_degree(2, 0), 0.0);
1134 }
1135
1136 #[test]
1137 fn avg_degree_is_two_when_triangle() {
1138 assert_eq!(compute_avg_degree(3, 3), 2.0);
1140 }
1141
1142 #[test]
1143 fn graph_entities_response_serializes_required_fields() {
1144 let resp = GraphEntitiesResponse {
1145 entities: vec![EntityItem {
1146 id: 1,
1147 name: "claude-code".to_string(),
1148 entity_type: "agent".to_string(),
1149 namespace: "global".to_string(),
1150 created_at: "2026-01-01T00:00:00Z".to_string(),
1151 degree: 0,
1152 description: None,
1153 }],
1154 total_count: 1,
1155 limit: 50,
1156 offset: 0,
1157 namespace: Some("global".to_string()),
1158 elapsed_ms: 3,
1159 };
1160 let json = serde_json::to_value(&resp).unwrap();
1161 assert!(json["entities"].is_array());
1162 assert_eq!(json["entities"][0]["name"], "claude-code");
1163 assert_eq!(json["entities"][0]["entity_type"], "agent");
1164 assert_eq!(json["total_count"], 1);
1165 assert_eq!(json["limit"], 50);
1166 assert_eq!(json["offset"], 0);
1167 assert_eq!(json["namespace"], "global");
1168 }
1169
1170 #[test]
1171 fn entity_item_serializes_all_fields() {
1172 let item = EntityItem {
1173 id: 42,
1174 name: "test-entity".to_string(),
1175 entity_type: "concept".to_string(),
1176 namespace: "project-a".to_string(),
1177 created_at: "2026-04-19T12:00:00Z".to_string(),
1178 degree: 3,
1179 description: Some("test description".to_string()),
1180 };
1181 let json = serde_json::to_value(&item).unwrap();
1182 assert_eq!(json["id"], 42);
1183 assert_eq!(json["name"], "test-entity");
1184 assert_eq!(json["entity_type"], "concept");
1185 assert_eq!(json["namespace"], "project-a");
1186 assert_eq!(json["created_at"], "2026-04-19T12:00:00Z");
1187 }
1188
1189 #[test]
1190 fn entity_item_entity_type_is_never_null() {
1191 let item = EntityItem {
1193 id: 1,
1194 name: "sem-tipo".to_string(),
1195 entity_type: String::new(),
1196 namespace: "ns".to_string(),
1197 created_at: "2026-01-01T00:00:00Z".to_string(),
1198 degree: 0,
1199 description: None,
1200 };
1201 let json = serde_json::to_value(&item).unwrap();
1202 assert!(
1203 !json["entity_type"].is_null(),
1204 "entity_type must not be null"
1205 );
1206 assert!(json["entity_type"].is_string());
1207 }
1208
1209 #[test]
1210 fn graph_traverse_cli_rejects_format_dot() {
1211 let parsed = Cli::try_parse_from([
1212 "sqlite-graphrag",
1213 "graph",
1214 "traverse",
1215 "--from",
1216 "AuthDecision",
1217 "--format",
1218 "dot",
1219 ]);
1220 assert!(parsed.is_err(), "graph traverse must reject format=dot");
1221 }
1222
1223 #[test]
1224 fn graph_stats_cli_accepts_format_text() {
1225 let parsed = Cli::try_parse_from(["sqlite-graphrag", "graph", "stats", "--format", "text"])
1226 .expect("graph stats --format text must be accepted");
1227
1228 match parsed.command {
1229 Some(Commands::Graph(args)) => match args.subcommand {
1230 Some(GraphSubcommand::Stats(stats)) => {
1231 assert_eq!(stats.format, GraphStatsFormat::Text);
1232 }
1233 _ => unreachable!("unexpected subcommand"),
1234 },
1235 _ => unreachable!("unexpected command"),
1236 }
1237 }
1238
1239 #[test]
1240 fn graph_stats_cli_rejects_format_mermaid() {
1241 let parsed =
1242 Cli::try_parse_from(["sqlite-graphrag", "graph", "stats", "--format", "mermaid"]);
1243 assert!(parsed.is_err(), "graph stats must reject format=mermaid");
1244 }
1245
1246 #[test]
1247 fn graph_entities_response_has_no_items_key() {
1248 let resp = GraphEntitiesResponse {
1249 entities: vec![],
1250 total_count: 0,
1251 limit: 50,
1252 offset: 0,
1253 namespace: None,
1254 elapsed_ms: 0,
1255 };
1256 let json = serde_json::to_value(&resp).unwrap();
1257 assert!(
1258 json.get("items").is_none(),
1259 "legacy 'items' key must not appear"
1260 );
1261 assert!(
1262 json.get("entities").is_some(),
1263 "'entities' key must be present"
1264 );
1265 }
1266
1267 #[test]
1268 fn build_order_by_defaults_to_name_asc() {
1269 let clause = build_order_by(None, SortOrder::Asc);
1270 assert_eq!(clause, "ORDER BY e.name ASC");
1271 }
1272
1273 #[test]
1274 fn build_order_by_name_desc() {
1275 let clause = build_order_by(Some(EntitySortField::Name), SortOrder::Desc);
1276 assert_eq!(clause, "ORDER BY e.name DESC");
1277 }
1278
1279 #[test]
1280 fn build_order_by_degree_desc() {
1281 let clause = build_order_by(Some(EntitySortField::Degree), SortOrder::Desc);
1282 assert_eq!(clause, "ORDER BY degree DESC");
1283 }
1284
1285 #[test]
1286 fn build_order_by_degree_asc() {
1287 let clause = build_order_by(Some(EntitySortField::Degree), SortOrder::Asc);
1288 assert_eq!(clause, "ORDER BY degree ASC");
1289 }
1290
1291 #[test]
1292 fn build_order_by_created_at_asc() {
1293 let clause = build_order_by(Some(EntitySortField::CreatedAt), SortOrder::Asc);
1294 assert_eq!(clause, "ORDER BY e.created_at ASC");
1295 }
1296
1297 #[test]
1298 fn build_order_by_created_at_desc() {
1299 let clause = build_order_by(Some(EntitySortField::CreatedAt), SortOrder::Desc);
1300 assert_eq!(clause, "ORDER BY e.created_at DESC");
1301 }
1302
1303 #[test]
1304 fn graph_entities_cli_accepts_sort_by_degree_desc() {
1305 let parsed = Cli::try_parse_from([
1306 "sqlite-graphrag",
1307 "graph",
1308 "entities",
1309 "--sort-by",
1310 "degree",
1311 "--order",
1312 "desc",
1313 ])
1314 .expect("graph entities --sort-by degree --order desc must parse");
1315 match parsed.command {
1316 Some(Commands::Graph(args)) => match args.subcommand {
1317 Some(GraphSubcommand::Entities(e)) => {
1318 assert!(matches!(e.sort_by, Some(EntitySortField::Degree)));
1319 assert!(matches!(e.order, SortOrder::Desc));
1320 }
1321 _ => unreachable!("unexpected subcommand"),
1322 },
1323 _ => unreachable!("unexpected command"),
1324 }
1325 }
1326
1327 #[test]
1328 fn graph_entities_cli_accepts_sort_by_created_at_asc() {
1329 let parsed = Cli::try_parse_from([
1330 "sqlite-graphrag",
1331 "graph",
1332 "entities",
1333 "--sort-by",
1334 "created-at",
1335 ])
1336 .expect("graph entities --sort-by created-at must parse");
1337 match parsed.command {
1338 Some(Commands::Graph(args)) => match args.subcommand {
1339 Some(GraphSubcommand::Entities(e)) => {
1340 assert!(matches!(e.sort_by, Some(EntitySortField::CreatedAt)));
1341 assert!(matches!(e.order, SortOrder::Asc));
1342 }
1343 _ => unreachable!("unexpected subcommand"),
1344 },
1345 _ => unreachable!("unexpected command"),
1346 }
1347 }
1348
1349 #[test]
1350 fn graph_entities_cli_defaults_to_no_sort_by() {
1351 let parsed = Cli::try_parse_from(["sqlite-graphrag", "graph", "entities"])
1352 .expect("graph entities must parse without sort flags");
1353 match parsed.command {
1354 Some(Commands::Graph(args)) => match args.subcommand {
1355 Some(GraphSubcommand::Entities(e)) => {
1356 assert!(e.sort_by.is_none(), "sort_by must default to None");
1357 assert!(
1358 matches!(e.order, SortOrder::Asc),
1359 "order must default to Asc"
1360 );
1361 }
1362 _ => unreachable!("unexpected subcommand"),
1363 },
1364 _ => unreachable!("unexpected command"),
1365 }
1366 }
1367
1368 fn setup_migrated_db() -> (tempfile::TempDir, rusqlite::Connection) {
1373 crate::storage::connection::register_vec_extension();
1374 let tmp = tempfile::TempDir::new().expect("tempdir");
1375 let db_path = tmp.path().join("test.db");
1376 let mut conn = rusqlite::Connection::open(&db_path).expect("open");
1377 crate::migrations::runner().run(&mut conn).expect("migrate");
1378 (tmp, conn)
1379 }
1380
1381 fn insert_entity_with_degree(
1382 conn: &rusqlite::Connection,
1383 ns: &str,
1384 name: &str,
1385 degree: i64,
1386 ) -> i64 {
1387 conn.execute(
1388 "INSERT INTO entities (namespace, name, type, degree) VALUES (?1, ?2, 'concept', ?3)",
1389 rusqlite::params![ns, name, degree],
1390 )
1391 .expect("insert entity");
1392 conn.last_insert_rowid()
1393 }
1394
1395 fn insert_edge(conn: &rusqlite::Connection, ns: &str, source: i64, target: i64) {
1396 conn.execute(
1397 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight) \
1398 VALUES (?1, ?2, ?3, 'uses', 0.5)",
1399 rusqlite::params![ns, source, target],
1400 )
1401 .expect("insert edge");
1402 }
1403
1404 #[test]
1405 fn recompute_degrees_reconciles_updated_zeroed_and_unchanged() {
1406 let (_tmp, mut conn) = setup_migrated_db();
1407 let a = insert_entity_with_degree(&conn, "global", "ent-a", 0);
1410 let b = insert_entity_with_degree(&conn, "global", "ent-b", 5);
1411 let c = insert_entity_with_degree(&conn, "global", "ent-c", 7);
1412 let d = insert_entity_with_degree(&conn, "global", "ent-d", 0);
1413 insert_edge(&conn, "global", a, b);
1414
1415 let summary = recompute_degrees(&mut conn, Some("global"), false).expect("recompute");
1416 assert_eq!(
1417 summary,
1418 RecomputeDegreeSummary {
1419 total: 4,
1420 updated: 2,
1421 zeroed: 1,
1422 unchanged: 1,
1423 }
1424 );
1425
1426 let degree_of = |id: i64| -> i64 {
1427 conn.query_row(
1428 "SELECT degree FROM entities WHERE id = ?1",
1429 rusqlite::params![id],
1430 |r| r.get(0),
1431 )
1432 .unwrap()
1433 };
1434 assert_eq!(degree_of(a), 1);
1435 assert_eq!(degree_of(b), 1);
1436 assert_eq!(degree_of(c), 0, "entidade sem arestas deve ser zerada");
1437 assert_eq!(degree_of(d), 0);
1438
1439 let second = recompute_degrees(&mut conn, Some("global"), false).expect("recompute 2");
1441 assert_eq!(second.updated + second.zeroed, 0);
1442 assert_eq!(second.unchanged, 4);
1443 }
1444
1445 #[test]
1446 fn recompute_degrees_dry_run_reports_without_writing() {
1447 let (_tmp, mut conn) = setup_migrated_db();
1448 let a = insert_entity_with_degree(&conn, "global", "ent-a", 9);
1449
1450 let summary = recompute_degrees(&mut conn, Some("global"), true).expect("dry-run");
1451 assert_eq!(summary.zeroed, 1, "divergência reportada no dry-run");
1452
1453 let stored: i64 = conn
1454 .query_row(
1455 "SELECT degree FROM entities WHERE id = ?1",
1456 rusqlite::params![a],
1457 |r| r.get(0),
1458 )
1459 .unwrap();
1460 assert_eq!(stored, 9, "dry-run não pode escrever");
1461 }
1462
1463 #[test]
1464 fn recompute_degrees_scopes_by_namespace_and_none_covers_all() {
1465 let (_tmp, mut conn) = setup_migrated_db();
1466 insert_entity_with_degree(&conn, "ns1", "ent-ns1", 3);
1467 insert_entity_with_degree(&conn, "ns2", "ent-ns2", 4);
1468
1469 let only_ns1 = recompute_degrees(&mut conn, Some("ns1"), false).expect("ns1");
1470 assert_eq!(only_ns1.total, 1);
1471
1472 let all = recompute_degrees(&mut conn, None, false).expect("all");
1474 assert_eq!(all.total, 2);
1475 assert_eq!(all.zeroed, 1, "só ns2 ainda divergia");
1476 assert_eq!(all.unchanged, 1);
1477 }
1478
1479 #[test]
1480 fn graph_recompute_degree_cli_parses_flags() {
1481 let parsed = Cli::try_parse_from([
1482 "sqlite-graphrag",
1483 "graph",
1484 "recompute-degree",
1485 "--dry-run",
1486 "--namespace",
1487 "project-x",
1488 ])
1489 .expect("recompute-degree must parse");
1490 match parsed.command {
1491 Some(Commands::Graph(args)) => match args.subcommand {
1492 Some(GraphSubcommand::RecomputeDegree(a)) => {
1493 assert!(a.dry_run);
1494 assert_eq!(a.namespace.as_deref(), Some("project-x"));
1495 }
1496 _ => unreachable!("unexpected subcommand"),
1497 },
1498 _ => unreachable!("unexpected command"),
1499 }
1500 }
1501}