Skip to main content

sqlite_graphrag/commands/
graph_export.rs

1//! Handler for the `graph-export` CLI subcommand.
2
3use 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/// Optional nested subcommands. When absent, the default behavior exports
17/// the full entity snapshot for backward compatibility.
18#[derive(clap::Subcommand)]
19pub enum GraphSubcommand {
20    /// Traverse relationships from a starting entity using BFS
21    Traverse(GraphTraverseArgs),
22    /// Show graph statistics (node/edge counts, degree distribution)
23    Stats(GraphStatsArgs),
24    /// List entities stored in the graph with optional filters
25    Entities(GraphEntitiesArgs),
26    /// Reconcile the cached `degree` column with the real edge counts (P3)
27    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    /// Optional subcommand; without one, export the full entity snapshot.
58    #[command(subcommand)]
59    pub subcommand: Option<GraphSubcommand>,
60    /// Filter by namespace. Defaults to all namespaces.
61    #[arg(long)]
62    pub namespace: Option<String>,
63    /// Snapshot output format.
64    #[arg(long, value_enum, default_value = "json")]
65    pub format: GraphExportFormat,
66    /// File path to write output instead of stdout.
67    #[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    /// Root entity name for the traversal.
88    #[arg(long)]
89    pub from: String,
90    /// Maximum traversal depth.
91    #[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    /// Output format for the stats response.
118    #[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/// Field to sort entities by in `graph entities`.
127#[derive(Debug, Clone, Copy, clap::ValueEnum)]
128pub enum EntitySortField {
129    /// Sort alphabetically by entity name.
130    Name,
131    /// Sort by degree (total number of relationships). Use `--order desc` for most-connected-first.
132    Degree,
133    /// Sort by entity creation timestamp.
134    CreatedAt,
135}
136
137/// Sort direction for `graph entities`.
138#[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    /// Filter by entity type (one of the 13 canonical types).
166    #[arg(long, value_enum)]
167    pub entity_type: Option<EntityType>,
168    /// Maximum number of results to return.
169    #[arg(long, default_value_t = crate::constants::K_GRAPH_ENTITIES_DEFAULT_LIMIT)]
170    pub limit: usize,
171    /// Number of results to skip for pagination.
172    #[arg(long, default_value_t = 0usize)]
173    pub offset: usize,
174    /// Sort entities by this field. When omitted, the default order is by name ascending.
175    #[arg(long, value_enum, help = "Sort entities by field")]
176    pub sort_by: Option<EntitySortField>,
177    /// Sort direction: `asc` (default) or `desc`.
178    #[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    /// Namespace to reconcile. Omit to reconcile ALL namespaces.
204    #[arg(long)]
205    pub namespace: Option<String>,
206    /// Report divergences without writing anything.
207    #[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    /// Deprecated alias of `type` kept for backward-compat with pre-v1.0.35 clients.
221    /// New consumers MUST read `type` instead. Will be removed in a future major release.
222    kind: String,
223    /// Canonical entity classification (organization, concept, person, etc.).
224    /// Mirrors `kind` while the deprecation window is active.
225    #[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    /// Total number of relationships (inbound + outbound) for this entity.
281    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/// v1.1.1 (P3): summary of one degree-reconciliation pass.
345///
346/// `total` is every entity scanned; `updated` diverged to a non-zero real
347/// degree; `zeroed` diverged to zero (no live edges); `unchanged` already
348/// matched. `updated + zeroed + unchanged == total`.
349#[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
368/// v1.1.1 (P3): recomputes `entities.degree` from the real `relationships`
369/// rows inside one IMMEDIATE transaction.
370///
371/// Uses the SAME per-entity semantics as the canonical
372/// [`entities::recalculate_degree`] helper (`COUNT(*) WHERE source_id = id OR
373/// target_id = id` — a self-loop counts once), so a reconciled graph is
374/// byte-identical to one maintained exclusively through link/merge/delete.
375/// With `dry_run` the transaction never writes and is rolled back on drop.
376fn 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        // Dropping the transaction rolls back; nothing was written anyway.
429        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(&current_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    // avg_degree = 2 * edge_count / node_count (each edge contributes 2 to total degree sum).
670    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
708/// Builds the `ORDER BY` clause fragment from sort options.
709///
710/// Returns a static SQL fragment such as `ORDER BY e.name ASC`.
711fn build_order_by(sort_by: Option<EntitySortField>, order: SortOrder) -> &'static str {
712    // The combinations are enumerated as static strings to avoid
713    // format!() allocations in the hot path and satisfy the borrow checker
714    // when the string is used inside conn.prepare().
715    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        // Fallback: None/Desc → sort by name desc (consistent with dir variable).
725        (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
835/// Streams the graph as NDJSON: one object per node, one per edge, then a summary.
836///
837/// Each line is flushed immediately so consumers can process incrementally.
838/// When `output_path` is `Some`, lines are written to the file; otherwise to stdout.
839fn 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    // Truncate the output file once before starting (avoids re-opening with append for every line).
893    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        // Reproduces L1 bug: previously returned 1.0 instead of 0.0.
1106        assert_eq!(compute_avg_degree(2, 0), 0.0);
1107    }
1108
1109    #[test]
1110    fn avg_degree_is_two_when_triangle() {
1111        // 3 nodes, 3 edges: 2 * 3 / 3 = 2.0
1112        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        // P2-C: entity_type must never be null, even when DB column is empty.
1165        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    // -----------------------------------------------------------------------
1342    // v1.1.1 (P3): graph recompute-degree — reconciliação do cache `degree`
1343    // -----------------------------------------------------------------------
1344
1345    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        // a—b conectadas mas com degree armazenado errado (0 e 5); c órfã com
1381        // degree fantasma 7; d já correta com degree 0.
1382        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        // Segunda passada converge: tudo unchanged.
1413        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        // ns2 permanece divergente até uma passada sem namespace (todas).
1446        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}