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
307const 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}