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