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#[derive(clap::Subcommand)]
17pub enum GraphSubcommand {
18 Traverse(GraphTraverseArgs),
20 Stats(GraphStatsArgs),
22 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 #[command(subcommand)]
41 pub subcommand: Option<GraphSubcommand>,
42 #[arg(long)]
44 pub namespace: Option<String>,
45 #[arg(long, value_enum, default_value = "json")]
47 pub format: GraphExportFormat,
48 #[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 #[arg(long)]
61 pub from: String,
62 #[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 #[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 #[arg(long)]
94 pub entity_type: Option<String>,
95 #[arg(long, default_value_t = crate::constants::K_GRAPH_ENTITIES_DEFAULT_LIMIT)]
97 pub limit: usize,
98 #[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 #[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(¤t_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}