1use std::path::PathBuf;
2
3use clap::builder::{PossibleValuesParser, TypedValueParser};
4use clap::{ArgAction, Args as ClapArgs, Parser, Subcommand, ValueEnum};
5
6use crate::extract::Predicate;
7use code_moniker_core::core::moniker::Moniker;
8use code_moniker_core::core::shape::Shape;
9use code_moniker_core::core::uri::{UriConfig, from_uri};
10
11const ASCII_LOGO: &str = "
12 ◆ code+moniker://
13 └─◆ lang:ts
14 └─◆ class:Util
15";
16
17#[derive(Debug, Parser)]
18#[command(name = "code-moniker", before_help = ASCII_LOGO, version)]
19pub struct Cli {
20 #[command(subcommand)]
21 pub command: Command,
22}
23
24#[derive(Debug, Subcommand)]
25pub enum Command {
26 #[command(about = "Extract a moniker graph from a file or directory.")]
27 Extract(ExtractArgs),
28 #[command(about = "Report extraction metrics for a file or directory.")]
29 Stats(StatsArgs),
30 #[command(about = "Lint a path against .code-moniker.toml rules.")]
31 Check(CheckArgs),
32 #[command(about = "Report git changes as symbol-level facts instead of lines.")]
33 Diff(DiffArgs),
34 #[command(about = "Create and toggle the project rules file.")]
35 Rules(RulesArgs),
36 #[cfg(feature = "tui")]
37 #[command(about = "Open a read-only terminal architecture explorer.")]
38 Ui(UiArgs),
39 #[cfg(feature = "mcp")]
40 #[command(about = "Start a local stateless MCP HTTP endpoint.")]
41 Mcp(McpArgs),
42 #[command(about = "Manage a code-moniker workspace daemon.")]
43 Daemon(DaemonArgs),
44 #[command(about = "Send a human-readable query DSL request to a workspace daemon.")]
45 Query(QueryArgs),
46 #[command(about = "Install live agent harness configuration.")]
47 Harness(HarnessArgs),
48 #[command(about = "List supported languages, or kinds of one.")]
49 Langs(LangsArgs),
50 #[command(about = "Show the shape vocabulary.")]
51 Shapes(ShapesArgs),
52 #[command(
53 about = "Extract declared dependencies from a build manifest (auto-detected by filename) or every manifest under a directory."
54 )]
55 Manifest(ManifestArgs),
56}
57
58#[derive(Debug, ClapArgs)]
59pub struct DaemonArgs {
60 #[command(subcommand)]
61 pub command: DaemonCommand,
62}
63
64#[derive(Debug, Subcommand)]
65pub enum DaemonCommand {
66 #[command(about = "Start a foreground daemon for a workspace root.")]
67 Start(DaemonRootArgs),
68 #[command(about = "Print daemon status for a workspace root.")]
69 Status(DaemonRootArgs),
70 #[command(about = "Ask a workspace daemon to shut down.")]
71 Stop(DaemonRootArgs),
72 #[command(about = "List daemon registry entries.")]
73 List,
74}
75
76#[derive(Debug, ClapArgs)]
77pub struct DaemonRootArgs {
78 #[arg(value_name = "WORKSPACE_ROOT", default_value = ".", num_args = 1..)]
79 pub workspace_roots: Vec<PathBuf>,
80
81 #[arg(
82 long,
83 value_name = "NAME",
84 help = "project component of the anchor moniker; defaults to '.'"
85 )]
86 pub project: Option<String>,
87
88 #[arg(
89 long,
90 value_name = "DIR",
91 env = "CODE_MONIKER_CACHE_DIR",
92 help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
93 )]
94 pub cache: Option<PathBuf>,
95
96 #[arg(
97 long,
98 value_enum,
99 default_value_t = LiveRefresh::OnDemand,
100 help = "live index policy attached to this daemon identity"
101 )]
102 pub live_refresh: LiveRefresh,
103}
104
105#[derive(Debug, ClapArgs)]
106pub struct QueryArgs {
107 #[arg(
108 short = 'r',
109 long = "root",
110 value_name = "WORKSPACE_ROOT",
111 action = ArgAction::Append,
112 help = "Workspace root attached to the daemon. Repeat for multi-root sessions."
113 )]
114 pub workspace_roots: Vec<PathBuf>,
115
116 #[arg(
117 long,
118 value_name = "NAME",
119 help = "project component of the daemon identity; defaults to '.'"
120 )]
121 pub project: Option<String>,
122
123 #[arg(
124 long,
125 value_name = "DIR",
126 env = "CODE_MONIKER_CACHE_DIR",
127 help = "on-disk cache directory attached to the daemon identity"
128 )]
129 pub cache: Option<PathBuf>,
130
131 #[arg(
132 long,
133 value_enum,
134 default_value_t = LiveRefresh::OnDemand,
135 help = "live index policy attached to the daemon identity"
136 )]
137 pub live_refresh: LiveRefresh,
138
139 #[arg(value_name = "QUERY")]
140 pub query: String,
141
142 #[arg(long, help = "Print the structured query response DTO as JSON.")]
143 pub json: bool,
144
145 #[arg(
146 long,
147 value_enum,
148 default_value_t = QueryConsistency::StaleOk,
149 help = "Snapshot freshness: stale-ok answers immediately, refresh-if-stale reindexes first, current fails on a stale workspace. A `consistency` field in the query text wins."
150 )]
151 pub(crate) consistency: QueryConsistency,
152}
153
154#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
155pub(crate) enum QueryConsistency {
156 StaleOk,
157 RefreshIfStale,
158 Current,
159}
160
161#[derive(Debug, ClapArgs)]
162pub struct HarnessArgs {
163 #[command(subcommand)]
164 pub command: HarnessCommand,
165}
166
167#[derive(Debug, ClapArgs)]
168pub struct RulesArgs {
169 #[command(subcommand)]
170 pub command: RulesCommand,
171}
172
173#[derive(Debug, Subcommand)]
174pub enum RulesCommand {
175 #[command(about = "Create a project .code-moniker.toml with detected aliases.")]
176 Init(RulesFileArgs),
177 #[command(about = "Disable embedded default rules in .code-moniker.toml.")]
178 Disable(RulesFileArgs),
179 #[command(about = "Enable embedded default rules in .code-moniker.toml.")]
180 Enable(RulesFileArgs),
181 #[command(about = "Show the effective compiled rules after defaults, overlay, and profile.")]
182 Show(RulesShowArgs),
183 #[command(about = "Print focused DSL documentation and copyable rule snippets.")]
184 Learn(RulesLearnArgs),
185 #[command(about = "Evaluate a rules TOML fragment against a source file or stdin.")]
186 Eval(RulesEvalArgs),
187}
188
189#[derive(Debug, ClapArgs)]
190pub struct RulesFileArgs {
191 #[arg(value_name = "ROOT", default_value = ".")]
192 pub root: PathBuf,
193
194 #[arg(
195 long,
196 value_name = "PATH",
197 default_value = ".code-moniker.toml",
198 help = "project rules file, resolved from ROOT unless absolute"
199 )]
200 pub rules: PathBuf,
201}
202
203#[derive(Debug, ClapArgs)]
204pub struct RulesShowArgs {
205 #[arg(value_name = "ROOT", default_value = ".")]
206 pub root: PathBuf,
207
208 #[arg(
209 long,
210 value_name = "PATH",
211 default_value = ".code-moniker.toml",
212 help = "project rules file, resolved from ROOT unless absolute"
213 )]
214 pub rules: PathBuf,
215
216 #[arg(
217 long,
218 value_enum,
219 default_value_t = RulesShowFormat::Text,
220 help = "output format"
221 )]
222 pub format: RulesShowFormat,
223
224 #[arg(
225 long = "default-rules",
226 value_enum,
227 help = "override whether embedded default rules are loaded before applying --rules"
228 )]
229 pub default_rules: Option<DefaultRules>,
230
231 #[arg(
232 long,
233 value_name = "NAME",
234 help = "filter rules through a named profile from .code-moniker.toml"
235 )]
236 pub profile: Option<String>,
237}
238
239#[derive(Debug, ClapArgs)]
240pub struct RulesLearnArgs {
241 #[arg(
242 value_name = "TOPIC",
243 help = "DSL topic to print, for example basics, paths, refs, collections, or metrics; omit to print every topic"
244 )]
245 pub topic: Option<String>,
246
247 #[arg(
248 long,
249 value_enum,
250 default_value_t = RulesLearnFormat::Text,
251 help = "output format"
252 )]
253 pub format: RulesLearnFormat,
254}
255
256#[derive(Debug, ClapArgs)]
257pub struct RulesEvalArgs {
258 #[arg(
259 long,
260 value_name = "PATH",
261 help = "rules TOML to evaluate: a real .code-moniker.toml fragment (rules, aliases, profiles)"
262 )]
263 pub rules: PathBuf,
264
265 #[arg(
266 long,
267 value_name = "TAG",
268 help = "language tag of the sample, for example rs, ts, py, go, java, cs, sql"
269 )]
270 pub lang: String,
271
272 #[arg(
273 long,
274 value_name = "NAME",
275 help = "filter rules through a named profile from the rules TOML"
276 )]
277 pub profile: Option<String>,
278
279 #[arg(
280 long = "default-rules",
281 value_enum,
282 help = "override whether embedded default rules are loaded before applying the rules TOML"
283 )]
284 pub default_rules: Option<DefaultRules>,
285
286 #[arg(
287 long,
288 value_enum,
289 default_value_t = RulesShowFormat::Text,
290 help = "output format"
291 )]
292 pub format: RulesShowFormat,
293
294 #[arg(
295 value_name = "FILE",
296 help = "sample source file to evaluate; reads stdin when omitted"
297 )]
298 pub source: Option<PathBuf>,
299}
300
301#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
302pub enum RulesShowFormat {
303 Text,
304 Json,
305}
306
307#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
308pub enum RulesLearnFormat {
309 Text,
310 Json,
311}
312
313#[derive(Debug, Subcommand)]
314pub enum HarnessCommand {
315 #[command(about = "Install a project-local Codex PostToolUse hook.")]
316 Codex(CodexHarnessArgs),
317 #[command(about = "Install a project-local Claude Code PostToolUse hook.")]
318 Claude(CodexHarnessArgs),
319 #[command(about = "Install a project-local Gemini CLI AfterTool hook.")]
320 Gemini(CodexHarnessArgs),
321 #[command(hide = true)]
322 ToolFiles(HarnessToolFilesArgs),
323}
324
325#[derive(Debug, ClapArgs)]
326pub struct CodexHarnessArgs {
327 #[arg(value_name = "ROOT", default_value = ".")]
328 pub root: PathBuf,
329
330 #[arg(
331 long,
332 value_name = "PATH",
333 default_value = ".code-moniker.toml",
334 help = "project rules file, resolved from ROOT unless absolute"
335 )]
336 pub rules: PathBuf,
337
338 #[arg(
339 long,
340 value_name = "NAME",
341 help = "optional profile that the live harness must run"
342 )]
343 pub profile: Option<String>,
344
345 #[arg(
346 long,
347 value_name = "PATH",
348 default_value = ".",
349 help = "project scope checked by the hook, resolved from ROOT"
350 )]
351 pub scope: PathBuf,
352
353 #[arg(
354 long,
355 value_name = "N",
356 default_value_t = 10,
357 help = "maximum violations printed by the generated live harness check"
358 )]
359 pub max_violations: usize,
360}
361
362#[derive(Debug, ClapArgs)]
363pub struct HarnessToolFilesArgs {
364 #[arg(value_enum)]
365 pub backend: HarnessToolBackend,
366
367 #[arg(value_name = "HOOK_INPUT_JSON")]
368 pub input: PathBuf,
369}
370
371#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
372pub enum HarnessToolBackend {
373 Codex,
374 Claude,
375 Gemini,
376}
377
378#[derive(Debug, ClapArgs)]
379pub struct ShapesArgs {
380 #[arg(long, value_enum, default_value_t = LangsFormat::Text)]
381 pub format: LangsFormat,
382}
383
384#[derive(Debug, ClapArgs)]
385pub struct LangsArgs {
386 #[arg(
387 value_name = "LANG",
388 help = "language tag (e.g. rs, ts, java, python, go, cs, sql); omit to list every tag"
389 )]
390 pub lang: Option<String>,
391
392 #[arg(long, value_enum, default_value_t = LangsFormat::Text)]
393 pub format: LangsFormat,
394}
395
396#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
397pub enum LangsFormat {
398 Text,
399 Json,
400}
401
402#[derive(Debug, ClapArgs)]
403pub struct CheckArgs {
404 #[arg(value_name = "PATH")]
405 pub path: PathBuf,
406
407 #[arg(
408 long,
409 value_name = "PATH",
410 default_value = ".code-moniker.toml",
411 help = "user TOML rules file; missing file is ignored"
412 )]
413 pub rules: PathBuf,
414
415 #[arg(
416 long = "rules-inline",
417 value_name = "TOML",
418 action = ArgAction::Append,
419 help = "inline TOML rules overlay; repeatable and merged after --rules fragments"
420 )]
421 pub rules_inline: Vec<String>,
422
423 #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
424 pub format: CheckFormat,
425
426 #[arg(
427 long = "default-rules",
428 value_enum,
429 help = "override whether embedded default rules are loaded before applying --rules"
430 )]
431 pub default_rules: Option<DefaultRules>,
432
433 #[arg(
434 long,
435 help = "print per-rule observability, including implication antecedent hit counts"
436 )]
437 pub report: bool,
438
439 #[arg(
440 long,
441 value_name = "NAME",
442 help = "filter rules through a named profile from .code-moniker.toml"
443 )]
444 pub profile: Option<String>,
445
446 #[arg(
447 long,
448 value_name = "N",
449 help = "maximum violations to print in text/codex-hook output; selects the largest failed rule group and shows the first N by path"
450 )]
451 pub max_violations: Option<usize>,
452
453 #[arg(
454 long = "file",
455 value_name = "PATH",
456 help = "when PATH is a directory, check only this touched file; repeatable"
457 )]
458 pub files: Vec<PathBuf>,
459
460 #[arg(
461 long,
462 value_name = "PATH",
463 hide = true,
464 help = "unstable: run an executable scenario document (`-` reads stdin); PATH is ignored"
465 )]
466 pub scenario: Option<PathBuf>,
467}
468
469#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
470pub enum CheckFormat {
471 Text,
472 Json,
473 CodexHook,
474}
475
476#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
477pub enum DefaultRules {
478 On,
479 Off,
480}
481
482#[derive(Debug, ClapArgs)]
483pub struct DiffArgs {
484 #[arg(
485 value_name = "RANGE_OR_PATH",
486 default_value = ".",
487 help = "a <base>..<head> / <base>...<head> revision range, or the workspace path"
488 )]
489 pub target: String,
490
491 #[arg(
492 value_name = "PATH",
493 help = "workspace path when the first argument is a range"
494 )]
495 pub path: Option<PathBuf>,
496
497 #[arg(
498 long,
499 value_name = "REV",
500 help = "base revision compared against the worktree (conflicts with a range)"
501 )]
502 pub base: Option<String>,
503
504 #[arg(long, value_enum, default_value_t = DiffFormat::Text)]
505 pub(crate) format: DiffFormat,
506
507 #[arg(
508 long,
509 help = "detail retargeted references instead of the collapsed per-file count"
510 )]
511 pub refs: bool,
512
513 #[arg(
514 long,
515 value_name = "NAME",
516 help = "project name for moniker anchors (defaults to the directory name)"
517 )]
518 pub project: Option<String>,
519}
520
521#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
522pub(crate) enum DiffFormat {
523 Text,
524 Json,
525}
526
527impl DefaultRules {
528 pub fn enabled(self) -> bool {
529 matches!(self, Self::On)
530 }
531}
532
533#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
534pub enum LiveRefresh {
535 #[default]
536 OnDemand,
537 Auto,
538}
539
540impl LiveRefresh {
541 pub fn is_on_demand(self) -> bool {
542 matches!(self, Self::OnDemand)
543 }
544}
545
546#[cfg(feature = "tui")]
547#[derive(Debug, ClapArgs)]
548pub struct UiArgs {
549 #[arg(value_name = "PATH", default_value = ".", num_args = 1..)]
550 pub paths: Vec<PathBuf>,
551
552 #[arg(
553 long,
554 value_name = "SCHEME",
555 help = "URI scheme; defaults to code+moniker://"
556 )]
557 pub scheme: Option<String>,
558
559 #[arg(
560 long,
561 value_name = "NAME",
562 help = "project component of the anchor moniker; defaults to '.'"
563 )]
564 pub project: Option<String>,
565
566 #[arg(
567 long,
568 value_name = "DIR",
569 env = "CODE_MONIKER_CACHE_DIR",
570 help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
571 )]
572 pub cache: Option<PathBuf>,
573
574 #[arg(
575 long,
576 value_name = "PATH",
577 default_value = ".code-moniker.toml",
578 help = "user TOML overlay for the check view"
579 )]
580 pub rules: PathBuf,
581
582 #[arg(
583 long,
584 value_name = "NAME",
585 help = "filter check rules through a named profile from .code-moniker.toml"
586 )]
587 pub profile: Option<String>,
588
589 #[arg(long, help = "show TUI component debug markers")]
590 pub debug: bool,
591
592 #[arg(
593 long,
594 value_enum,
595 default_value_t = LiveRefresh::OnDemand,
596 help = "live index policy: on-demand marks changes stale until a manual refresh; auto refreshes on every change"
597 )]
598 pub live_refresh: LiveRefresh,
599}
600
601#[cfg(feature = "mcp")]
602#[derive(Debug, ClapArgs)]
603pub struct McpArgs {
604 #[arg(value_name = "PATH", default_value = ".", num_args = 1..)]
605 pub paths: Vec<PathBuf>,
606
607 #[arg(
608 long,
609 value_name = "SCHEME",
610 help = "URI scheme; defaults to code+moniker://"
611 )]
612 pub scheme: Option<String>,
613
614 #[arg(
615 long,
616 value_name = "NAME",
617 help = "project component of the anchor moniker; defaults to '.'"
618 )]
619 pub project: Option<String>,
620
621 #[arg(
622 long,
623 value_name = "DIR",
624 env = "CODE_MONIKER_CACHE_DIR",
625 help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
626 )]
627 pub cache: Option<PathBuf>,
628
629 #[arg(
630 long,
631 alias = "mcp-port",
632 value_name = "PORT",
633 default_value_t = 3210,
634 help = "TCP port for the MCP endpoint"
635 )]
636 pub port: u16,
637
638 #[arg(
639 long,
640 value_enum,
641 default_value_t = LiveRefresh::OnDemand,
642 help = "live index policy: on-demand marks changes stale until the refresh tool runs; auto refreshes on every change"
643 )]
644 pub live_refresh: LiveRefresh,
645}
646
647#[derive(Debug, ClapArgs)]
648pub struct StatsArgs {
649 #[arg(value_name = "PATH", num_args = 1..)]
650 pub paths: Vec<PathBuf>,
651
652 #[arg(long, value_enum, default_value_t = StatsFormat::Tsv)]
653 pub format: StatsFormat,
654
655 #[arg(
656 long,
657 value_enum,
658 default_value_t = ColorChoice::Auto,
659 help = "ANSI color for --format tree: auto = on if stdout is a TTY (honors NO_COLOR / CLICOLOR / CLICOLOR_FORCE)"
660 )]
661 pub color: ColorChoice,
662
663 #[arg(
664 long,
665 value_enum,
666 default_value_t = Charset::Utf8,
667 help = "glyph set for --format tree"
668 )]
669 pub charset: Charset,
670
671 #[arg(
672 long,
673 value_name = "NAME",
674 help = "project component of the anchor moniker; defaults to '.'"
675 )]
676 pub project: Option<String>,
677
678 #[arg(
679 long,
680 value_name = "DIR",
681 env = "CODE_MONIKER_CACHE_DIR",
682 help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
683 )]
684 pub cache: Option<PathBuf>,
685}
686
687#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
688pub enum StatsFormat {
689 Tsv,
690 Json,
691 #[cfg(feature = "pretty")]
692 Tree,
693}
694
695#[derive(Debug, ClapArgs)]
696pub struct ExtractArgs {
697 #[arg(value_name = "PATH")]
698 pub path: PathBuf,
699
700 #[arg(
701 long = "where",
702 value_name = "OP URI",
703 help = "predicate `<op> <uri>` where op ∈ {=, <, <=, >, >=, @>, <@, ?=}; repeatable, AND-combined"
704 )]
705 pub where_: Vec<String>,
706
707 #[arg(
708 long,
709 value_name = "NAME",
710 value_delimiter = ',',
711 help = "concrete kind (e.g. class, fn, calls); repeatable or comma-separated; OR within --kind, AND with --shape. Discover values per language with `code-moniker langs <TAG>`."
712 )]
713 pub kind: Vec<String>,
714
715 #[arg(
716 long,
717 value_name = "REGEX",
718 help = "regex matched against the last moniker segment name; repeatable; OR within --name, AND with --kind/--shape/--where"
719 )]
720 pub name: Vec<String>,
721
722 #[arg(
723 long,
724 value_name = "SHAPE",
725 value_delimiter = ',',
726 value_parser = shape_parser(),
727 help = "kind family; repeatable or comma-separated; OR within --shape, AND with --kind. See `code-moniker shapes`."
728 )]
729 pub shape: Vec<Shape>,
730
731 #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
732 pub format: OutputFormat,
733
734 #[arg(
735 long,
736 visible_alias = "max-symbols",
737 value_name = "N",
738 default_value_t = 1000,
739 value_parser = parse_positive_usize,
740 conflicts_with_all = ["all", "count", "quiet"],
741 help = "maximum matched monikers to emit; use --all to bypass"
742 )]
743 pub limit: usize,
744
745 #[arg(
746 long,
747 value_name = "MONIKER_URI",
748 conflicts_with_all = ["all", "count", "quiet"],
749 help = "resume after this moniker URI"
750 )]
751 pub after: Option<String>,
752
753 #[arg(
754 long,
755 conflicts_with_all = ["count", "quiet"],
756 help = "emit all matched monikers, bypassing --limit"
757 )]
758 pub all: bool,
759
760 #[arg(
761 long,
762 value_enum,
763 default_value_t = MonikerFormat::Compact,
764 help = "moniker rendering for text and TSV output"
765 )]
766 pub moniker_format: MonikerFormat,
767
768 #[arg(
769 long,
770 short = 'c',
771 value_enum,
772 default_value_t = ColorChoice::Auto,
773 default_missing_value = "always",
774 num_args = 0..=1,
775 require_equals = false,
776 help = "ANSI color for --format text/tree: auto = on if stdout is a TTY (honors NO_COLOR / CLICOLOR / CLICOLOR_FORCE); -c forces color"
777 )]
778 pub color: ColorChoice,
779
780 #[arg(
781 long,
782 value_enum,
783 default_value_t = Charset::Utf8,
784 help = "glyph set for --format tree"
785 )]
786 pub charset: Charset,
787
788 #[arg(long, conflicts_with = "quiet", help = "print only the match count")]
789 pub count: bool,
790 #[arg(
791 long,
792 conflicts_with = "count",
793 help = "suppress output, exit code only"
794 )]
795 pub quiet: bool,
796
797 #[arg(long = "with-text", help = "include comment text (re-reads source)")]
798 pub with_text: bool,
799
800 #[arg(
801 long = "path",
802 value_name = "GLOB",
803 help = "only extract files whose path relative to PATH matches this glob; repeatable, OR-combined"
804 )]
805 pub path_filter: Vec<String>,
806
807 #[arg(
808 long,
809 value_name = "SCHEME",
810 help = "URI scheme; defaults to code+moniker://"
811 )]
812 pub scheme: Option<String>,
813
814 #[arg(
815 long,
816 value_name = "NAME",
817 help = "project component of the anchor moniker; defaults to '.'"
818 )]
819 pub project: Option<String>,
820
821 #[arg(
822 long,
823 value_name = "DIR",
824 env = "CODE_MONIKER_CACHE_DIR",
825 help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
826 )]
827 pub cache: Option<PathBuf>,
828}
829
830#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
831pub enum OutputFormat {
832 #[value(alias = "txt")]
833 Text,
834 Tsv,
835 Json,
836 #[cfg(feature = "pretty")]
837 Tree,
838}
839
840#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
841pub enum MonikerFormat {
842 Compact,
843 Uri,
844}
845
846#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
847pub enum ColorChoice {
848 Auto,
849 Always,
850 Never,
851}
852
853#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
854pub enum Charset {
855 Utf8,
856 Ascii,
857}
858
859#[derive(Copy, Clone, Debug, Eq, PartialEq)]
860pub enum OutputMode {
861 Default,
862 Count,
863 Quiet,
864}
865
866#[derive(Debug, ClapArgs)]
867pub struct ManifestArgs {
868 #[arg(
869 value_name = "PATH",
870 help = "manifest file (Cargo.toml / package.json / pom.xml / pyproject.toml / go.mod / *.csproj) or a directory to walk for any of those"
871 )]
872 pub path: PathBuf,
873
874 #[arg(long, value_enum, default_value_t = ManifestFormat::Tsv)]
875 pub format: ManifestFormat,
876
877 #[arg(long, conflicts_with = "quiet", help = "print only the row count")]
878 pub count: bool,
879 #[arg(
880 long,
881 conflicts_with = "count",
882 help = "suppress output, exit code only"
883 )]
884 pub quiet: bool,
885
886 #[arg(
887 long,
888 value_name = "SCHEME",
889 help = "URI scheme for package_moniker; defaults to code+moniker://"
890 )]
891 pub scheme: Option<String>,
892}
893
894#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
895pub enum ManifestFormat {
896 Tsv,
897 Json,
898 #[cfg(feature = "pretty")]
899 Tree,
900}
901
902impl ManifestArgs {
903 pub fn mode(&self) -> OutputMode {
904 if self.count {
905 OutputMode::Count
906 } else if self.quiet {
907 OutputMode::Quiet
908 } else {
909 OutputMode::Default
910 }
911 }
912}
913
914impl ExtractArgs {
915 #[cfg(test)]
916 pub(crate) fn for_tests() -> Self {
917 ExtractArgs {
918 path: "a.ts".into(),
919 where_: Vec::new(),
920 kind: vec![],
921 name: vec![],
922 shape: vec![],
923 format: OutputFormat::Text,
924 limit: 1000,
925 after: None,
926 all: false,
927 moniker_format: MonikerFormat::Compact,
928 color: ColorChoice::Never,
929 charset: Charset::Utf8,
930 count: false,
931 quiet: false,
932 with_text: false,
933 path_filter: Vec::new(),
934 scheme: None,
935 project: None,
936 cache: None,
937 }
938 }
939
940 pub fn mode(&self) -> OutputMode {
941 if self.count {
942 OutputMode::Count
943 } else if self.quiet {
944 OutputMode::Quiet
945 } else {
946 OutputMode::Default
947 }
948 }
949
950 pub fn compiled_predicates(&self, default_scheme: &str) -> anyhow::Result<Vec<Predicate>> {
951 let scheme = self.scheme.as_deref().unwrap_or(default_scheme);
952 let cfg = UriConfig { scheme };
953 let mut out = Vec::with_capacity(self.where_.len());
954 for raw in &self.where_ {
955 out.push(parse_where(raw, &cfg)?);
956 }
957 Ok(out)
958 }
959}
960
961fn shape_parser() -> impl TypedValueParser<Value = Shape> {
962 PossibleValuesParser::new(Shape::ALL.iter().map(|s| s.as_str())).map(|s| {
963 Shape::ALL
964 .iter()
965 .copied()
966 .find(|shape| shape.as_str() == s)
967 .unwrap_or(Shape::Ref)
968 })
969}
970
971fn parse_positive_usize(raw: &str) -> Result<usize, String> {
972 let n = raw
973 .parse::<usize>()
974 .map_err(|e| format!("expected positive integer: {e}"))?;
975 if n == 0 {
976 return Err("expected positive integer greater than zero".to_string());
977 }
978 Ok(n)
979}
980
981const CLI_TWO_CHAR_OPS: &[&str] = &["<=", ">=", "<@", "@>", "?="];
984
985fn parse_where(raw: &str, cfg: &UriConfig<'_>) -> anyhow::Result<Predicate> {
986 let raw = raw.trim();
987 let bail = || {
988 anyhow::anyhow!("--where `{raw}`: expected `<op> <uri>` (op ∈ =, <=, >=, <, >, @>, <@, ?=)")
989 };
990 for op in CLI_TWO_CHAR_OPS {
991 if let Some(rest) = raw.strip_prefix(op) {
992 return finish_where(op, rest.trim(), cfg, raw);
993 }
994 }
995 for &op in &["<", ">", "="] {
996 if let Some(rest) = raw.strip_prefix(op) {
997 return finish_where(op, rest.trim(), cfg, raw);
998 }
999 }
1000 Err(bail())
1001}
1002
1003fn finish_where(op: &str, uri: &str, cfg: &UriConfig<'_>, raw: &str) -> anyhow::Result<Predicate> {
1004 if uri.is_empty() {
1005 return Err(anyhow::anyhow!("--where `{raw}`: missing URI after `{op}`"));
1006 }
1007 let m: Moniker = from_uri(uri, cfg).map_err(|e| anyhow::anyhow!("--where `{raw}`: {e}"))?;
1008 Ok(match op {
1009 "=" => Predicate::Eq(m),
1010 "<" => Predicate::Lt(m),
1011 "<=" => Predicate::Le(m),
1012 ">" => Predicate::Gt(m),
1013 ">=" => Predicate::Ge(m),
1014 "@>" => Predicate::AncestorOf(m),
1015 "<@" => Predicate::DescendantOf(m),
1016 "?=" => Predicate::Bind(m),
1017 _ => unreachable!("op set is whitelisted via CLI_TWO_CHAR_OPS / single-char fallthrough"),
1018 })
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023 use super::*;
1024
1025 fn parse(argv: &[&str]) -> Result<Cli, clap::Error> {
1026 let mut full = vec!["code-moniker"];
1027 full.extend_from_slice(argv);
1028 Cli::try_parse_from(full)
1029 }
1030
1031 fn extract(argv: &[&str]) -> ExtractArgs {
1032 let mut full = vec!["extract"];
1033 full.extend_from_slice(argv);
1034 let cli = parse(&full).unwrap();
1035 match cli.command {
1036 Command::Extract(a) => a,
1037 other => panic!("expected Extract, got {other:?}"),
1038 }
1039 }
1040
1041 fn stats(argv: &[&str]) -> StatsArgs {
1042 let mut full = vec!["stats"];
1043 full.extend_from_slice(argv);
1044 let cli = parse(&full).unwrap();
1045 match cli.command {
1046 Command::Stats(a) => a,
1047 other => panic!("expected Stats, got {other:?}"),
1048 }
1049 }
1050
1051 #[cfg(feature = "tui")]
1052 fn ui(argv: &[&str]) -> UiArgs {
1053 let mut full = vec!["ui"];
1054 full.extend_from_slice(argv);
1055 let cli = parse(&full).unwrap();
1056 match cli.command {
1057 Command::Ui(a) => a,
1058 other => panic!("expected Ui, got {other:?}"),
1059 }
1060 }
1061
1062 #[test]
1063 fn no_args_requires_subcommand() {
1064 assert!(
1065 parse(&[]).is_err(),
1066 "empty argv must error — subcommand required"
1067 );
1068 }
1069
1070 #[test]
1071 fn minimal_invocation() {
1072 let a = extract(&["a.ts"]);
1073 assert_eq!(a.path, PathBuf::from("a.ts"));
1074 assert_eq!(a.format, OutputFormat::Text);
1075 assert_eq!(a.mode(), OutputMode::Default);
1076 assert!(a.kind.is_empty());
1077 assert!(!a.with_text);
1078 }
1079
1080 #[test]
1081 fn stats_accepts_multiple_paths() {
1082 let a = stats(&["svc-a", "svc-b"]);
1083 assert_eq!(
1084 a.paths,
1085 vec![PathBuf::from("svc-a"), PathBuf::from("svc-b")]
1086 );
1087 }
1088
1089 #[cfg(feature = "tui")]
1090 #[test]
1091 fn ui_defaults_to_current_dir_and_accepts_multiple_paths() {
1092 assert_eq!(ui(&[]).paths, vec![PathBuf::from(".")]);
1093 assert_eq!(
1094 ui(&["svc-a", "svc-b"]).paths,
1095 vec![PathBuf::from("svc-a"), PathBuf::from("svc-b")]
1096 );
1097 assert!(ui(&["--debug"]).debug);
1098 }
1099
1100 #[test]
1101 fn quiet_and_count_are_mutually_exclusive() {
1102 assert!(parse(&["extract", "a.ts", "--count", "--quiet"]).is_err());
1103 }
1104
1105 #[test]
1106 fn count_mode_detected() {
1107 assert_eq!(extract(&["a.ts", "--count"]).mode(), OutputMode::Count);
1108 }
1109
1110 #[test]
1111 fn quiet_mode_detected() {
1112 assert_eq!(extract(&["a.ts", "--quiet"]).mode(), OutputMode::Quiet);
1113 }
1114
1115 #[test]
1116 fn format_json_recognised() {
1117 assert_eq!(
1118 extract(&["a.ts", "--format", "json"]).format,
1119 OutputFormat::Json
1120 );
1121 }
1122
1123 #[test]
1124 fn extract_pagination_defaults_and_flags() {
1125 let a = extract(&["a.ts"]);
1126 assert_eq!(a.limit, 1000);
1127 assert!(a.after.is_none());
1128 assert!(!a.all);
1129
1130 let a = extract(&[
1131 "a.ts",
1132 "--limit",
1133 "25",
1134 "--after",
1135 "code+moniker://./class:Foo",
1136 ]);
1137 assert_eq!(a.limit, 25);
1138 assert_eq!(a.after.as_deref(), Some("code+moniker://./class:Foo"));
1139 }
1140
1141 #[test]
1142 fn extract_max_symbols_aliases_limit() {
1143 let a = extract(&["a.ts", "--max-symbols", "25"]);
1144 assert_eq!(a.limit, 25);
1145 }
1146
1147 #[test]
1148 fn extract_all_conflicts_with_limit_and_after() {
1149 assert!(parse(&["extract", "a.ts", "--all", "--limit", "10"]).is_err());
1150 assert!(parse(&["extract", "a.ts", "--all", "--max-symbols", "10"]).is_err());
1151 assert!(
1152 parse(&[
1153 "extract",
1154 "a.ts",
1155 "--all",
1156 "--after",
1157 "code+moniker://./class:Foo"
1158 ])
1159 .is_err()
1160 );
1161 }
1162
1163 #[test]
1164 fn unknown_format_rejected() {
1165 assert!(parse(&["extract", "a.ts", "--format", "xml"]).is_err());
1166 }
1167
1168 #[test]
1169 fn kind_is_repeatable() {
1170 let a = extract(&["a.ts", "--kind", "class", "--kind", "method"]);
1171 assert_eq!(a.kind, vec!["class".to_string(), "method".to_string()]);
1172 }
1173
1174 #[test]
1175 fn with_text_flag() {
1176 assert!(extract(&["a.ts", "--with-text"]).with_text);
1177 }
1178
1179 #[test]
1180 fn path_filter_is_repeatable() {
1181 let a = extract(&[
1182 ".",
1183 "--path",
1184 "crates/cli/src/mcp/**",
1185 "--path",
1186 "crates/check/src/check/**",
1187 ]);
1188 assert_eq!(
1189 a.path_filter,
1190 vec![
1191 "crates/cli/src/mcp/**".to_string(),
1192 "crates/check/src/check/**".to_string()
1193 ]
1194 );
1195 }
1196
1197 #[test]
1198 fn where_descendant_parses() {
1199 let a = extract(&["a.ts", "--where", "<@ code+moniker://./class:Foo"]);
1200 let preds = a.compiled_predicates("code+moniker://").expect("ok");
1201 assert_eq!(preds.len(), 1);
1202 assert!(matches!(preds[0], Predicate::DescendantOf(_)));
1203 }
1204
1205 #[test]
1206 fn where_multiple_predicates_compose_with_and() {
1207 let a = extract(&[
1208 "a.ts",
1209 "--where",
1210 "@> code+moniker://./class:Foo",
1211 "--where",
1212 "= code+moniker://./class:Foo/method:bar",
1213 ]);
1214 let preds = a.compiled_predicates("code+moniker://").expect("ok");
1215 assert_eq!(preds.len(), 2);
1216 assert!(matches!(preds[0], Predicate::AncestorOf(_)));
1217 assert!(matches!(preds[1], Predicate::Eq(_)));
1218 }
1219
1220 #[test]
1221 fn where_each_operator_supported() {
1222 for op in &["=", "<", "<=", ">", ">=", "@>", "<@", "?="] {
1223 let a = extract(&[
1224 "a.ts",
1225 "--where",
1226 &format!("{op} code+moniker://./class:Foo"),
1227 ]);
1228 let preds = a.compiled_predicates("code+moniker://").expect(op);
1229 assert_eq!(preds.len(), 1, "op {op} failed");
1230 }
1231 }
1232
1233 #[test]
1234 fn where_malformed_is_usage_error() {
1235 let a = extract(&["a.ts", "--where", "garbage uri"]);
1236 let err = a.compiled_predicates("code+moniker://").unwrap_err();
1237 let msg = format!("{err:#}");
1238 assert!(msg.contains("--where"), "{msg}");
1239 }
1240
1241 #[test]
1242 fn where_missing_uri_is_usage_error() {
1243 let a = extract(&["a.ts", "--where", "@>"]);
1244 let err = a.compiled_predicates("code+moniker://").unwrap_err();
1245 let msg = format!("{err:#}");
1246 assert!(msg.contains("missing URI"), "{msg}");
1247 }
1248
1249 #[test]
1250 fn check_subcommand_routes_to_command() {
1251 let cli = parse(&["check", "a.ts"]).unwrap();
1252 match cli.command {
1253 Command::Check(c) => assert_eq!(c.path, PathBuf::from("a.ts")),
1254 other => panic!("expected Check, got {other:?}"),
1255 }
1256 }
1257
1258 #[test]
1259 fn check_subcommand_accepts_rules_and_format() {
1260 let cli = parse(&[
1261 "check",
1262 "a.ts",
1263 "--rules",
1264 "my-rules.toml",
1265 "--format",
1266 "json",
1267 ])
1268 .unwrap();
1269 match cli.command {
1270 Command::Check(c) => {
1271 assert_eq!(c.rules, PathBuf::from("my-rules.toml"));
1272 assert_eq!(c.format, CheckFormat::Json);
1273 assert_eq!(c.default_rules, None);
1274 assert!(!c.report);
1275 }
1276 other => panic!("expected Check, got {other:?}"),
1277 }
1278 }
1279
1280 #[test]
1281 fn check_subcommand_accepts_report() {
1282 let cli = parse(&["check", "a.ts", "--report"]).unwrap();
1283 match cli.command {
1284 Command::Check(c) => assert!(c.report),
1285 other => panic!("expected Check, got {other:?}"),
1286 }
1287 }
1288}