1use crate::utils::project_root::effective_path_opt;
2use clap::{ArgAction, Parser, Subcommand, ValueEnum};
3use clap_complete::Shell;
4use serde::Deserialize;
5use std::path::PathBuf;
6
7#[derive(Debug, Parser)]
8#[command(
9 name = "rust-relations-explorer",
10 version,
11 about = "Rust Knowledge Graph System",
12 long_about = "Parse Rust projects into a knowledge graph and run queries. File discovery respects .gitignore and .ignore with parent traversal. Global git excludes are disabled for determinism. Use --no-ignore to bypass ignore rules.",
13 arg_required_else_help = true,
14 propagate_version = true
15)]
16pub struct Cli {
17 #[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
19 pub verbose: u8,
20 #[arg(short = 'q', long, default_value_t = false)]
22 pub quiet: bool,
23 #[command(subcommand)]
24 pub command: Commands,
25}
26
27#[derive(Clone, Debug, Copy, ValueEnum, PartialEq, Eq)]
28pub enum OutputFormat {
29 Text,
30 Json,
31}
32
33#[derive(Clone, Debug, Copy, ValueEnum)]
34pub enum Direction {
35 Callers,
36 Callees,
37}
38
39#[derive(Clone, Debug, Copy, ValueEnum)]
40pub enum CentralityMetricArg {
41 In,
42 Out,
43 Total,
44}
45
46#[derive(Clone, Debug, Copy, ValueEnum, PartialEq, Eq)]
47pub enum OnOffArg {
48 On,
49 Off,
50}
51
52#[derive(Clone, Debug, Copy, ValueEnum, PartialEq, Eq)]
53pub enum DotThemeArg {
54 Light,
55 Dark,
56}
57
58#[derive(Clone, Debug, Copy, ValueEnum, PartialEq, Eq)]
59pub enum DotRankDirArg {
60 #[value(alias = "LR")]
61 LR,
62 #[value(alias = "TB")]
63 TB,
64}
65
66#[derive(Clone, Debug, Copy, ValueEnum, PartialEq, Eq)]
67pub enum DotSplinesArg {
68 Curved,
69 Ortho,
70 Polyline,
71}
72
73#[derive(Debug, Deserialize)]
77struct ConfigFile {
78 #[serde(default)]
79 dot: DotConfig,
80 #[serde(default)]
81 svg: SvgConfig,
82 #[serde(default)]
83 query: QueryConfig,
84}
85
86#[derive(Debug, Default, Deserialize)]
87struct DotConfig {
88 clusters: Option<bool>,
89 legend: Option<bool>,
90 theme: Option<String>, rankdir: Option<String>, splines: Option<String>, rounded: Option<bool>,
94}
95
96#[derive(Debug, Default, Deserialize)]
97struct SvgConfig {
98 interactive: Option<bool>,
99}
100
101#[derive(Debug, Default, Deserialize)]
102struct QueryConfig {
103 default_format: Option<String>, }
105
106fn load_config(path: &str) -> Option<ConfigFile> {
107 let content = std::fs::read_to_string(path).ok()?;
108 toml::from_str::<ConfigFile>(&content).ok()
109}
110
111fn on_off(b: bool) -> OnOffArg {
112 if b {
113 OnOffArg::On
114 } else {
115 OnOffArg::Off
116 }
117}
118fn parse_theme(s: &str) -> Option<DotThemeArg> {
119 match s.to_ascii_lowercase().as_str() {
120 "light" => Some(DotThemeArg::Light),
121 "dark" => Some(DotThemeArg::Dark),
122 _ => None,
123 }
124}
125fn parse_rankdir(s: &str) -> Option<DotRankDirArg> {
126 match s.to_ascii_uppercase().as_str() {
127 "LR" => Some(DotRankDirArg::LR),
128 "TB" => Some(DotRankDirArg::TB),
129 _ => None,
130 }
131}
132fn parse_splines(s: &str) -> Option<DotSplinesArg> {
133 match s.to_ascii_lowercase().as_str() {
134 "curved" => Some(DotSplinesArg::Curved),
135 "ortho" => Some(DotSplinesArg::Ortho),
136 "polyline" => Some(DotSplinesArg::Polyline),
137 _ => None,
138 }
139}
140fn parse_format(s: &str) -> Option<OutputFormat> {
141 match s.to_ascii_lowercase().as_str() {
142 "text" => Some(OutputFormat::Text),
143 "json" => Some(OutputFormat::Json),
144 _ => None,
145 }
146}
147
148#[derive(Clone, Debug, Copy, ValueEnum)]
149pub enum ItemKindArg {
150 Module,
151 Function,
152 Struct,
153 Enum,
154 Trait,
155 Impl,
156 Const,
157 Static,
158 Type,
159 Macro,
160}
161
162#[derive(Debug, Subcommand)]
163pub enum Commands {
164 Build {
166 #[arg(short, long, env = "RRE_PATH")]
168 path: Option<PathBuf>,
169 #[arg(short = 'c', long)]
171 config: Option<String>,
172 #[arg(
174 short='I', long,
175 visible_aliases=["no-gitignore","all","ni"],
176 default_value_t = false,
177 help = "Include files even if matched by .gitignore/.ignore. Global git excludes are always disabled for determinism."
178 )]
179 no_ignore: bool,
180 #[arg(long, default_value_t = false)]
182 no_cache: bool,
183 #[arg(long, default_value_t = false)]
185 rebuild: bool,
186 #[arg(long)]
188 json: Option<String>,
189 #[arg(long)]
191 dot: Option<String>,
192 #[arg(long)]
194 svg: Option<String>,
195 #[arg(long, value_enum, default_value_t = OnOffArg::On)]
197 dot_clusters: OnOffArg,
198 #[arg(long, value_enum, default_value_t = OnOffArg::On)]
200 dot_legend: OnOffArg,
201 #[arg(long, value_enum, default_value_t = DotThemeArg::Light)]
203 dot_theme: DotThemeArg,
204 #[arg(long, value_enum, default_value_t = DotRankDirArg::LR)]
206 dot_rankdir: DotRankDirArg,
207 #[arg(long, value_enum, default_value_t = DotSplinesArg::Curved)]
209 dot_splines: DotSplinesArg,
210 #[arg(long, value_enum, default_value_t = OnOffArg::On)]
212 dot_rounded: OnOffArg,
213 #[arg(long, value_enum, default_value_t = OnOffArg::On)]
215 svg_interactive: OnOffArg,
216 #[arg(long)]
218 save: Option<String>,
219 },
220 Query {
222 #[command(subcommand)]
223 query: QueryCommands,
224 },
225 Completions {
227 #[arg(value_enum)]
229 shell: Shell,
230 },
231}
232
233#[derive(Debug, Subcommand)]
234pub enum QueryCommands {
235 ConnectedFiles {
237 #[arg(short, long, env = "RRE_PATH")]
239 path: Option<PathBuf>,
240 #[arg(short = 'c', long)]
242 config: Option<String>,
243 #[arg(
245 short='I', long,
246 visible_aliases=["no-gitignore","all","ni"],
247 default_value_t = false,
248 help = "Include files even if matched by .gitignore/.ignore. Global git excludes are always disabled for determinism."
249 )]
250 no_ignore: bool,
251 #[arg(value_name = "FILE")]
253 file_pos: Option<String>,
254 #[arg(long)]
256 file: Option<String>,
257 #[arg(long, env = "RRE_GRAPH")]
259 graph: Option<String>,
260 #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Text, env = "RRE_FORMAT")]
262 format: OutputFormat,
263 #[arg(long, default_value_t = 0)]
265 offset: usize,
266 #[arg(long)]
268 limit: Option<usize>,
269 },
270 ItemInfo {
272 #[arg(short, long, env = "RRE_PATH")]
274 path: Option<PathBuf>,
275 #[arg(short = 'c', long)]
277 config: Option<String>,
278 #[arg(short='I', long, visible_aliases=["no-gitignore","all","ni"], default_value_t = false)]
280 no_ignore: bool,
281 #[arg(long, value_name = "ID")]
283 item_id: Option<String>,
284 #[arg(short = 'n', long, value_name = "NAME")]
286 name: Option<String>,
287 #[arg(short = 'k', long, value_enum)]
289 kind: Option<ItemKindArg>,
290 #[arg(long, env = "RRE_GRAPH")]
292 graph: Option<String>,
293 #[arg(long, default_value_t = true)]
295 show_code: bool,
296 #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Text, env = "RRE_FORMAT")]
298 format: OutputFormat,
299 },
300 FunctionUsage {
302 #[arg(short, long, env = "RRE_PATH")]
304 path: Option<PathBuf>,
305 #[arg(short = 'c', long)]
307 config: Option<String>,
308 #[arg(short='I', long, visible_aliases=["no-gitignore","all","ni"], default_value_t = false)]
310 no_ignore: bool,
311 #[arg(long)]
313 function: String,
314 #[arg(long, value_enum, default_value_t = Direction::Callers)]
316 direction: Direction,
317 #[arg(long, env = "RRE_GRAPH")]
319 graph: Option<String>,
320 #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Text, env = "RRE_FORMAT")]
322 format: OutputFormat,
323 #[arg(long, default_value_t = 0)]
325 offset: usize,
326 #[arg(long)]
328 limit: Option<usize>,
329 },
330 Cycles {
332 #[arg(short, long, env = "RRE_PATH")]
334 path: Option<PathBuf>,
335 #[arg(short = 'c', long)]
337 config: Option<String>,
338 #[arg(short='I', long, visible_aliases=["no-gitignore","all","ni"], default_value_t = false)]
340 no_ignore: bool,
341 #[arg(long, env = "RRE_GRAPH")]
343 graph: Option<String>,
344 #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Text, env = "RRE_FORMAT")]
346 format: OutputFormat,
347 #[arg(long, default_value_t = 0)]
349 offset: usize,
350 #[arg(long)]
352 limit: Option<usize>,
353 },
354 Path {
356 #[arg(short = 'p', long, env = "RRE_PATH")]
358 path: Option<PathBuf>,
359 #[arg(short = 'c', long)]
361 config: Option<String>,
362 #[arg(short='I', long, visible_aliases=["no-gitignore","all","ni"], default_value_t = false)]
364 no_ignore: bool,
365 #[arg(long)]
367 from: String,
368 #[arg(long)]
370 to: String,
371 #[arg(long, env = "RRE_GRAPH")]
373 graph: Option<String>,
374 #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Text, env = "RRE_FORMAT")]
376 format: OutputFormat,
377 #[arg(long, default_value_t = 0)]
379 offset: usize,
380 #[arg(long)]
382 limit: Option<usize>,
383 },
384 Hubs {
386 #[arg(short, long, env = "RRE_PATH")]
388 path: Option<PathBuf>,
389 #[arg(short = 'c', long)]
391 config: Option<String>,
392 #[arg(short='I', long, visible_aliases=["no-gitignore","all","ni"], default_value_t = false)]
394 no_ignore: bool,
395 #[arg(long, env = "RRE_GRAPH")]
397 graph: Option<String>,
398 #[arg(long, value_enum, default_value_t = CentralityMetricArg::Total)]
400 metric: CentralityMetricArg,
401 #[arg(short = 't', long, default_value_t = 10)]
403 top: usize,
404 #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Text, env = "RRE_FORMAT")]
406 format: OutputFormat,
407 #[arg(long, default_value_t = 0)]
409 offset: usize,
410 #[arg(long)]
412 limit: Option<usize>,
413 },
414 ModuleCentrality {
416 #[arg(short, long, env = "RRE_PATH")]
418 path: Option<PathBuf>,
419 #[arg(short = 'c', long)]
421 config: Option<String>,
422 #[arg(short='I', long, visible_aliases=["no-gitignore","all","ni"], default_value_t = false)]
424 no_ignore: bool,
425 #[arg(long, env = "RRE_GRAPH")]
427 graph: Option<String>,
428 #[arg(long, value_enum, default_value_t = CentralityMetricArg::Total)]
430 metric: CentralityMetricArg,
431 #[arg(short = 't', long, default_value_t = 10)]
433 top: usize,
434 #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Text, env = "RRE_FORMAT")]
436 format: OutputFormat,
437 #[arg(long, default_value_t = 0)]
439 offset: usize,
440 #[arg(long)]
442 limit: Option<usize>,
443 },
444 TraitImpls {
446 #[arg(short, long, env = "RRE_PATH")]
448 path: Option<PathBuf>,
449 #[arg(short = 'c', long)]
451 config: Option<String>,
452 #[arg(short='I', long, visible_aliases=["no-gitignore","all","ni"], default_value_t = false)]
454 no_ignore: bool,
455 #[arg(long, value_name = "NAME")]
457 r#trait: String,
458 #[arg(long, env = "RRE_GRAPH")]
460 graph: Option<String>,
461 #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Text, env = "RRE_FORMAT")]
463 format: OutputFormat,
464 #[arg(long, default_value_t = 0)]
466 offset: usize,
467 #[arg(long)]
469 limit: Option<usize>,
470 },
471 UnreferencedItems {
473 #[arg(short, long, env = "RRE_PATH")]
475 path: Option<PathBuf>,
476 #[arg(short = 'c', long)]
478 config: Option<String>,
479 #[arg(short='I', long, visible_aliases=["no-gitignore","all","ni"], default_value_t = false)]
481 no_ignore: bool,
482 #[arg(long, default_value_t = false)]
484 include_public: bool,
485 #[arg(long)]
487 exclude: Option<String>,
488 #[arg(long, env = "RRE_GRAPH")]
490 graph: Option<String>,
491 #[arg(short='f', long, value_enum, default_value_t = OutputFormat::Text, env = "RRE_FORMAT")]
493 format: OutputFormat,
494 #[arg(long, default_value_t = 0)]
496 offset: usize,
497 #[arg(long)]
499 limit: Option<usize>,
500 },
501}
502
503#[must_use]
504pub fn parse() -> Cli {
505 let mut cli = Cli::parse();
506 match &mut cli.command {
507 Commands::Build {
508 path,
509 config,
510 no_ignore: _,
511 no_cache: _,
512 rebuild: _,
513 json: _,
514 dot: _,
515 svg: _,
516 dot_clusters,
517 dot_legend,
518 dot_theme,
519 dot_rankdir,
520 dot_splines,
521 dot_rounded,
522 svg_interactive,
523 save: _,
524 } => {
525 let p = effective_path_opt(path.as_deref());
526 *path = Some(p);
527 if let Some(cfg_path) = config.as_deref() {
529 if let Some(cfg) = load_config(cfg_path) {
530 if let Some(b) = cfg.dot.clusters {
531 if *dot_clusters == OnOffArg::On {
532 *dot_clusters = on_off(b);
533 }
534 }
535 if let Some(b) = cfg.dot.legend {
536 if *dot_legend == OnOffArg::On {
537 *dot_legend = on_off(b);
538 }
539 }
540 if let Some(s) = cfg.dot.theme.as_deref().and_then(parse_theme) {
541 if *dot_theme == DotThemeArg::Light {
542 *dot_theme = s;
543 }
544 }
545 if let Some(s) = cfg.dot.rankdir.as_deref().and_then(parse_rankdir) {
546 if *dot_rankdir == DotRankDirArg::LR {
547 *dot_rankdir = s;
548 }
549 }
550 if let Some(s) = cfg.dot.splines.as_deref().and_then(parse_splines) {
551 if *dot_splines == DotSplinesArg::Curved {
552 *dot_splines = s;
553 }
554 }
555 if let Some(b) = cfg.dot.rounded {
556 if *dot_rounded == OnOffArg::On {
557 *dot_rounded = on_off(b);
558 }
559 }
560 if let Some(b) = cfg.svg.interactive {
561 if *svg_interactive == OnOffArg::On {
562 *svg_interactive = on_off(b);
563 }
564 }
565 }
566 }
567 if cli.verbose > 0 && !cli.quiet {
568 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
569 }
570 }
571 Commands::Query { query } => match query {
572 QueryCommands::ConnectedFiles { path, file_pos, file, config, format, .. } => {
573 let p = effective_path_opt(path.as_deref());
574 *path = Some(p);
575 if let Some(cfg_path) = config.as_deref() {
576 if let Some(cfg) = load_config(cfg_path) {
577 if let Some(f) = cfg.query.default_format.as_deref().and_then(parse_format)
578 {
579 if *format == OutputFormat::Text {
580 *format = f;
581 }
582 }
583 }
584 }
585 if cli.verbose > 0 && !cli.quiet {
586 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
587 }
588 let merged = if file.is_none() { file_pos.clone() } else { file.clone() };
590 if let Some(f) = merged {
591 *file = Some(f);
592 }
593 }
594 QueryCommands::ItemInfo { path, config, format, .. } => {
595 let p = effective_path_opt(path.as_deref());
596 *path = Some(p);
597 if let Some(cfg_path) = config.as_deref() {
598 if let Some(cfg) = load_config(cfg_path) {
599 if let Some(f) = cfg.query.default_format.as_deref().and_then(parse_format)
600 {
601 if *format == OutputFormat::Text {
602 *format = f;
603 }
604 }
605 }
606 }
607 if cli.verbose > 0 && !cli.quiet {
608 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
609 }
610 }
611 QueryCommands::FunctionUsage { path, config, format, .. } => {
612 let p = effective_path_opt(path.as_deref());
613 *path = Some(p);
614 if let Some(cfg_path) = config.as_deref() {
615 if let Some(cfg) = load_config(cfg_path) {
616 if let Some(f) = cfg.query.default_format.as_deref().and_then(parse_format)
617 {
618 if *format == OutputFormat::Text {
619 *format = f;
620 }
621 }
622 }
623 }
624 if cli.verbose > 0 && !cli.quiet {
625 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
626 }
627 }
628 QueryCommands::Cycles { path, config, format, .. } => {
629 let p = effective_path_opt(path.as_deref());
630 *path = Some(p);
631 if let Some(cfg_path) = config.as_deref() {
632 if let Some(cfg) = load_config(cfg_path) {
633 if let Some(f) = cfg.query.default_format.as_deref().and_then(parse_format)
634 {
635 if *format == OutputFormat::Text {
636 *format = f;
637 }
638 }
639 }
640 }
641 if cli.verbose > 0 && !cli.quiet {
642 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
643 }
644 }
645 QueryCommands::Path { path, config, format, .. } => {
646 let p = effective_path_opt(path.as_deref());
647 *path = Some(p);
648 if let Some(cfg_path) = config.as_deref() {
649 if let Some(cfg) = load_config(cfg_path) {
650 if let Some(f) = cfg.query.default_format.as_deref().and_then(parse_format)
651 {
652 if *format == OutputFormat::Text {
653 *format = f;
654 }
655 }
656 }
657 }
658 if cli.verbose > 0 && !cli.quiet {
659 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
660 }
661 }
662 QueryCommands::Hubs { path, config, format, .. } => {
663 let p = effective_path_opt(path.as_deref());
664 *path = Some(p);
665 if let Some(cfg_path) = config.as_deref() {
666 if let Some(cfg) = load_config(cfg_path) {
667 if let Some(f) = cfg.query.default_format.as_deref().and_then(parse_format)
668 {
669 if *format == OutputFormat::Text {
670 *format = f;
671 }
672 }
673 }
674 }
675 if cli.verbose > 0 && !cli.quiet {
676 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
677 }
678 }
679 QueryCommands::ModuleCentrality { path, config, format, .. } => {
680 let p = effective_path_opt(path.as_deref());
681 *path = Some(p);
682 if let Some(cfg_path) = config.as_deref() {
683 if let Some(cfg) = load_config(cfg_path) {
684 if let Some(f) = cfg.query.default_format.as_deref().and_then(parse_format)
685 {
686 if *format == OutputFormat::Text {
687 *format = f;
688 }
689 }
690 }
691 }
692 if cli.verbose > 0 && !cli.quiet {
693 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
694 }
695 }
696 QueryCommands::TraitImpls { path, config, format, .. } => {
697 let p = effective_path_opt(path.as_deref());
698 *path = Some(p);
699 if let Some(cfg_path) = config.as_deref() {
700 if let Some(cfg) = load_config(cfg_path) {
701 if let Some(f) = cfg.query.default_format.as_deref().and_then(parse_format)
702 {
703 if *format == OutputFormat::Text {
704 *format = f;
705 }
706 }
707 }
708 }
709 if cli.verbose > 0 && !cli.quiet {
710 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
711 }
712 }
713 QueryCommands::UnreferencedItems { path, config, format, .. } => {
714 let p = effective_path_opt(path.as_deref());
715 *path = Some(p);
716 if let Some(cfg_path) = config.as_deref() {
717 if let Some(cfg) = load_config(cfg_path) {
718 if let Some(f) = cfg.query.default_format.as_deref().and_then(parse_format)
719 {
720 if *format == OutputFormat::Text {
721 *format = f;
722 }
723 }
724 }
725 }
726 if cli.verbose > 0 && !cli.quiet {
727 eprintln!("Using project root: {}", path.as_ref().unwrap().display());
728 }
729 }
730 },
731 Commands::Completions { .. } => {
732 }
734 }
735 cli
736}