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.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(¤t_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}