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