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