face 0.1.0

Fold And Cluster Entries — a Unix-style CLI for grouping, paging, and summarizing structured command output.
Documentation
//! Current clap surface for the `face` binary.
//!
//! Flags are grouped by concern (INPUT, SCORE, STRATEGY, PAGE, OUTPUT,
//! MODES) using clap's `help_heading` so `--help` mirrors the §3 layout.
//! The library entry point — [`crate::run`] — consumes the parsed [`Cli`]
//! and threads stdin/stdout/stderr explicitly so callers (including
//! tests) can inject I/O.

use clap::Parser;

use face_core::OutputFormat;

/// Parsed CLI surface for the `face` binary.
///
/// Each flag is documented inline; see `docs/design.md` §3 for the
/// authoritative table. The shape is `derive`-style clap so we can
/// generate `--help` from the doc comments and rebind without a custom
/// builder.
#[derive(Debug, Parser)]
#[command(
    name = "face",
    version,
    about = "Fold And Cluster Entries — group, page, and summarize structured command output."
)]
#[non_exhaustive]
pub struct Cli {
    // ---- INPUT ----
    /// Force input format (skip sniffing). One of: json, jsonl, csv, tsv.
    #[arg(long = "format-in", help_heading = "INPUT")]
    pub format_in: Option<String>,

    /// jq-like path to the items array within the input document.
    #[arg(long, help_heading = "INPUT")]
    pub items: Option<String>,

    /// Comma-separated column names for headerless CSV.
    #[arg(long, value_delimiter = ',', help_heading = "INPUT")]
    pub columns: Option<Vec<String>>,

    // ---- SCORE ----
    /// jq-like path to the score field on each item.
    #[arg(long, help_heading = "SCORE")]
    pub score: Option<String>,

    /// Optional max scale used to normalize raw score values for `--invert`.
    #[arg(long, help_heading = "SCORE")]
    pub scale: Option<f64>,

    /// Flip score polarity (lower-is-better → higher-is-better).
    #[arg(long, help_heading = "SCORE")]
    pub invert: bool,

    /// Named preset (`confidence`, `concept-search`, `bm25`, `severity`,
    /// or user-defined).
    #[arg(long, help_heading = "SCORE")]
    pub preset: Option<String>,

    // ---- STRATEGY ----
    /// Field(s) to group by; comma chains nested axes (e.g. `file,module`).
    #[arg(long, value_delimiter = ',', help_heading = "STRATEGY")]
    pub by: Option<Vec<String>>,

    /// Force a strategy: exact, prefix, top, bands, quantiles, natural, similar.
    /// Optional payload: `top=10`, `bands=5`, `prefix=2`, `similar=token`.
    #[arg(long, help_heading = "STRATEGY")]
    pub strategy: Option<String>,

    /// Number of bands for `bands` strategy (default 5).
    #[arg(long, help_heading = "STRATEGY")]
    pub bands: Option<u8>,

    /// Number of clusters for `top` strategy.
    #[arg(long, help_heading = "STRATEGY")]
    pub top: Option<u32>,

    /// Add a sub-axis. Repeatable — each `--within` adds a level of nesting.
    #[arg(long, help_heading = "STRATEGY")]
    pub within: Vec<String>,

    // ---- PAGE ----
    /// Drill into a specific cluster. Accepts a 1-based top-level
    /// ordinal (`1`), canonical comma form (`file:src/cli.rs,score:excellent`),
    /// or `axis=value` (repeatable).
    #[arg(long, help_heading = "PAGE")]
    pub cluster: Vec<String>,

    /// Page number (1-based).
    #[arg(long, help_heading = "PAGE")]
    pub page: Option<u32>,

    /// Items per page.
    #[arg(long, help_heading = "PAGE")]
    pub per_page: Option<u32>,

    // ---- OUTPUT ----
    /// Output format: human (default), json, json-flat, jsonl-items, tsv,
    /// csv, markdown.
    #[arg(
        long,
        value_parser = parse_format,
        help_heading = "OUTPUT",
    )]
    pub format: Option<OutputFormat>,

    /// Shortcut for `--format=json`.
    #[arg(long = "json", short = 'j', help_heading = "OUTPUT")]
    pub json: bool,

    /// Color policy: auto (default), always, never.
    #[arg(
        long,
        value_parser = clap::builder::PossibleValuesParser::new(["auto", "always", "never"]),
        help_heading = "OUTPUT",
    )]
    pub color: Option<String>,

    // ---- MODES ----
    /// Print full detection reasoning, then exit.
    #[arg(long, help_heading = "MODES")]
    pub explain: bool,

    /// Print input shape, then exit.
    #[arg(long, help_heading = "MODES")]
    pub schema: bool,

    /// Drill through clusters with an inline picker when attached to a TTY.
    #[arg(long, help_heading = "MODES")]
    pub interactive: bool,

    /// Emit provenance line and skip warnings on stderr.
    #[arg(long, help_heading = "MODES")]
    pub verbose: bool,
}

/// `--format=` parser: defer to the kebab-case wire form via serde so
/// the CLI accepts exactly what `OutputFormat` deserializes.
fn parse_format(s: &str) -> Result<OutputFormat, String> {
    serde_json::from_value(serde_json::Value::String(s.to_string()))
        .map_err(|e| format!("invalid format `{s}`: {e}"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    #[test]
    fn parses_minimal_invocation() {
        let cli = Cli::try_parse_from(["face"]).unwrap();
        assert_eq!(cli.format, None);
        assert_eq!(cli.color, None);
        assert!(!cli.verbose);
        assert!(!cli.interactive);
        assert!(!cli.explain);
        assert!(!cli.json);
        assert_eq!(cli.page, None);
        assert_eq!(cli.per_page, None);
    }

    #[test]
    fn parses_format_kebab_case() {
        let cli = Cli::try_parse_from(["face", "--format=json-flat"]).unwrap();
        assert_eq!(cli.format, Some(OutputFormat::JsonFlat));
    }

    #[test]
    fn json_short_flag_sets_alias() {
        let cli = Cli::try_parse_from(["face", "-j"]).unwrap();
        assert!(cli.json);
        // `format` stays unset until `run` resolves the alias.
        assert_eq!(cli.format, None);
    }

    #[test]
    fn json_long_flag_sets_alias() {
        let cli = Cli::try_parse_from(["face", "--json"]).unwrap();
        assert!(cli.json);
        assert_eq!(cli.format, None);
    }

    #[test]
    fn by_splits_on_comma() {
        let cli = Cli::try_parse_from(["face", "--by", "file,module"]).unwrap();
        assert_eq!(cli.by, Some(vec!["file".to_string(), "module".to_string()]));
    }

    #[test]
    fn within_is_repeatable() {
        let cli = Cli::try_parse_from(["face", "--within", "score", "--within", "tag"]).unwrap();
        assert_eq!(cli.within, vec!["score".to_string(), "tag".to_string()]);
    }

    #[test]
    fn cluster_is_repeatable() {
        let cli = Cli::try_parse_from([
            "face",
            "--cluster",
            "file=src/cli.rs",
            "--cluster",
            "score=excellent",
        ])
        .unwrap();
        assert_eq!(cli.cluster.len(), 2);
    }

    #[test]
    fn interactive_flag_parses() {
        let cli = Cli::try_parse_from(["face", "--interactive"]).unwrap();
        assert!(cli.interactive);
    }

    #[test]
    fn verbose_flag_parses() {
        let cli = Cli::try_parse_from(["face", "--verbose"]).unwrap();
        assert!(cli.verbose);
    }

    #[test]
    fn quiet_flag_is_not_supported() {
        let err = Cli::try_parse_from(["face", "--quiet"]).unwrap_err();
        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
    }
}