Skip to main content

code_moniker_cli/
args.rs

1use std::path::PathBuf;
2
3use clap::builder::{PossibleValuesParser, TypedValueParser};
4use clap::{Args as ClapArgs, Parser, Subcommand, ValueEnum};
5
6use crate::predicate::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 = "Lint a path against .code-moniker.toml rules.")]
29	Check(CheckArgs),
30	#[command(about = "List supported languages, or kinds of one.")]
31	Langs(LangsArgs),
32	#[command(about = "Show the shape vocabulary.")]
33	Shapes(ShapesArgs),
34	#[command(
35		about = "Extract declared dependencies from a build manifest (auto-detected by filename) or every manifest under a directory."
36	)]
37	Manifest(ManifestArgs),
38}
39
40#[derive(Debug, ClapArgs)]
41pub struct ShapesArgs {
42	#[arg(long, value_enum, default_value_t = LangsFormat::Text)]
43	pub format: LangsFormat,
44}
45
46#[derive(Debug, ClapArgs)]
47pub struct LangsArgs {
48	#[arg(
49		value_name = "LANG",
50		help = "language tag (e.g. rs, ts, java, python, go, cs, sql); omit to list every tag"
51	)]
52	pub lang: Option<String>,
53
54	#[arg(long, value_enum, default_value_t = LangsFormat::Text)]
55	pub format: LangsFormat,
56}
57
58#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
59pub enum LangsFormat {
60	Text,
61	Json,
62}
63
64#[derive(Debug, ClapArgs)]
65pub struct CheckArgs {
66	#[arg(value_name = "PATH")]
67	pub path: PathBuf,
68
69	#[arg(
70		long,
71		value_name = "PATH",
72		default_value = ".code-moniker.toml",
73		help = "user TOML overlay; missing file falls back to embedded defaults"
74	)]
75	pub rules: PathBuf,
76
77	#[arg(long, value_enum, default_value_t = CheckFormat::Text)]
78	pub format: CheckFormat,
79
80	#[arg(
81		long,
82		help = "print per-rule observability, including implication antecedent hit counts"
83	)]
84	pub report: bool,
85
86	#[arg(
87		long,
88		value_name = "NAME",
89		help = "filter rules through a named profile from .code-moniker.toml"
90	)]
91	pub profile: Option<String>,
92}
93
94#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
95pub enum CheckFormat {
96	Text,
97	Json,
98}
99
100#[derive(Debug, ClapArgs)]
101pub struct ExtractArgs {
102	#[arg(value_name = "PATH")]
103	pub path: PathBuf,
104
105	#[arg(
106		long = "where",
107		value_name = "OP URI",
108		help = "predicate `<op> <uri>` where op ∈ {=, <, <=, >, >=, @>, <@, ?=}; repeatable, AND-combined"
109	)]
110	pub where_: Vec<String>,
111
112	#[arg(
113		long,
114		value_name = "NAME",
115		value_delimiter = ',',
116		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>`."
117	)]
118	pub kind: Vec<String>,
119
120	#[arg(
121		long,
122		value_name = "SHAPE",
123		value_delimiter = ',',
124		value_parser = shape_parser(),
125		help = "kind family; repeatable or comma-separated; OR within --shape, AND with --kind. See `code-moniker shapes`."
126	)]
127	pub shape: Vec<Shape>,
128
129	#[arg(long, value_enum, default_value_t = OutputFormat::Tsv)]
130	pub format: OutputFormat,
131
132	#[arg(
133		long,
134		value_enum,
135		default_value_t = ColorChoice::Auto,
136		help = "ANSI color for --format tree: auto = on if stdout is a TTY (honors NO_COLOR / CLICOLOR / CLICOLOR_FORCE)"
137	)]
138	pub color: ColorChoice,
139
140	#[arg(
141		long,
142		value_enum,
143		default_value_t = Charset::Utf8,
144		help = "glyph set for --format tree"
145	)]
146	pub charset: Charset,
147
148	#[arg(long, conflicts_with = "quiet", help = "print only the match count")]
149	pub count: bool,
150	#[arg(
151		long,
152		conflicts_with = "count",
153		help = "suppress output, exit code only"
154	)]
155	pub quiet: bool,
156
157	#[arg(long = "with-text", help = "include comment text (re-reads source)")]
158	pub with_text: bool,
159
160	#[arg(
161		long,
162		value_name = "SCHEME",
163		help = "URI scheme; defaults to code+moniker://"
164	)]
165	pub scheme: Option<String>,
166
167	#[arg(
168		long,
169		value_name = "NAME",
170		help = "project component of the anchor moniker; defaults to '.'"
171	)]
172	pub project: Option<String>,
173
174	#[arg(
175		long,
176		value_name = "DIR",
177		env = "CODE_MONIKER_CACHE_DIR",
178		help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
179	)]
180	pub cache: Option<PathBuf>,
181}
182
183#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
184pub enum OutputFormat {
185	Tsv,
186	Json,
187	#[cfg(feature = "pretty")]
188	Tree,
189}
190
191#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
192pub enum ColorChoice {
193	Auto,
194	Always,
195	Never,
196}
197
198#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
199pub enum Charset {
200	Utf8,
201	Ascii,
202}
203
204#[derive(Copy, Clone, Debug, Eq, PartialEq)]
205pub enum OutputMode {
206	Default,
207	Count,
208	Quiet,
209}
210
211#[derive(Debug, ClapArgs)]
212pub struct ManifestArgs {
213	#[arg(
214		value_name = "PATH",
215		help = "manifest file (Cargo.toml / package.json / pom.xml / pyproject.toml / go.mod / *.csproj) or a directory to walk for any of those"
216	)]
217	pub path: PathBuf,
218
219	#[arg(long, value_enum, default_value_t = ManifestFormat::Tsv)]
220	pub format: ManifestFormat,
221
222	#[arg(long, conflicts_with = "quiet", help = "print only the row count")]
223	pub count: bool,
224	#[arg(
225		long,
226		conflicts_with = "count",
227		help = "suppress output, exit code only"
228	)]
229	pub quiet: bool,
230
231	#[arg(
232		long,
233		value_name = "SCHEME",
234		help = "URI scheme for package_moniker; defaults to code+moniker://"
235	)]
236	pub scheme: Option<String>,
237}
238
239#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
240pub enum ManifestFormat {
241	Tsv,
242	Json,
243	#[cfg(feature = "pretty")]
244	Tree,
245}
246
247impl ManifestArgs {
248	pub fn mode(&self) -> OutputMode {
249		if self.count {
250			OutputMode::Count
251		} else if self.quiet {
252			OutputMode::Quiet
253		} else {
254			OutputMode::Default
255		}
256	}
257}
258
259impl ExtractArgs {
260	#[cfg(test)]
261	pub(crate) fn for_tests() -> Self {
262		ExtractArgs {
263			path: "a.ts".into(),
264			where_: Vec::new(),
265			kind: vec![],
266			shape: vec![],
267			format: OutputFormat::Tsv,
268			color: ColorChoice::Never,
269			charset: Charset::Utf8,
270			count: false,
271			quiet: false,
272			with_text: false,
273			scheme: None,
274			project: None,
275			cache: None,
276		}
277	}
278
279	pub fn mode(&self) -> OutputMode {
280		if self.count {
281			OutputMode::Count
282		} else if self.quiet {
283			OutputMode::Quiet
284		} else {
285			OutputMode::Default
286		}
287	}
288
289	pub fn compiled_predicates(&self, default_scheme: &str) -> anyhow::Result<Vec<Predicate>> {
290		let scheme = self.scheme.as_deref().unwrap_or(default_scheme);
291		let cfg = UriConfig { scheme };
292		let mut out = Vec::with_capacity(self.where_.len());
293		for raw in &self.where_ {
294			out.push(parse_where(raw, &cfg)?);
295		}
296		Ok(out)
297	}
298}
299
300fn shape_parser() -> impl TypedValueParser<Value = Shape> {
301	PossibleValuesParser::new(Shape::ALL.iter().map(|s| s.as_str())).map(|s| {
302		s.parse::<Shape>()
303			.expect("PossibleValuesParser pre-validated")
304	})
305}
306
307/// CLI predicate ops are the moniker subset of `expr::TWO_CHAR_OPS` — regex
308/// ops (`=~`, `!~`) and inequality (`!=`) don't map to a `Predicate` variant.
309const CLI_TWO_CHAR_OPS: &[&str] = &["<=", ">=", "<@", "@>", "?="];
310
311fn parse_where(raw: &str, cfg: &UriConfig<'_>) -> anyhow::Result<Predicate> {
312	let raw = raw.trim();
313	let bail = || {
314		anyhow::anyhow!("--where `{raw}`: expected `<op> <uri>` (op ∈ =, <=, >=, <, >, @>, <@, ?=)")
315	};
316	for op in CLI_TWO_CHAR_OPS {
317		if let Some(rest) = raw.strip_prefix(op) {
318			return finish_where(op, rest.trim(), cfg, raw);
319		}
320	}
321	for &op in &["<", ">", "="] {
322		if let Some(rest) = raw.strip_prefix(op) {
323			return finish_where(op, rest.trim(), cfg, raw);
324		}
325	}
326	Err(bail())
327}
328
329fn finish_where(op: &str, uri: &str, cfg: &UriConfig<'_>, raw: &str) -> anyhow::Result<Predicate> {
330	if uri.is_empty() {
331		return Err(anyhow::anyhow!("--where `{raw}`: missing URI after `{op}`"));
332	}
333	let m: Moniker = from_uri(uri, cfg).map_err(|e| anyhow::anyhow!("--where `{raw}`: {e}"))?;
334	Ok(match op {
335		"=" => Predicate::Eq(m),
336		"<" => Predicate::Lt(m),
337		"<=" => Predicate::Le(m),
338		">" => Predicate::Gt(m),
339		">=" => Predicate::Ge(m),
340		"@>" => Predicate::AncestorOf(m),
341		"<@" => Predicate::DescendantOf(m),
342		"?=" => Predicate::Bind(m),
343		_ => unreachable!("op set is whitelisted via CLI_TWO_CHAR_OPS / single-char fallthrough"),
344	})
345}
346
347#[cfg(test)]
348mod tests {
349	use super::*;
350
351	fn parse(argv: &[&str]) -> Result<Cli, clap::Error> {
352		let mut full = vec!["code-moniker"];
353		full.extend_from_slice(argv);
354		Cli::try_parse_from(full)
355	}
356
357	fn extract(argv: &[&str]) -> ExtractArgs {
358		let mut full = vec!["extract"];
359		full.extend_from_slice(argv);
360		let cli = parse(&full).unwrap();
361		match cli.command {
362			Command::Extract(a) => a,
363			other => panic!("expected Extract, got {other:?}"),
364		}
365	}
366
367	#[test]
368	fn no_args_requires_subcommand() {
369		assert!(
370			parse(&[]).is_err(),
371			"empty argv must error — subcommand required"
372		);
373	}
374
375	#[test]
376	fn minimal_invocation() {
377		let a = extract(&["a.ts"]);
378		assert_eq!(a.path, PathBuf::from("a.ts"));
379		assert_eq!(a.format, OutputFormat::Tsv);
380		assert_eq!(a.mode(), OutputMode::Default);
381		assert!(a.kind.is_empty());
382		assert!(!a.with_text);
383	}
384
385	#[test]
386	fn quiet_and_count_are_mutually_exclusive() {
387		assert!(parse(&["extract", "a.ts", "--count", "--quiet"]).is_err());
388	}
389
390	#[test]
391	fn count_mode_detected() {
392		assert_eq!(extract(&["a.ts", "--count"]).mode(), OutputMode::Count);
393	}
394
395	#[test]
396	fn quiet_mode_detected() {
397		assert_eq!(extract(&["a.ts", "--quiet"]).mode(), OutputMode::Quiet);
398	}
399
400	#[test]
401	fn format_json_recognised() {
402		assert_eq!(
403			extract(&["a.ts", "--format", "json"]).format,
404			OutputFormat::Json
405		);
406	}
407
408	#[test]
409	fn unknown_format_rejected() {
410		assert!(parse(&["extract", "a.ts", "--format", "xml"]).is_err());
411	}
412
413	#[test]
414	fn kind_is_repeatable() {
415		let a = extract(&["a.ts", "--kind", "class", "--kind", "method"]);
416		assert_eq!(a.kind, vec!["class".to_string(), "method".to_string()]);
417	}
418
419	#[test]
420	fn with_text_flag() {
421		assert!(extract(&["a.ts", "--with-text"]).with_text);
422	}
423
424	#[test]
425	fn where_descendant_parses() {
426		let a = extract(&["a.ts", "--where", "<@ code+moniker://./class:Foo"]);
427		let preds = a.compiled_predicates("code+moniker://").expect("ok");
428		assert_eq!(preds.len(), 1);
429		assert!(matches!(preds[0], Predicate::DescendantOf(_)));
430	}
431
432	#[test]
433	fn where_multiple_predicates_compose_with_and() {
434		let a = extract(&[
435			"a.ts",
436			"--where",
437			"@> code+moniker://./class:Foo",
438			"--where",
439			"= code+moniker://./class:Foo/method:bar",
440		]);
441		let preds = a.compiled_predicates("code+moniker://").expect("ok");
442		assert_eq!(preds.len(), 2);
443		assert!(matches!(preds[0], Predicate::AncestorOf(_)));
444		assert!(matches!(preds[1], Predicate::Eq(_)));
445	}
446
447	#[test]
448	fn where_each_operator_supported() {
449		for op in &["=", "<", "<=", ">", ">=", "@>", "<@", "?="] {
450			let a = extract(&[
451				"a.ts",
452				"--where",
453				&format!("{op} code+moniker://./class:Foo"),
454			]);
455			let preds = a.compiled_predicates("code+moniker://").expect(op);
456			assert_eq!(preds.len(), 1, "op {op} failed");
457		}
458	}
459
460	#[test]
461	fn where_malformed_is_usage_error() {
462		let a = extract(&["a.ts", "--where", "garbage uri"]);
463		let err = a.compiled_predicates("code+moniker://").unwrap_err();
464		let msg = format!("{err:#}");
465		assert!(msg.contains("--where"), "{msg}");
466	}
467
468	#[test]
469	fn where_missing_uri_is_usage_error() {
470		let a = extract(&["a.ts", "--where", "@>"]);
471		let err = a.compiled_predicates("code+moniker://").unwrap_err();
472		let msg = format!("{err:#}");
473		assert!(msg.contains("missing URI"), "{msg}");
474	}
475
476	#[test]
477	fn check_subcommand_routes_to_command() {
478		let cli = parse(&["check", "a.ts"]).unwrap();
479		match cli.command {
480			Command::Check(c) => assert_eq!(c.path, PathBuf::from("a.ts")),
481			other => panic!("expected Check, got {other:?}"),
482		}
483	}
484
485	#[test]
486	fn check_subcommand_accepts_rules_and_format() {
487		let cli = parse(&[
488			"check",
489			"a.ts",
490			"--rules",
491			"my-rules.toml",
492			"--format",
493			"json",
494		])
495		.unwrap();
496		match cli.command {
497			Command::Check(c) => {
498				assert_eq!(c.rules, PathBuf::from("my-rules.toml"));
499				assert_eq!(c.format, CheckFormat::Json);
500				assert!(!c.report);
501			}
502			other => panic!("expected Check, got {other:?}"),
503		}
504	}
505
506	#[test]
507	fn check_subcommand_accepts_report() {
508		let cli = parse(&["check", "a.ts", "--report"]).unwrap();
509		match cli.command {
510			Command::Check(c) => assert!(c.report),
511			other => panic!("expected Check, got {other:?}"),
512		}
513	}
514}