Skip to main content

face_cli/
cli.rs

1//! Current clap surface for the `face` binary.
2//!
3//! Flags are grouped by concern (INPUT, SCORE, STRATEGY, PAGE, OUTPUT,
4//! MODES) using clap's `help_heading` so `--help` mirrors the §3 layout.
5//! The library entry point — [`crate::run`] — consumes the parsed [`Cli`]
6//! and threads stdin/stdout/stderr explicitly so callers (including
7//! tests) can inject I/O.
8
9use clap::Parser;
10
11use face_core::OutputFormat;
12
13/// Parsed CLI surface for the `face` binary.
14///
15/// Each flag is documented inline; see `docs/design.md` §3 for the
16/// authoritative table. The shape is `derive`-style clap so we can
17/// generate `--help` from the doc comments and rebind without a custom
18/// builder.
19#[derive(Debug, Parser)]
20#[command(
21    name = "face",
22    version,
23    about = "Fold And Cluster Entries — group, page, and summarize structured command output."
24)]
25#[non_exhaustive]
26pub struct Cli {
27    // ---- INPUT ----
28    /// Force input format (skip sniffing). One of: json, jsonl, csv, tsv.
29    #[arg(long = "format-in", help_heading = "INPUT")]
30    pub format_in: Option<String>,
31
32    /// jq-like path to the items array within the input document.
33    #[arg(long, help_heading = "INPUT")]
34    pub items: Option<String>,
35
36    /// Comma-separated column names for headerless CSV.
37    #[arg(long, value_delimiter = ',', help_heading = "INPUT")]
38    pub columns: Option<Vec<String>>,
39
40    // ---- SCORE ----
41    /// jq-like path to the score field on each item.
42    #[arg(long, help_heading = "SCORE")]
43    pub score: Option<String>,
44
45    /// Optional max scale used to normalize raw score values for `--invert`.
46    #[arg(long, help_heading = "SCORE")]
47    pub scale: Option<f64>,
48
49    /// Flip score polarity (lower-is-better → higher-is-better).
50    #[arg(long, help_heading = "SCORE")]
51    pub invert: bool,
52
53    /// Named preset (`confidence`, `concept-search`, `bm25`, `severity`,
54    /// or user-defined).
55    #[arg(long, help_heading = "SCORE")]
56    pub preset: Option<String>,
57
58    // ---- STRATEGY ----
59    /// Field(s) to group by; comma chains nested axes (e.g. `file,module`).
60    #[arg(long, value_delimiter = ',', help_heading = "STRATEGY")]
61    pub by: Option<Vec<String>>,
62
63    /// Force a strategy: exact, prefix, top, bands, quantiles, natural, similar.
64    /// Optional payload: `top=10`, `bands=5`, `prefix=2`, `similar=token`.
65    #[arg(long, help_heading = "STRATEGY")]
66    pub strategy: Option<String>,
67
68    /// Number of bands for `bands` strategy (default 5).
69    #[arg(long, help_heading = "STRATEGY")]
70    pub bands: Option<u8>,
71
72    /// Number of clusters for `top` strategy.
73    #[arg(long, help_heading = "STRATEGY")]
74    pub top: Option<u32>,
75
76    /// Add a sub-axis. Repeatable — each `--within` adds a level of nesting.
77    #[arg(long, help_heading = "STRATEGY")]
78    pub within: Vec<String>,
79
80    // ---- PAGE ----
81    /// Drill into a specific cluster. Accepts a 1-based top-level
82    /// ordinal (`1`), canonical comma form (`file:src/cli.rs,score:excellent`),
83    /// or `axis=value` (repeatable).
84    #[arg(long, help_heading = "PAGE")]
85    pub cluster: Vec<String>,
86
87    /// Page number (1-based).
88    #[arg(long, help_heading = "PAGE")]
89    pub page: Option<u32>,
90
91    /// Items per page.
92    #[arg(long, help_heading = "PAGE")]
93    pub per_page: Option<u32>,
94
95    // ---- OUTPUT ----
96    /// Output format: human (default), json, json-flat, jsonl-items, tsv,
97    /// csv, markdown.
98    #[arg(
99        long,
100        value_parser = parse_format,
101        help_heading = "OUTPUT",
102    )]
103    pub format: Option<OutputFormat>,
104
105    /// Shortcut for `--format=json`.
106    #[arg(long = "json", short = 'j', help_heading = "OUTPUT")]
107    pub json: bool,
108
109    /// Color policy: auto (default), always, never.
110    #[arg(
111        long,
112        value_parser = clap::builder::PossibleValuesParser::new(["auto", "always", "never"]),
113        help_heading = "OUTPUT",
114    )]
115    pub color: Option<String>,
116
117    // ---- MODES ----
118    /// Print full detection reasoning, then exit.
119    #[arg(long, help_heading = "MODES")]
120    pub explain: bool,
121
122    /// Print input shape, then exit.
123    #[arg(long, help_heading = "MODES")]
124    pub schema: bool,
125
126    /// Drill through clusters with an inline picker when attached to a TTY.
127    #[arg(long, help_heading = "MODES")]
128    pub interactive: bool,
129
130    /// Emit provenance line and skip warnings on stderr.
131    #[arg(long, help_heading = "MODES")]
132    pub verbose: bool,
133}
134
135/// `--format=` parser: defer to the kebab-case wire form via serde so
136/// the CLI accepts exactly what `OutputFormat` deserializes.
137fn parse_format(s: &str) -> Result<OutputFormat, String> {
138    serde_json::from_value(serde_json::Value::String(s.to_string()))
139        .map_err(|e| format!("invalid format `{s}`: {e}"))
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use clap::Parser;
146
147    #[test]
148    fn parses_minimal_invocation() {
149        let cli = Cli::try_parse_from(["face"]).unwrap();
150        assert_eq!(cli.format, None);
151        assert_eq!(cli.color, None);
152        assert!(!cli.verbose);
153        assert!(!cli.interactive);
154        assert!(!cli.explain);
155        assert!(!cli.json);
156        assert_eq!(cli.page, None);
157        assert_eq!(cli.per_page, None);
158    }
159
160    #[test]
161    fn parses_format_kebab_case() {
162        let cli = Cli::try_parse_from(["face", "--format=json-flat"]).unwrap();
163        assert_eq!(cli.format, Some(OutputFormat::JsonFlat));
164    }
165
166    #[test]
167    fn json_short_flag_sets_alias() {
168        let cli = Cli::try_parse_from(["face", "-j"]).unwrap();
169        assert!(cli.json);
170        // `format` stays unset until `run` resolves the alias.
171        assert_eq!(cli.format, None);
172    }
173
174    #[test]
175    fn json_long_flag_sets_alias() {
176        let cli = Cli::try_parse_from(["face", "--json"]).unwrap();
177        assert!(cli.json);
178        assert_eq!(cli.format, None);
179    }
180
181    #[test]
182    fn by_splits_on_comma() {
183        let cli = Cli::try_parse_from(["face", "--by", "file,module"]).unwrap();
184        assert_eq!(cli.by, Some(vec!["file".to_string(), "module".to_string()]));
185    }
186
187    #[test]
188    fn within_is_repeatable() {
189        let cli = Cli::try_parse_from(["face", "--within", "score", "--within", "tag"]).unwrap();
190        assert_eq!(cli.within, vec!["score".to_string(), "tag".to_string()]);
191    }
192
193    #[test]
194    fn cluster_is_repeatable() {
195        let cli = Cli::try_parse_from([
196            "face",
197            "--cluster",
198            "file=src/cli.rs",
199            "--cluster",
200            "score=excellent",
201        ])
202        .unwrap();
203        assert_eq!(cli.cluster.len(), 2);
204    }
205
206    #[test]
207    fn interactive_flag_parses() {
208        let cli = Cli::try_parse_from(["face", "--interactive"]).unwrap();
209        assert!(cli.interactive);
210    }
211
212    #[test]
213    fn verbose_flag_parses() {
214        let cli = Cli::try_parse_from(["face", "--verbose"]).unwrap();
215        assert!(cli.verbose);
216    }
217
218    #[test]
219    fn quiet_flag_is_not_supported() {
220        let err = Cli::try_parse_from(["face", "--quiet"]).unwrap_err();
221        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
222    }
223}