Skip to main content

code_moniker_cli/
args.rs

1use std::path::PathBuf;
2
3use clap::{Args as ClapArgs, Parser, Subcommand, ValueEnum};
4
5use crate::predicate::Predicate;
6use code_moniker_core::core::moniker::Moniker;
7use code_moniker_core::core::uri::{UriConfig, from_uri};
8
9const ASCII_LOGO: &str = "
10    ◆ code+moniker://
11    └─◆ lang:ts
12      └─◆ class:Util
13";
14
15#[derive(Debug, Parser)]
16#[command(
17	name = "code-moniker",
18	about = "Probe a file or a directory tree. Single-file → full graph; directory → per-file summary (counts) or filtered list when --kind / --where is set. See docs/cli-extract.md.",
19	before_help = ASCII_LOGO,
20	version
21)]
22pub struct Cli {
23	#[command(subcommand)]
24	pub command: Option<Command>,
25
26	#[command(flatten)]
27	pub extract: Args,
28}
29
30#[derive(Debug, Subcommand)]
31pub enum Command {
32	Check(CheckArgs),
33}
34
35#[derive(Debug, ClapArgs)]
36pub struct CheckArgs {
37	#[arg(value_name = "PATH")]
38	pub file: PathBuf,
39
40	#[arg(
41		long,
42		value_name = "PATH",
43		default_value = ".code-moniker.toml",
44		help = "user TOML overlay; missing file falls back to embedded defaults"
45	)]
46	pub rules: PathBuf,
47
48	#[arg(long, value_enum, default_value_t = CheckFormat::Text)]
49	pub format: CheckFormat,
50
51	#[arg(
52		long,
53		value_name = "NAME",
54		help = "filter rules through a named profile from .code-moniker.toml"
55	)]
56	pub profile: Option<String>,
57}
58
59#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
60pub enum CheckFormat {
61	Text,
62	Json,
63}
64
65#[derive(Debug, ClapArgs)]
66pub struct Args {
67	pub file: Option<PathBuf>,
68
69	#[arg(
70		long = "where",
71		value_name = "OP URI",
72		help = "predicate `<op> <uri>` where op ∈ {=, <, <=, >, >=, @>, <@, ?=}; repeatable, AND-combined"
73	)]
74	pub where_: Vec<String>,
75
76	#[arg(long, value_name = "NAME", help = "kind filter (repeatable, OR)")]
77	pub kind: Vec<String>,
78
79	#[arg(long, value_enum, default_value_t = OutputFormat::Tsv)]
80	pub format: OutputFormat,
81
82	#[arg(long, conflicts_with = "quiet", help = "print only the match count")]
83	pub count: bool,
84	#[arg(
85		long,
86		conflicts_with = "count",
87		help = "suppress output, exit code only"
88	)]
89	pub quiet: bool,
90
91	#[arg(long = "with-text", help = "include comment text (re-reads source)")]
92	pub with_text: bool,
93
94	#[arg(
95		long,
96		value_name = "SCHEME",
97		help = "URI scheme; defaults to code+moniker://"
98	)]
99	pub scheme: Option<String>,
100
101	#[arg(
102		long,
103		value_name = "DIR",
104		env = "CODE_MONIKER_CACHE_DIR",
105		help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
106	)]
107	pub cache: Option<PathBuf>,
108}
109
110#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
111pub enum OutputFormat {
112	Tsv,
113	Json,
114}
115
116#[derive(Copy, Clone, Debug, Eq, PartialEq)]
117pub enum OutputMode {
118	Default,
119	Count,
120	Quiet,
121}
122
123impl Args {
124	pub fn mode(&self) -> OutputMode {
125		if self.count {
126			OutputMode::Count
127		} else if self.quiet {
128			OutputMode::Quiet
129		} else {
130			OutputMode::Default
131		}
132	}
133
134	pub fn compiled_predicates(&self, default_scheme: &str) -> anyhow::Result<Vec<Predicate>> {
135		let scheme = self.scheme.as_deref().unwrap_or(default_scheme);
136		let cfg = UriConfig { scheme };
137		let mut out = Vec::with_capacity(self.where_.len());
138		for raw in &self.where_ {
139			out.push(parse_where(raw, &cfg)?);
140		}
141		Ok(out)
142	}
143}
144
145/// CLI predicate ops are the moniker subset of `expr::TWO_CHAR_OPS` — regex
146/// ops (`=~`, `!~`) and inequality (`!=`) don't map to a `Predicate` variant.
147const CLI_TWO_CHAR_OPS: &[&str] = &["<=", ">=", "<@", "@>", "?="];
148
149fn parse_where(raw: &str, cfg: &UriConfig<'_>) -> anyhow::Result<Predicate> {
150	let raw = raw.trim();
151	let bail = || {
152		anyhow::anyhow!("--where `{raw}`: expected `<op> <uri>` (op ∈ =, <=, >=, <, >, @>, <@, ?=)")
153	};
154	for op in CLI_TWO_CHAR_OPS {
155		if let Some(rest) = raw.strip_prefix(op) {
156			return finish_where(op, rest.trim(), cfg, raw);
157		}
158	}
159	for &op in &["<", ">", "="] {
160		if let Some(rest) = raw.strip_prefix(op) {
161			return finish_where(op, rest.trim(), cfg, raw);
162		}
163	}
164	Err(bail())
165}
166
167fn finish_where(op: &str, uri: &str, cfg: &UriConfig<'_>, raw: &str) -> anyhow::Result<Predicate> {
168	if uri.is_empty() {
169		return Err(anyhow::anyhow!("--where `{raw}`: missing URI after `{op}`"));
170	}
171	let m: Moniker = from_uri(uri, cfg).map_err(|e| anyhow::anyhow!("--where `{raw}`: {e}"))?;
172	Ok(match op {
173		"=" => Predicate::Eq(m),
174		"<" => Predicate::Lt(m),
175		"<=" => Predicate::Le(m),
176		">" => Predicate::Gt(m),
177		">=" => Predicate::Ge(m),
178		"@>" => Predicate::AncestorOf(m),
179		"<@" => Predicate::DescendantOf(m),
180		"?=" => Predicate::Bind(m),
181		_ => unreachable!("op set is whitelisted via CLI_TWO_CHAR_OPS / single-char fallthrough"),
182	})
183}
184
185#[cfg(test)]
186mod tests {
187	use super::*;
188
189	fn parse(argv: &[&str]) -> Result<Cli, clap::Error> {
190		let mut full = vec!["code-moniker"];
191		full.extend_from_slice(argv);
192		Cli::try_parse_from(full)
193	}
194
195	fn extract(argv: &[&str]) -> Args {
196		let cli = parse(argv).unwrap();
197		assert!(cli.command.is_none());
198		cli.extract
199	}
200
201	#[test]
202	fn no_args_parses_but_carries_no_file() {
203		let cli = parse(&[]).expect("clap accepts empty argv");
204		assert!(cli.command.is_none());
205		assert!(cli.extract.file.is_none());
206	}
207
208	#[test]
209	fn minimal_invocation() {
210		let a = extract(&["a.ts"]);
211		assert_eq!(a.file.as_deref(), Some(std::path::Path::new("a.ts")));
212		assert_eq!(a.format, OutputFormat::Tsv);
213		assert_eq!(a.mode(), OutputMode::Default);
214		assert!(a.kind.is_empty());
215		assert!(!a.with_text);
216	}
217
218	#[test]
219	fn quiet_and_count_are_mutually_exclusive() {
220		assert!(parse(&["a.ts", "--count", "--quiet"]).is_err());
221	}
222
223	#[test]
224	fn count_mode_detected() {
225		assert_eq!(extract(&["a.ts", "--count"]).mode(), OutputMode::Count);
226	}
227
228	#[test]
229	fn quiet_mode_detected() {
230		assert_eq!(extract(&["a.ts", "--quiet"]).mode(), OutputMode::Quiet);
231	}
232
233	#[test]
234	fn format_json_recognised() {
235		assert_eq!(
236			extract(&["a.ts", "--format", "json"]).format,
237			OutputFormat::Json
238		);
239	}
240
241	#[test]
242	fn unknown_format_rejected() {
243		assert!(parse(&["a.ts", "--format", "xml"]).is_err());
244	}
245
246	#[test]
247	fn kind_is_repeatable() {
248		let a = extract(&["a.ts", "--kind", "class", "--kind", "method"]);
249		assert_eq!(a.kind, vec!["class".to_string(), "method".to_string()]);
250	}
251
252	#[test]
253	fn with_text_flag() {
254		assert!(extract(&["a.ts", "--with-text"]).with_text);
255	}
256
257	#[test]
258	fn where_descendant_parses() {
259		let a = extract(&["a.ts", "--where", "<@ code+moniker://./class:Foo"]);
260		let preds = a.compiled_predicates("code+moniker://").expect("ok");
261		assert_eq!(preds.len(), 1);
262		assert!(matches!(preds[0], Predicate::DescendantOf(_)));
263	}
264
265	#[test]
266	fn where_multiple_predicates_compose_with_and() {
267		let a = extract(&[
268			"a.ts",
269			"--where",
270			"@> code+moniker://./class:Foo",
271			"--where",
272			"= code+moniker://./class:Foo/method:bar",
273		]);
274		let preds = a.compiled_predicates("code+moniker://").expect("ok");
275		assert_eq!(preds.len(), 2);
276		assert!(matches!(preds[0], Predicate::AncestorOf(_)));
277		assert!(matches!(preds[1], Predicate::Eq(_)));
278	}
279
280	#[test]
281	fn where_each_operator_supported() {
282		for op in &["=", "<", "<=", ">", ">=", "@>", "<@", "?="] {
283			let a = extract(&[
284				"a.ts",
285				"--where",
286				&format!("{op} code+moniker://./class:Foo"),
287			]);
288			let preds = a.compiled_predicates("code+moniker://").expect(op);
289			assert_eq!(preds.len(), 1, "op {op} failed");
290		}
291	}
292
293	#[test]
294	fn where_malformed_is_usage_error() {
295		let a = extract(&["a.ts", "--where", "garbage uri"]);
296		let err = a.compiled_predicates("code+moniker://").unwrap_err();
297		let msg = format!("{err:#}");
298		assert!(msg.contains("--where"), "{msg}");
299	}
300
301	#[test]
302	fn where_missing_uri_is_usage_error() {
303		let a = extract(&["a.ts", "--where", "@>"]);
304		let err = a.compiled_predicates("code+moniker://").unwrap_err();
305		let msg = format!("{err:#}");
306		assert!(msg.contains("missing URI"), "{msg}");
307	}
308
309	#[test]
310	fn check_subcommand_routes_to_command() {
311		let cli = parse(&["check", "a.ts"]).unwrap();
312		match cli.command {
313			Some(Command::Check(c)) => assert_eq!(c.file, PathBuf::from("a.ts")),
314			other => panic!("expected Check, got {other:?}"),
315		}
316	}
317
318	#[test]
319	fn check_subcommand_accepts_rules_and_format() {
320		let cli = parse(&[
321			"check",
322			"a.ts",
323			"--rules",
324			"my-rules.toml",
325			"--format",
326			"json",
327		])
328		.unwrap();
329		match cli.command {
330			Some(Command::Check(c)) => {
331				assert_eq!(c.rules, PathBuf::from("my-rules.toml"));
332				assert_eq!(c.format, CheckFormat::Json);
333			}
334			other => panic!("expected Check, got {other:?}"),
335		}
336	}
337}