Skip to main content

sqlite_graphrag/commands/
graph_export.rs

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