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