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, hide = true, 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, hide = true, 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, hide = true, 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, hide = true, 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.output.as_deref(),
187        ),
188        Some(GraphSubcommand::Traverse(a)) => run_traverse(a),
189        Some(GraphSubcommand::Stats(a)) => run_stats(a),
190        Some(GraphSubcommand::Entities(a)) => run_entities(a),
191    }
192}
193
194fn run_entities_snapshot(
195    db: Option<&str>,
196    namespace: Option<&str>,
197    format: GraphExportFormat,
198    output_path: Option<&std::path::Path>,
199) -> Result<(), AppError> {
200    let inicio = Instant::now();
201    let paths = AppPaths::resolve(db)?;
202
203    if !paths.db.exists() {
204        return Err(AppError::NotFound(erros::banco_nao_encontrado(
205            &paths.db.display().to_string(),
206        )));
207    }
208
209    let conn = open_ro(&paths.db)?;
210
211    let nodes_raw = entities::list_entities(&conn, namespace)?;
212    let edges_raw = entities::list_relationships_by_namespace(&conn, namespace)?;
213
214    let id_to_name: HashMap<i64, String> =
215        nodes_raw.iter().map(|n| (n.id, n.name.clone())).collect();
216
217    let nodes: Vec<NodeOut> = nodes_raw
218        .into_iter()
219        .map(|n| NodeOut {
220            id: n.id,
221            name: n.name,
222            namespace: n.namespace,
223            r#type: n.kind.clone(),
224            kind: n.kind,
225        })
226        .collect();
227
228    let mut edges: Vec<EdgeOut> = Vec::with_capacity(edges_raw.len());
229    for r in edges_raw {
230        let from = match id_to_name.get(&r.source_id) {
231            Some(n) => n.clone(),
232            None => continue,
233        };
234        let to = match id_to_name.get(&r.target_id) {
235            Some(n) => n.clone(),
236            None => continue,
237        };
238        edges.push(EdgeOut {
239            from,
240            to,
241            relation: r.relation,
242            weight: r.weight,
243        });
244    }
245
246    let rendered = match format {
247        GraphExportFormat::Json => render_json(&GraphSnapshot {
248            nodes,
249            edges,
250            elapsed_ms: inicio.elapsed().as_millis() as u64,
251        })?,
252        GraphExportFormat::Dot => render_dot(&nodes, &edges),
253        GraphExportFormat::Mermaid => render_mermaid(&nodes, &edges),
254    };
255
256    if let Some(path) = output_path {
257        fs::write(path, &rendered)?;
258        output::emit_progress(&format!("wrote {}", path.display()));
259    } else {
260        output::emit_text(&rendered);
261    }
262
263    Ok(())
264}
265
266fn run_traverse(args: GraphTraverseArgs) -> Result<(), AppError> {
267    let inicio = Instant::now();
268    let _ = args.format;
269    let paths = AppPaths::resolve(args.db.as_deref())?;
270
271    if !paths.db.exists() {
272        return Err(AppError::NotFound(erros::banco_nao_encontrado(
273            &paths.db.display().to_string(),
274        )));
275    }
276
277    let conn = open_ro(&paths.db)?;
278    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
279
280    let from_id = entities::find_entity_id(&conn, &namespace, &args.from)?
281        .ok_or_else(|| AppError::NotFound(format!("entity '{}' not found", args.from)))?;
282
283    let all_rels = entities::list_relationships_by_namespace(&conn, Some(&namespace))?;
284    let all_entities = entities::list_entities(&conn, Some(&namespace))?;
285    let id_to_name: HashMap<i64, String> = all_entities
286        .iter()
287        .map(|e| (e.id, e.name.clone()))
288        .collect();
289
290    let mut hops: Vec<TraverseHop> = Vec::new();
291    let mut visited: std::collections::HashSet<i64> = std::collections::HashSet::new();
292    let mut frontier: Vec<(i64, u32)> = vec![(from_id, 0)];
293
294    while let Some((current_id, current_depth)) = frontier.pop() {
295        if current_depth >= args.depth || visited.contains(&current_id) {
296            continue;
297        }
298        visited.insert(current_id);
299
300        for rel in &all_rels {
301            if rel.source_id == current_id {
302                if let Some(target_name) = id_to_name.get(&rel.target_id) {
303                    hops.push(TraverseHop {
304                        entity: target_name.clone(),
305                        relation: rel.relation.clone(),
306                        direction: "outbound".to_string(),
307                        weight: rel.weight,
308                        depth: current_depth + 1,
309                    });
310                    frontier.push((rel.target_id, current_depth + 1));
311                }
312            } else if rel.target_id == current_id {
313                if let Some(source_name) = id_to_name.get(&rel.source_id) {
314                    hops.push(TraverseHop {
315                        entity: source_name.clone(),
316                        relation: rel.relation.clone(),
317                        direction: "inbound".to_string(),
318                        weight: rel.weight,
319                        depth: current_depth + 1,
320                    });
321                    frontier.push((rel.source_id, current_depth + 1));
322                }
323            }
324        }
325    }
326
327    output::emit_json(&GraphTraverseResponse {
328        from: args.from,
329        namespace,
330        depth: args.depth,
331        hops,
332        elapsed_ms: inicio.elapsed().as_millis() as u64,
333    })?;
334
335    Ok(())
336}
337
338fn run_stats(args: GraphStatsArgs) -> Result<(), AppError> {
339    let inicio = Instant::now();
340    let paths = AppPaths::resolve(args.db.as_deref())?;
341
342    if !paths.db.exists() {
343        return Err(AppError::NotFound(erros::banco_nao_encontrado(
344            &paths.db.display().to_string(),
345        )));
346    }
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    match args.format {
397        GraphStatsFormat::Json => output::emit_json(&resp)?,
398        GraphStatsFormat::Text => {
399            output::emit_text(&format!(
400                "nodes={} edges={} avg_degree={:.2} max_degree={} namespace={}",
401                resp.node_count,
402                resp.edge_count,
403                resp.avg_degree,
404                resp.max_degree,
405                resp.namespace.as_deref().unwrap_or("all"),
406            ));
407        }
408    }
409
410    Ok(())
411}
412
413fn run_entities(args: GraphEntitiesArgs) -> Result<(), AppError> {
414    let inicio = Instant::now();
415    let paths = AppPaths::resolve(args.db.as_deref())?;
416
417    if !paths.db.exists() {
418        return Err(AppError::NotFound(erros::banco_nao_encontrado(
419            &paths.db.display().to_string(),
420        )));
421    }
422
423    let conn = open_ro(&paths.db)?;
424
425    let row_to_item = |r: &rusqlite::Row<'_>| -> rusqlite::Result<EntityItem> {
426        let ts: i64 = r.get(4)?;
427        let created_at = chrono::DateTime::from_timestamp(ts, 0)
428            .unwrap_or_default()
429            .format("%Y-%m-%dT%H:%M:%SZ")
430            .to_string();
431        Ok(EntityItem {
432            id: r.get(0)?,
433            name: r.get(1)?,
434            entity_type: r.get(2)?,
435            namespace: r.get(3)?,
436            created_at,
437        })
438    };
439
440    let limit_i = args.limit as i64;
441    let offset_i = args.offset as i64;
442
443    let (total_count, items) = match (args.namespace.as_deref(), args.entity_type.as_deref()) {
444        (Some(ns), Some(et)) => {
445            let count: i64 = conn.query_row(
446                "SELECT COUNT(*) FROM entities WHERE namespace = ?1 AND type = ?2",
447                rusqlite::params![ns, et],
448                |r| r.get(0),
449            )?;
450            let mut stmt = conn.prepare(
451                "SELECT id, name, type, namespace, created_at FROM entities
452                 WHERE namespace = ?1 AND type = ?2
453                 ORDER BY name ASC LIMIT ?3 OFFSET ?4",
454            )?;
455            let rows = stmt
456                .query_map(rusqlite::params![ns, et, limit_i, offset_i], row_to_item)?
457                .collect::<rusqlite::Result<Vec<_>>>()?;
458            (count, rows)
459        }
460        (Some(ns), None) => {
461            let count: i64 = conn.query_row(
462                "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
463                rusqlite::params![ns],
464                |r| r.get(0),
465            )?;
466            let mut stmt = conn.prepare(
467                "SELECT id, name, type, namespace, created_at FROM entities
468                 WHERE namespace = ?1
469                 ORDER BY name ASC LIMIT ?2 OFFSET ?3",
470            )?;
471            let rows = stmt
472                .query_map(rusqlite::params![ns, limit_i, offset_i], row_to_item)?
473                .collect::<rusqlite::Result<Vec<_>>>()?;
474            (count, rows)
475        }
476        (None, Some(et)) => {
477            let count: i64 = conn.query_row(
478                "SELECT COUNT(*) FROM entities WHERE type = ?1",
479                rusqlite::params![et],
480                |r| r.get(0),
481            )?;
482            let mut stmt = conn.prepare(
483                "SELECT id, name, type, namespace, created_at FROM entities
484                 WHERE type = ?1
485                 ORDER BY name ASC LIMIT ?2 OFFSET ?3",
486            )?;
487            let rows = stmt
488                .query_map(rusqlite::params![et, limit_i, offset_i], row_to_item)?
489                .collect::<rusqlite::Result<Vec<_>>>()?;
490            (count, rows)
491        }
492        (None, None) => {
493            let count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
494            let mut stmt = conn.prepare(
495                "SELECT id, name, type, namespace, created_at FROM entities
496                 ORDER BY name ASC LIMIT ?1 OFFSET ?2",
497            )?;
498            let rows = stmt
499                .query_map(rusqlite::params![limit_i, offset_i], row_to_item)?
500                .collect::<rusqlite::Result<Vec<_>>>()?;
501            (count, rows)
502        }
503    };
504
505    output::emit_json(&GraphEntitiesResponse {
506        items,
507        total_count,
508        limit: args.limit,
509        offset: args.offset,
510        namespace: args.namespace,
511        elapsed_ms: inicio.elapsed().as_millis() as u64,
512    })
513}
514
515fn render_json(snapshot: &GraphSnapshot) -> Result<String, AppError> {
516    Ok(serde_json::to_string_pretty(snapshot)?)
517}
518
519fn sanitize_dot_id(raw: &str) -> String {
520    raw.chars()
521        .map(|c| {
522            if c.is_ascii_alphanumeric() || c == '_' {
523                c
524            } else {
525                '_'
526            }
527        })
528        .collect()
529}
530
531fn render_dot(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
532    let mut out = String::new();
533    out.push_str("digraph sqlite-graphrag {\n");
534    for node in nodes {
535        let node_id = sanitize_dot_id(&node.name);
536        let escaped = node.name.replace('"', "\\\"");
537        out.push_str(&format!("  {node_id} [label=\"{escaped}\"];\n"));
538    }
539    for edge in edges {
540        let from = sanitize_dot_id(&edge.from);
541        let to = sanitize_dot_id(&edge.to);
542        let label = edge.relation.replace('"', "\\\"");
543        out.push_str(&format!("  {from} -> {to} [label=\"{label}\"];\n"));
544    }
545    out.push_str("}\n");
546    out
547}
548
549fn sanitize_mermaid_id(raw: &str) -> String {
550    raw.chars()
551        .map(|c| {
552            if c.is_ascii_alphanumeric() || c == '_' {
553                c
554            } else {
555                '_'
556            }
557        })
558        .collect()
559}
560
561fn render_mermaid(nodes: &[NodeOut], edges: &[EdgeOut]) -> String {
562    let mut out = String::new();
563    out.push_str("graph LR\n");
564    for node in nodes {
565        let id = sanitize_mermaid_id(&node.name);
566        let escaped = node.name.replace('"', "\\\"");
567        out.push_str(&format!("  {id}[\"{escaped}\"]\n"));
568    }
569    for edge in edges {
570        let from = sanitize_mermaid_id(&edge.from);
571        let to = sanitize_mermaid_id(&edge.to);
572        let label = edge.relation.replace('|', "\\|");
573        out.push_str(&format!("  {from} -->|{label}| {to}\n"));
574    }
575    out
576}
577
578#[cfg(test)]
579mod testes {
580    use super::*;
581    use crate::cli::{Cli, Commands};
582    use clap::Parser;
583
584    fn cria_node(kind: &str) -> NodeOut {
585        NodeOut {
586            id: 1,
587            name: "entidade-teste".to_string(),
588            namespace: "default".to_string(),
589            kind: kind.to_string(),
590            r#type: kind.to_string(),
591        }
592    }
593
594    #[test]
595    fn node_out_type_duplica_kind() {
596        let node = cria_node("agent");
597        let json = serde_json::to_value(&node).expect("serialização deve funcionar");
598        assert_eq!(json["kind"], json["type"]);
599        assert_eq!(json["kind"], "agent");
600        assert_eq!(json["type"], "agent");
601    }
602
603    #[test]
604    fn node_out_serializa_todos_campos() {
605        let node = cria_node("document");
606        let json = serde_json::to_value(&node).expect("serialização deve funcionar");
607        assert!(json.get("id").is_some());
608        assert!(json.get("name").is_some());
609        assert!(json.get("namespace").is_some());
610        assert!(json.get("kind").is_some());
611        assert!(json.get("type").is_some());
612    }
613
614    #[test]
615    fn graph_snapshot_serializa_nodes_com_type() {
616        let node = cria_node("concept");
617        let snapshot = GraphSnapshot {
618            nodes: vec![node],
619            edges: vec![],
620            elapsed_ms: 0,
621        };
622        let json_str = render_json(&snapshot).expect("renderização deve funcionar");
623        let json: serde_json::Value = serde_json::from_str(&json_str).expect("json válido");
624        let primeiro_node = &json["nodes"][0];
625        assert_eq!(primeiro_node["kind"], primeiro_node["type"]);
626        assert_eq!(primeiro_node["type"], "concept");
627    }
628
629    #[test]
630    fn graph_traverse_response_serializa_corretamente() {
631        let resp = GraphTraverseResponse {
632            from: "entity-a".to_string(),
633            namespace: "global".to_string(),
634            depth: 2,
635            hops: vec![TraverseHop {
636                entity: "entity-b".to_string(),
637                relation: "uses".to_string(),
638                direction: "outbound".to_string(),
639                weight: 1.0,
640                depth: 1,
641            }],
642            elapsed_ms: 5,
643        };
644        let json = serde_json::to_value(&resp).unwrap();
645        assert_eq!(json["from"], "entity-a");
646        assert_eq!(json["depth"], 2);
647        assert!(json["hops"].is_array());
648        assert_eq!(json["hops"][0]["direction"], "outbound");
649    }
650
651    #[test]
652    fn graph_stats_response_serializa_corretamente() {
653        let resp = GraphStatsResponse {
654            namespace: Some("global".to_string()),
655            node_count: 10,
656            edge_count: 15,
657            avg_degree: 3.0,
658            max_degree: 7,
659            elapsed_ms: 2,
660        };
661        let json = serde_json::to_value(&resp).unwrap();
662        assert_eq!(json["node_count"], 10);
663        assert_eq!(json["edge_count"], 15);
664        assert_eq!(json["avg_degree"], 3.0);
665        assert_eq!(json["max_degree"], 7);
666    }
667
668    #[test]
669    fn graph_entities_response_serializa_campos_obrigatorios() {
670        let resp = GraphEntitiesResponse {
671            items: vec![EntityItem {
672                id: 1,
673                name: "claude-code".to_string(),
674                entity_type: "agent".to_string(),
675                namespace: "global".to_string(),
676                created_at: "2026-01-01T00:00:00Z".to_string(),
677            }],
678            total_count: 1,
679            limit: 50,
680            offset: 0,
681            namespace: Some("global".to_string()),
682            elapsed_ms: 3,
683        };
684        let json = serde_json::to_value(&resp).unwrap();
685        assert!(json["items"].is_array());
686        assert_eq!(json["items"][0]["name"], "claude-code");
687        assert_eq!(json["items"][0]["entity_type"], "agent");
688        assert_eq!(json["total_count"], 1);
689        assert_eq!(json["limit"], 50);
690        assert_eq!(json["offset"], 0);
691        assert_eq!(json["namespace"], "global");
692    }
693
694    #[test]
695    fn entity_item_serializa_todos_campos() {
696        let item = EntityItem {
697            id: 42,
698            name: "test-entity".to_string(),
699            entity_type: "concept".to_string(),
700            namespace: "project-a".to_string(),
701            created_at: "2026-04-19T12:00:00Z".to_string(),
702        };
703        let json = serde_json::to_value(&item).unwrap();
704        assert_eq!(json["id"], 42);
705        assert_eq!(json["name"], "test-entity");
706        assert_eq!(json["entity_type"], "concept");
707        assert_eq!(json["namespace"], "project-a");
708        assert_eq!(json["created_at"], "2026-04-19T12:00:00Z");
709    }
710
711    #[test]
712    fn graph_traverse_cli_rejeita_format_dot() {
713        let parsed = Cli::try_parse_from([
714            "sqlite-graphrag",
715            "graph",
716            "traverse",
717            "--from",
718            "AuthDecision",
719            "--format",
720            "dot",
721        ]);
722        assert!(parsed.is_err(), "graph traverse nao deve aceitar format=dot");
723    }
724
725    #[test]
726    fn graph_stats_cli_aceita_format_text() {
727        let parsed = Cli::try_parse_from([
728            "sqlite-graphrag",
729            "graph",
730            "stats",
731            "--format",
732            "text",
733        ])
734        .expect("graph stats --format text deve ser aceito");
735
736        match parsed.command {
737            Commands::Graph(args) => match args.subcommand {
738                Some(GraphSubcommand::Stats(stats)) => {
739                    assert_eq!(stats.format, GraphStatsFormat::Text);
740                }
741                _ => panic!("subcomando inesperado"),
742            },
743            _ => panic!("comando inesperado"),
744        }
745    }
746
747    #[test]
748    fn graph_stats_cli_rejeita_format_mermaid() {
749        let parsed = Cli::try_parse_from([
750            "sqlite-graphrag",
751            "graph",
752            "stats",
753            "--format",
754            "mermaid",
755        ]);
756        assert!(
757            parsed.is_err(),
758            "graph stats nao deve aceitar format=mermaid"
759        );
760    }
761}