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