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 `items`, `total_count`, `limit`, and `offset` fields.\n  \
136    Entity types are strings extracted by BERT 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    items: 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    for r in edges_raw {
279        let from = match id_to_name.get(&r.source_id) {
280            Some(n) => n.clone(),
281            None => continue,
282        };
283        let to = match id_to_name.get(&r.target_id) {
284            Some(n) => n.clone(),
285            None => continue,
286        };
287        edges.push(EdgeOut {
288            from,
289            to,
290            relation: r.relation,
291            weight: r.weight,
292        });
293    }
294
295    let effective_format = if json {
296        GraphExportFormat::Json
297    } else {
298        format
299    };
300
301    let rendered = match effective_format {
302        GraphExportFormat::Json => render_json(&GraphSnapshot {
303            nodes,
304            edges,
305            elapsed_ms: inicio.elapsed().as_millis() as u64,
306        })?,
307        GraphExportFormat::Dot => render_dot(&nodes, &edges),
308        GraphExportFormat::Mermaid => render_mermaid(&nodes, &edges),
309    };
310
311    if let Some(path) = output_path.filter(|_| !json) {
312        fs::write(path, &rendered)?;
313        output::emit_progress(&format!("wrote {}", path.display()));
314    } else {
315        output::emit_text(&rendered);
316    }
317
318    Ok(())
319}
320
321fn run_traverse(args: GraphTraverseArgs) -> Result<(), AppError> {
322    let inicio = Instant::now();
323    let _ = args.format;
324    let paths = AppPaths::resolve(args.db.as_deref())?;
325
326    crate::storage::connection::ensure_db_ready(&paths)?;
327
328    let conn = open_ro(&paths.db)?;
329    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
330
331    let from_id = entities::find_entity_id(&conn, &namespace, &args.from)?
332        .ok_or_else(|| AppError::NotFound(format!("entity '{}' not found", args.from)))?;
333
334    let all_rels = entities::list_relationships_by_namespace(&conn, Some(&namespace))?;
335    let all_entities = entities::list_entities(&conn, Some(&namespace))?;
336    let id_to_name: HashMap<i64, String> = all_entities
337        .iter()
338        .map(|e| (e.id, e.name.clone()))
339        .collect();
340
341    let mut hops: Vec<TraverseHop> = Vec::new();
342    let mut visited: std::collections::HashSet<i64> = std::collections::HashSet::new();
343    let mut frontier: Vec<(i64, u32)> = vec![(from_id, 0)];
344
345    while let Some((current_id, current_depth)) = frontier.pop() {
346        if current_depth >= args.depth || visited.contains(&current_id) {
347            continue;
348        }
349        visited.insert(current_id);
350
351        for rel in &all_rels {
352            if rel.source_id == current_id {
353                if let Some(target_name) = id_to_name.get(&rel.target_id) {
354                    hops.push(TraverseHop {
355                        entity: target_name.clone(),
356                        relation: rel.relation.clone(),
357                        direction: "outbound".to_string(),
358                        weight: rel.weight,
359                        depth: current_depth + 1,
360                    });
361                    frontier.push((rel.target_id, current_depth + 1));
362                }
363            } else if rel.target_id == current_id {
364                if let Some(source_name) = id_to_name.get(&rel.source_id) {
365                    hops.push(TraverseHop {
366                        entity: source_name.clone(),
367                        relation: rel.relation.clone(),
368                        direction: "inbound".to_string(),
369                        weight: rel.weight,
370                        depth: current_depth + 1,
371                    });
372                    frontier.push((rel.source_id, current_depth + 1));
373                }
374            }
375        }
376    }
377
378    output::emit_json(&GraphTraverseResponse {
379        from: args.from,
380        namespace,
381        depth: args.depth,
382        hops,
383        elapsed_ms: inicio.elapsed().as_millis() as u64,
384    })?;
385
386    Ok(())
387}
388
389fn run_stats(args: GraphStatsArgs) -> Result<(), AppError> {
390    let inicio = Instant::now();
391    let paths = AppPaths::resolve(args.db.as_deref())?;
392
393    crate::storage::connection::ensure_db_ready(&paths)?;
394
395    let conn = open_ro(&paths.db)?;
396    let ns = args.namespace.as_deref();
397
398    let node_count: i64 = if let Some(n) = ns {
399        conn.query_row(
400            "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
401            rusqlite::params![n],
402            |r| r.get(0),
403        )?
404    } else {
405        conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?
406    };
407
408    let edge_count: i64 = if let Some(n) = ns {
409        conn.query_row(
410            "SELECT COUNT(*) FROM relationships r
411             JOIN entities s ON s.id = r.source_id
412             WHERE s.namespace = ?1",
413            rusqlite::params![n],
414            |r| r.get(0),
415        )?
416    } else {
417        conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?
418    };
419
420    let max_degree: i64 = if let Some(n) = ns {
421        conn.query_row(
422            "SELECT COALESCE(MAX(degree), 0) FROM entities WHERE namespace = ?1",
423            rusqlite::params![n],
424            |r| r.get(0),
425        )?
426    } else {
427        conn.query_row("SELECT COALESCE(MAX(degree), 0) FROM entities", [], |r| {
428            r.get(0)
429        })?
430    };
431
432    // avg_degree = 2 * edge_count / node_count (each edge contributes 2 to total degree sum).
433    let avg_degree = if node_count > 0 {
434        2.0 * (edge_count as f64) / (node_count as f64)
435    } else {
436        0.0
437    };
438
439    let resp = GraphStatsResponse {
440        namespace: args.namespace,
441        node_count,
442        edge_count,
443        avg_degree,
444        max_degree,
445        elapsed_ms: inicio.elapsed().as_millis() as u64,
446    };
447
448    let effective_format = if args.json {
449        GraphStatsFormat::Json
450    } else {
451        args.format
452    };
453
454    match effective_format {
455        GraphStatsFormat::Json => output::emit_json(&resp)?,
456        GraphStatsFormat::Text => {
457            output::emit_text(&format!(
458                "nodes={} edges={} avg_degree={:.2} max_degree={} namespace={}",
459                resp.node_count,
460                resp.edge_count,
461                resp.avg_degree,
462                resp.max_degree,
463                resp.namespace.as_deref().unwrap_or("all"),
464            ));
465        }
466    }
467
468    Ok(())
469}
470
471fn run_entities(args: GraphEntitiesArgs) -> Result<(), AppError> {
472    let inicio = Instant::now();
473    let paths = AppPaths::resolve(args.db.as_deref())?;
474
475    crate::storage::connection::ensure_db_ready(&paths)?;
476
477    let conn = open_ro(&paths.db)?;
478
479    let row_to_item = |r: &rusqlite::Row<'_>| -> rusqlite::Result<EntityItem> {
480        let ts: i64 = r.get(4)?;
481        let created_at = chrono::DateTime::from_timestamp(ts, 0)
482            .unwrap_or_default()
483            .format("%Y-%m-%dT%H:%M:%SZ")
484            .to_string();
485        Ok(EntityItem {
486            id: r.get(0)?,
487            name: r.get(1)?,
488            entity_type: r.get(2)?,
489            namespace: r.get(3)?,
490            created_at,
491        })
492    };
493
494    let limit_i = args.limit as i64;
495    let offset_i = args.offset as i64;
496
497    let (total_count, items) = match (
498        args.namespace.as_deref(),
499        args.entity_type.map(|et| et.as_str()),
500    ) {
501        (Some(ns), Some(et)) => {
502            let count: i64 = conn.query_row(
503                "SELECT COUNT(*) FROM entities WHERE namespace = ?1 AND type = ?2",
504                rusqlite::params![ns, et],
505                |r| r.get(0),
506            )?;
507            let mut stmt = conn.prepare(
508                "SELECT id, name, COALESCE(type, ''), namespace, created_at FROM entities
509                 WHERE namespace = ?1 AND type = ?2
510                 ORDER BY name ASC LIMIT ?3 OFFSET ?4",
511            )?;
512            let rows = stmt
513                .query_map(rusqlite::params![ns, et, limit_i, offset_i], row_to_item)?
514                .collect::<rusqlite::Result<Vec<_>>>()?;
515            (count, rows)
516        }
517        (Some(ns), None) => {
518            let count: i64 = conn.query_row(
519                "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
520                rusqlite::params![ns],
521                |r| r.get(0),
522            )?;
523            let mut stmt = conn.prepare(
524                "SELECT id, name, COALESCE(type, ''), namespace, created_at FROM entities
525                 WHERE namespace = ?1
526                 ORDER BY name ASC LIMIT ?2 OFFSET ?3",
527            )?;
528            let rows = stmt
529                .query_map(rusqlite::params![ns, limit_i, offset_i], row_to_item)?
530                .collect::<rusqlite::Result<Vec<_>>>()?;
531            (count, rows)
532        }
533        (None, Some(et)) => {
534            let count: i64 = conn.query_row(
535                "SELECT COUNT(*) FROM entities WHERE type = ?1",
536                rusqlite::params![et],
537                |r| r.get(0),
538            )?;
539            let mut stmt = conn.prepare(
540                "SELECT id, name, COALESCE(type, ''), namespace, created_at FROM entities
541                 WHERE type = ?1
542                 ORDER BY name ASC LIMIT ?2 OFFSET ?3",
543            )?;
544            let rows = stmt
545                .query_map(rusqlite::params![et, limit_i, offset_i], row_to_item)?
546                .collect::<rusqlite::Result<Vec<_>>>()?;
547            (count, rows)
548        }
549        (None, None) => {
550            let count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
551            let mut stmt = conn.prepare(
552                "SELECT id, name, COALESCE(type, ''), namespace, created_at FROM entities
553                 ORDER BY name ASC LIMIT ?1 OFFSET ?2",
554            )?;
555            let rows = stmt
556                .query_map(rusqlite::params![limit_i, offset_i], row_to_item)?
557                .collect::<rusqlite::Result<Vec<_>>>()?;
558            (count, rows)
559        }
560    };
561
562    output::emit_json(&GraphEntitiesResponse {
563        items,
564        total_count,
565        limit: args.limit,
566        offset: args.offset,
567        namespace: args.namespace,
568        elapsed_ms: inicio.elapsed().as_millis() as u64,
569    })
570}
571
572fn render_json(snapshot: &GraphSnapshot) -> Result<String, AppError> {
573    Ok(serde_json::to_string_pretty(snapshot)?)
574}
575
576fn sanitize_dot_id(raw: &str) -> String {
577    raw.chars()
578        .map(|c| {
579            if c.is_ascii_alphanumeric() || c == '_' {
580                c
581            } else {
582                '_'
583            }
584        })
585        .collect()
586}
587
588fn render_dot(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
589    let mut out = String::new();
590    out.push_str("digraph sqlite-graphrag {\n");
591    for node in nodes {
592        let node_id = sanitize_dot_id(&node.name);
593        let escaped = node.name.replace('"', "\\\"");
594        out.push_str(&format!("  {node_id} [label=\"{escaped}\"];\n"));
595    }
596    for edge in edges {
597        let from = sanitize_dot_id(&edge.from);
598        let to = sanitize_dot_id(&edge.to);
599        let label = edge.relation.replace('"', "\\\"");
600        out.push_str(&format!("  {from} -> {to} [label=\"{label}\"];\n"));
601    }
602    out.push_str("}\n");
603    out
604}
605
606fn sanitize_mermaid_id(raw: &str) -> String {
607    raw.chars()
608        .map(|c| {
609            if c.is_ascii_alphanumeric() || c == '_' {
610                c
611            } else {
612                '_'
613            }
614        })
615        .collect()
616}
617
618fn render_mermaid(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
619    let mut out = String::new();
620    out.push_str("graph LR\n");
621    for node in nodes {
622        let id = sanitize_mermaid_id(&node.name);
623        let escaped = node.name.replace('"', "\\\"");
624        out.push_str(&format!("  {id}[\"{escaped}\"]\n"));
625    }
626    for edge in edges {
627        let from = sanitize_mermaid_id(&edge.from);
628        let to = sanitize_mermaid_id(&edge.to);
629        let label = edge.relation.replace('|', "\\|");
630        out.push_str(&format!("  {from} -->|{label}| {to}\n"));
631    }
632    out
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use crate::cli::{Cli, Commands};
639    use clap::Parser;
640
641    fn make_node(kind: &str) -> NodeOut {
642        NodeOut {
643            id: 1,
644            name: "test-entity".to_string(),
645            namespace: "default".to_string(),
646            kind: kind.to_string(),
647            r#type: kind.to_string(),
648        }
649    }
650
651    #[test]
652    fn node_out_type_duplicates_kind() {
653        let node = make_node("agent");
654        let json = serde_json::to_value(&node).expect("serialization must work");
655        assert_eq!(json["kind"], json["type"]);
656        assert_eq!(json["kind"], "agent");
657        assert_eq!(json["type"], "agent");
658    }
659
660    #[test]
661    fn node_out_serializes_all_fields() {
662        let node = make_node("document");
663        let json = serde_json::to_value(&node).expect("serialization must work");
664        assert!(json.get("id").is_some());
665        assert!(json.get("name").is_some());
666        assert!(json.get("namespace").is_some());
667        assert!(json.get("kind").is_some());
668        assert!(json.get("type").is_some());
669    }
670
671    #[test]
672    fn graph_snapshot_serializes_nodes_with_type() {
673        let node = make_node("concept");
674        let snapshot = GraphSnapshot {
675            nodes: vec![node],
676            edges: vec![],
677            elapsed_ms: 0,
678        };
679        let json_str = render_json(&snapshot).expect("rendering must work");
680        let json: serde_json::Value = serde_json::from_str(&json_str).expect("valid json");
681        let first_node = &json["nodes"][0];
682        assert_eq!(first_node["kind"], first_node["type"]);
683        assert_eq!(first_node["type"], "concept");
684    }
685
686    #[test]
687    fn graph_traverse_response_serializes_correctly() {
688        let resp = GraphTraverseResponse {
689            from: "entity-a".to_string(),
690            namespace: "global".to_string(),
691            depth: 2,
692            hops: vec![TraverseHop {
693                entity: "entity-b".to_string(),
694                relation: "uses".to_string(),
695                direction: "outbound".to_string(),
696                weight: 1.0,
697                depth: 1,
698            }],
699            elapsed_ms: 5,
700        };
701        let json = serde_json::to_value(&resp).unwrap();
702        assert_eq!(json["from"], "entity-a");
703        assert_eq!(json["depth"], 2);
704        assert!(json["hops"].is_array());
705        assert_eq!(json["hops"][0]["direction"], "outbound");
706    }
707
708    #[test]
709    fn graph_stats_response_serializes_correctly() {
710        let resp = GraphStatsResponse {
711            namespace: Some("global".to_string()),
712            node_count: 10,
713            edge_count: 15,
714            avg_degree: 3.0,
715            max_degree: 7,
716            elapsed_ms: 2,
717        };
718        let json = serde_json::to_value(&resp).unwrap();
719        assert_eq!(json["node_count"], 10);
720        assert_eq!(json["edge_count"], 15);
721        assert_eq!(json["avg_degree"], 3.0);
722        assert_eq!(json["max_degree"], 7);
723    }
724
725    fn compute_avg_degree(node_count: i64, edge_count: i64) -> f64 {
726        if node_count > 0 {
727            2.0 * (edge_count as f64) / (node_count as f64)
728        } else {
729            0.0
730        }
731    }
732
733    #[test]
734    fn avg_degree_is_zero_when_no_nodes() {
735        assert_eq!(compute_avg_degree(0, 0), 0.0);
736    }
737
738    #[test]
739    fn avg_degree_is_zero_when_nodes_but_no_edges() {
740        // Reproduces L1 bug: previously returned 1.0 instead of 0.0.
741        assert_eq!(compute_avg_degree(2, 0), 0.0);
742    }
743
744    #[test]
745    fn avg_degree_is_two_when_triangle() {
746        // 3 nodes, 3 edges: 2 * 3 / 3 = 2.0
747        assert_eq!(compute_avg_degree(3, 3), 2.0);
748    }
749
750    #[test]
751    fn graph_entities_response_serializes_required_fields() {
752        let resp = GraphEntitiesResponse {
753            items: vec![EntityItem {
754                id: 1,
755                name: "claude-code".to_string(),
756                entity_type: "agent".to_string(),
757                namespace: "global".to_string(),
758                created_at: "2026-01-01T00:00:00Z".to_string(),
759            }],
760            total_count: 1,
761            limit: 50,
762            offset: 0,
763            namespace: Some("global".to_string()),
764            elapsed_ms: 3,
765        };
766        let json = serde_json::to_value(&resp).unwrap();
767        assert!(json["items"].is_array());
768        assert_eq!(json["items"][0]["name"], "claude-code");
769        assert_eq!(json["items"][0]["entity_type"], "agent");
770        assert_eq!(json["total_count"], 1);
771        assert_eq!(json["limit"], 50);
772        assert_eq!(json["offset"], 0);
773        assert_eq!(json["namespace"], "global");
774    }
775
776    #[test]
777    fn entity_item_serializes_all_fields() {
778        let item = EntityItem {
779            id: 42,
780            name: "test-entity".to_string(),
781            entity_type: "concept".to_string(),
782            namespace: "project-a".to_string(),
783            created_at: "2026-04-19T12:00:00Z".to_string(),
784        };
785        let json = serde_json::to_value(&item).unwrap();
786        assert_eq!(json["id"], 42);
787        assert_eq!(json["name"], "test-entity");
788        assert_eq!(json["entity_type"], "concept");
789        assert_eq!(json["namespace"], "project-a");
790        assert_eq!(json["created_at"], "2026-04-19T12:00:00Z");
791    }
792
793    #[test]
794    fn entity_item_entity_type_is_never_null() {
795        // P2-C: entity_type must never be null, even when DB column is empty.
796        let item = EntityItem {
797            id: 1,
798            name: "sem-tipo".to_string(),
799            entity_type: String::new(),
800            namespace: "ns".to_string(),
801            created_at: "2026-01-01T00:00:00Z".to_string(),
802        };
803        let json = serde_json::to_value(&item).unwrap();
804        assert!(
805            !json["entity_type"].is_null(),
806            "entity_type must not be null"
807        );
808        assert!(json["entity_type"].is_string());
809    }
810
811    #[test]
812    fn graph_traverse_cli_rejects_format_dot() {
813        let parsed = Cli::try_parse_from([
814            "sqlite-graphrag",
815            "graph",
816            "traverse",
817            "--from",
818            "AuthDecision",
819            "--format",
820            "dot",
821        ]);
822        assert!(parsed.is_err(), "graph traverse must reject format=dot");
823    }
824
825    #[test]
826    fn graph_stats_cli_accepts_format_text() {
827        let parsed = Cli::try_parse_from(["sqlite-graphrag", "graph", "stats", "--format", "text"])
828            .expect("graph stats --format text must be accepted");
829
830        match parsed.command {
831            Commands::Graph(args) => match args.subcommand {
832                Some(GraphSubcommand::Stats(stats)) => {
833                    assert_eq!(stats.format, GraphStatsFormat::Text);
834                }
835                _ => unreachable!("unexpected subcommand"),
836            },
837            _ => unreachable!("unexpected command"),
838        }
839    }
840
841    #[test]
842    fn graph_stats_cli_rejects_format_mermaid() {
843        let parsed =
844            Cli::try_parse_from(["sqlite-graphrag", "graph", "stats", "--format", "mermaid"]);
845        assert!(parsed.is_err(), "graph stats must reject format=mermaid");
846    }
847}