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