Skip to main content

code_moniker_cli/
args.rs

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
981/// CLI predicate ops are the moniker subset of `expr::TWO_CHAR_OPS` — regex
982/// ops (`=~`, `!~`) and inequality (`!=`) don't map to a `Predicate` variant.
983const 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}