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