face 0.1.0

Fold And Cluster Entries — a Unix-style CLI for grouping, paging, and summarizing structured command output.
Documentation
//! End-to-end pipeline coverage for simple stdin → stdout cases with
//! auto-detection across input shapes and output formats.
//!
//! Acceptance: docs/design.md §3 (CLI surface), §4.5 (auto strategy),
//! §4.1–§4.3 (input / items / score detection), §8 (output formats).

mod common;

use common::drive;

mod jsonl_input {
    use super::drive;

    /// Acceptance: docs/design.md §4.5 — JSONL input with a named
    /// enum-like field auto-picks Exact grouping.
    #[test]
    fn groups_by_named_field_auto() {
        let stdin = "{\"kind\":\"bug\"}\n{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
        let (code, stdout, stderr) = drive(&["face"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(
            stdout.contains("bug"),
            "expected `bug` cluster in stdout: {stdout:?}",
        );
        assert!(
            stdout.contains("feat"),
            "expected `feat` cluster in stdout: {stdout:?}",
        );
    }

    /// Acceptance: README quick tour — `rg --json | face` should group
    /// by the nested ripgrep file path (`data.path.text`), not the
    /// top-level record `type` discriminator.
    #[test]
    fn rg_json_auto_groups_by_nested_path() {
        let stdin = "\
{\"type\":\"begin\",\"data\":{\"path\":{\"text\":\"Modules/Share/A.swift\"}}}\n\
{\"type\":\"match\",\"data\":{\"path\":{\"text\":\"Modules/Share/A.swift\"},\"lines\":{\"text\":\"let viewModel = AViewModel()\"}}}\n\
{\"type\":\"end\",\"data\":{\"path\":{\"text\":\"Modules/Share/A.swift\"}}}\n\
{\"type\":\"begin\",\"data\":{\"path\":{\"text\":\"Modules/Room/B.swift\"}}}\n\
{\"type\":\"match\",\"data\":{\"path\":{\"text\":\"Modules/Room/B.swift\"},\"lines\":{\"text\":\"let viewModel = BViewModel()\"}}}\n\
{\"type\":\"end\",\"data\":{\"path\":{\"text\":\"Modules/Room/B.swift\"}}}\n\
{\"type\":\"summary\",\"data\":{\"elapsed_total\":{\"secs\":0,\"nanos\":1}}}\n";
        let (code, stdout, stderr) = drive(&["face", "--verbose"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(
            stderr.contains("items by data.path.text"),
            "verbose provenance names nested rg path: stderr={stderr:?}",
        );
        assert!(
            stdout.contains("Modules/Share/A.swift") && stdout.contains("Modules/Room/B.swift"),
            "stdout clusters by file path: {stdout:?}",
        );
        assert!(
            !stdout.contains("match"),
            "stdout should not cluster by rg record type: {stdout:?}",
        );
    }

    /// Acceptance: docs/design.md §8 — `--format=json` produces a
    /// parseable envelope with top-level `result` and `clusters` keys.
    #[test]
    fn respects_format_json_flag() {
        let stdin = "{\"kind\":\"bug\"}\n{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
        let (code, stdout, stderr) = drive(&["face", "--format=json"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        let v: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is parseable JSON");
        assert!(v.get("result").is_some(), "envelope has `result` key");
        assert!(v.get("clusters").is_some(), "envelope has `clusters` key");
    }

    /// Acceptance: docs/design.md §8 — `--format=tsv` starts with the
    /// header row (`id\tlabel\tcount\t…`).
    #[test]
    fn respects_format_tsv_flag() {
        let stdin = "{\"kind\":\"bug\"}\n{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
        let (code, stdout, stderr) = drive(&["face", "--format=tsv"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        let header = stdout.lines().next().unwrap_or("");
        assert!(header.contains("id"), "tsv header has `id`: {header:?}");
        assert!(
            header.contains("label"),
            "tsv header has `label`: {header:?}",
        );
        assert!(
            header.contains("count"),
            "tsv header has `count`: {header:?}",
        );
    }

    /// Acceptance: docs/design.md §8 — `--format=markdown` emits H2
    /// headings per top-level cluster.
    #[test]
    fn markdown_format() {
        let stdin = "{\"kind\":\"bug\"}\n{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
        let (code, stdout, stderr) = drive(&["face", "--format=markdown"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(
            stdout.contains("## bug"),
            "markdown should include `## bug`: {stdout:?}",
        );
    }
}

mod json_input {
    use super::drive;

    /// Acceptance: docs/design.md §4.2 — a top-level JSON array becomes
    /// the items list directly.
    #[test]
    fn top_level_array() {
        let stdin = "[{\"kind\":\"bug\"},{\"kind\":\"bug\"},{\"kind\":\"feat\"}]";
        let (code, stdout, stderr) = drive(&["face"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(stdout.contains("bug"), "stdout contains bug: {stdout:?}");
        assert!(stdout.contains("feat"), "stdout contains feat: {stdout:?}");
    }

    /// Acceptance: docs/design.md §4.1 — a valid JSON value larger
    /// than the sniff probe is still routed to the full JSON parser.
    #[test]
    fn large_pretty_array_past_probe() {
        let stdin = format!(
            "[\n  {{\"kind\":\"bug\",\"text\":\"{}\"}},\n  {{\"kind\":\"feat\",\"text\":\"done\"}}\n]\n",
            "x".repeat(8192)
        );
        let (code, stdout, stderr) = drive(&["face"], &stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(stdout.contains("bug"), "stdout contains bug: {stdout:?}");
        assert!(stdout.contains("feat"), "stdout contains feat: {stdout:?}");
    }

    /// Acceptance: docs/design.md §4.2 — a JSON object with a single
    /// named candidate array (`items`) is auto-detected.
    #[test]
    fn object_with_named_items() {
        let stdin = "{\"items\":[{\"kind\":\"bug\"},{\"kind\":\"feat\"}]}";
        let (code, stdout, stderr) = drive(&["face"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(stdout.contains("bug"), "stdout contains bug: {stdout:?}");
        assert!(stdout.contains("feat"), "stdout contains feat: {stdout:?}");
    }
}

mod delimited_input {
    use super::drive;

    /// Acceptance: docs/design.md §4.1 — CSV input is sniffed and
    /// parsed into records that the normal grouping pipeline consumes.
    #[test]
    fn csv_with_header_groups() {
        let stdin = "kind,severity\nbug,high\nbug,low\nfeat,low\n";
        let (code, stdout, stderr) = drive(&["face"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(stdout.contains("bug"), "stdout contains bug: {stdout:?}");
        assert!(stdout.contains("feat"), "stdout contains feat: {stdout:?}");
    }

    /// Acceptance: docs/design.md §3 INPUT — `--columns` names
    /// headerless CSV fields.
    #[test]
    fn csv_headerless_with_columns() {
        let stdin = "bug,high\nfeat,low\n";
        let (code, stdout, stderr) = drive(
            &["face", "--format-in=csv", "--columns=kind,severity"],
            stdin,
        );
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(stdout.contains("bug"), "stdout contains bug: {stdout:?}");
        assert!(stdout.contains("feat"), "stdout contains feat: {stdout:?}");
    }

    /// Acceptance: docs/design.md §4.1 — TSV input is classified as a
    /// delimited record stream.
    #[test]
    fn tsv_with_header_groups() {
        let stdin = "kind\tseverity\nbug\thigh\nfeat\tlow\n";
        let (code, stdout, stderr) = drive(&["face"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(stdout.contains("bug"), "stdout contains bug: {stdout:?}");
        assert!(stdout.contains("feat"), "stdout contains feat: {stdout:?}");
    }
}

mod with_score {
    use super::drive;

    /// Acceptance: docs/design.md §4.3 + §4.5 — when a `score` field is
    /// present, the auto-strategy is single-axis Bands on the score.
    /// Band labels render as `lo–hi` with U+2013 EN DASH.
    #[test]
    fn score_detected_uses_bands() {
        let stdin = "{\"score\":0.9,\"name\":\"a\"}\n\
                     {\"score\":0.5,\"name\":\"b\"}\n\
                     {\"score\":0.1,\"name\":\"c\"}\n";
        let (code, stdout, stderr) = drive(&["face"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        // Soft-match on the en-dash that the Bands strategy uses for
        // its labels — the exact band boundaries depend on auto-pick
        // (§5.2 default `--bands=5`) and are not pinned here.
        assert!(
            stdout.contains('\u{2013}'),
            "expected en-dash band label in stdout: {stdout:?}",
        );
    }

    /// Acceptance: docs/design.md §3 SCORE flags — `--score=PATH`
    /// overrides auto-detection and reads from a non-default field name.
    #[test]
    fn explicit_score_path() {
        let stdin = "{\"value\":0.9,\"name\":\"a\"}\n\
                     {\"value\":0.5,\"name\":\"b\"}\n\
                     {\"value\":0.1,\"name\":\"c\"}\n";
        let (code, stdout, stderr) = drive(&["face", "--score=.value"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        // Same en-dash signal as the auto case — the strategy still
        // resolves to numeric Bands once a score path is in use.
        assert!(
            stdout.contains('\u{2013}'),
            "expected band label in stdout: {stdout:?}",
        );
    }
}