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