face 0.1.0

Fold And Cluster Entries — a Unix-style CLI for grouping, paging, and summarizing structured command output.
Documentation
//! Regression coverage for the ergonomic auto-detection fixes the
//! `grapha` workflow exposed: `::`-separated locators, dotted-namespace
//! paths, the multi-separator path-like check, the path-like grouping
//! candidate fallback, and the §11.2 `AmbiguousDetection` error block.
//!
//! Acceptance: docs/design.md §3 (CLI surface), §4.5 (auto-strategy +
//! grouping-field pick), §5.3 (Prefix), §11.2 (ambiguous detection
//! error). Each test pins the user-visible behavior end-to-end via the
//! `drive(args, stdin)` helper in `common::mod`.

mod common;

use clap::Parser;
use common::drive;
use face_cli::{Io, cli::Cli, run};
use std::io::Cursor;

/// Drive `face` and, if the run errors, return the rendered error
/// message the binary's `main()` would print on stderr (`face: {error}`).
/// The CLI proper does not write [`face_core::FaceError`]s to the `Io`
/// stderr handle — that's owned by `main.rs` — so this helper reproduces
/// that step in-process so the test can assert against the user-visible
/// text without spawning a child.
fn drive_capture_error(args: &[&str], stdin: &str) -> (i32, String, String) {
    let cli = Cli::try_parse_from(args).expect("argv parse");
    let mut io = Io::new(
        Cursor::new(stdin.as_bytes().to_vec()),
        Vec::<u8>::new(),
        Vec::<u8>::new(),
    );
    let outcome = run(cli, &mut io);
    let stdout = String::from_utf8(io.stdout).expect("stdout utf-8");
    let mut stderr = String::from_utf8(io.stderr).expect("stderr utf-8");
    let exit_code = match outcome {
        Ok(()) => 0,
        Err(err) => {
            stderr.push_str(&format!("face: {err}\n"));
            1
        }
    };
    (exit_code, stdout, stderr)
}

mod locator_axis {
    //! `::`-separated locators behave like paths under §4.5: when no
    //! lower-cardinality axis is available, `pick_grouping_field` uses
    //! the path-like pool to keep `locator` viable, and the Prefix
    //! clusterer's separator auto-detection picks `::`.

    use super::drive;

    /// Acceptance: docs/design.md §4.5 — for `grapha symbol search`-shaped
    /// data (uniform `name`/`kind`, unique `id`, high-cardinality
    /// `locator`), the locator wins as the auto axis and Prefix splits on
    /// `::` so the visible labels are the leading module names.
    #[test]
    fn rust_style_locator_clusters_by_module() {
        let stdin = "[\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a1\",\"locator\":\"Room::Inbox::open\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a2\",\"locator\":\"Room::Inbox::close\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a3\",\"locator\":\"Room::Inbox::send\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a4\",\"locator\":\"Room::User::login\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a5\",\"locator\":\"Room::User::logout\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a6\",\"locator\":\"Room::Audio::start\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a7\",\"locator\":\"Room::Audio::stop\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a8\",\"locator\":\"Room::Audio::mute\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a9\",\"locator\":\"Room::Audio::join\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a10\",\"locator\":\"Room::Audio::leave\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b1\",\"locator\":\"WebView::Page::load\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b2\",\"locator\":\"WebView::Page::reload\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b3\",\"locator\":\"WebView::Page::stop\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b4\",\"locator\":\"WebView::Frame::resize\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b5\",\"locator\":\"WebView::Frame::move\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"c1\",\"locator\":\"Wallet::Tx::send\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"c2\",\"locator\":\"Wallet::Tx::sign\"}\
        ]";
        let (code, stdout, stderr) = drive(&["face", "--verbose"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(
            stderr.contains("items by locator (auto, from json)"),
            "verbose provenance names locator as the auto axis: stderr={stderr:?}",
        );
        // Module names are the depth-1 `::` prefixes; render is count-desc.
        let room = stdout.find("Room").expect("Room cluster line present");
        let webview = stdout
            .find("WebView")
            .expect("WebView cluster line present");
        let wallet = stdout.find("Wallet").expect("Wallet cluster line present");
        assert!(
            room < webview && webview < wallet,
            "expected count-desc order Room > WebView > Wallet in stdout: {stdout:?}",
        );
    }

    /// Acceptance: docs/design.md §4.5 — when both a low-cardinality
    /// canonical-named field (`kind`) and a high-cardinality path-like
    /// field (`locator`) are available, the canonical preference still
    /// wins over the path-like fallback.
    #[test]
    fn low_cardinality_kind_beats_locator() {
        let stdin = "[\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a1\",\"locator\":\"Room::Inbox::open\"},\
            {\"name\":\"Sym\",\"kind\":\"struct\",\"id\":\"a2\",\"locator\":\"Room::Inbox::close\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a3\",\"locator\":\"Room::Inbox::send\"},\
            {\"name\":\"Sym\",\"kind\":\"enum\",\"id\":\"a4\",\"locator\":\"Room::User::login\"},\
            {\"name\":\"Sym\",\"kind\":\"trait\",\"id\":\"a5\",\"locator\":\"Room::User::logout\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a6\",\"locator\":\"Room::Audio::start\"},\
            {\"name\":\"Sym\",\"kind\":\"struct\",\"id\":\"a7\",\"locator\":\"Room::Audio::stop\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a8\",\"locator\":\"Room::Audio::mute\"},\
            {\"name\":\"Sym\",\"kind\":\"enum\",\"id\":\"a9\",\"locator\":\"Room::Audio::join\"},\
            {\"name\":\"Sym\",\"kind\":\"trait\",\"id\":\"a10\",\"locator\":\"Room::Audio::leave\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b1\",\"locator\":\"WebView::Page::load\"},\
            {\"name\":\"Sym\",\"kind\":\"struct\",\"id\":\"b2\",\"locator\":\"WebView::Page::reload\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b3\",\"locator\":\"WebView::Page::stop\"},\
            {\"name\":\"Sym\",\"kind\":\"enum\",\"id\":\"b4\",\"locator\":\"WebView::Frame::resize\"},\
            {\"name\":\"Sym\",\"kind\":\"trait\",\"id\":\"b5\",\"locator\":\"WebView::Frame::move\"},\
            {\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"c1\",\"locator\":\"Wallet::Tx::send\"},\
            {\"name\":\"Sym\",\"kind\":\"struct\",\"id\":\"c2\",\"locator\":\"Wallet::Tx::sign\"}\
        ]";
        let (code, stdout, stderr) = drive(&["face", "--verbose"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(
            stderr.contains("items by kind (auto, from json)"),
            "canonical `kind` should beat the path-like `locator` fallback: stderr={stderr:?}",
        );
        // Sanity: the kind enum values render, locator's module names do not
        // become the cluster labels.
        assert!(
            stdout.contains("function"),
            "stdout has the `function` enum cluster: {stdout:?}",
        );
    }
}

mod ambiguous_error {
    //! §11.2 — when no field qualifies as a grouping axis, the error
    //! message lists string and numeric candidates plus the dual hint.

    use super::{drive, drive_capture_error};

    /// Acceptance: docs/design.md §11.2 — `grapha repo modules`-shaped
    /// data (unique string `name`, several numeric metric fields) trips
    /// the ambiguous-detection error and the rendered block carries the
    /// per-section breakdown plus the `--score=` / `--by=` hint.
    #[test]
    fn lists_string_and_numeric_candidates_with_hint() {
        let stdin = "{\"modules\":[\
            {\"name\":\"core\",\"symbol_count\":42,\"edge_count\":12,\"coupling_ratio\":0.3},\
            {\"name\":\"cli\",\"symbol_count\":30,\"edge_count\":8,\"coupling_ratio\":0.27},\
            {\"name\":\"util\",\"symbol_count\":15,\"edge_count\":3,\"coupling_ratio\":0.2},\
            {\"name\":\"web\",\"symbol_count\":50,\"edge_count\":20,\"coupling_ratio\":0.4},\
            {\"name\":\"db\",\"symbol_count\":35,\"edge_count\":11,\"coupling_ratio\":0.31},\
            {\"name\":\"auth\",\"symbol_count\":18,\"edge_count\":5,\"coupling_ratio\":0.28},\
            {\"name\":\"net\",\"symbol_count\":22,\"edge_count\":7,\"coupling_ratio\":0.32},\
            {\"name\":\"render\",\"symbol_count\":40,\"edge_count\":15,\"coupling_ratio\":0.38},\
            {\"name\":\"audio\",\"symbol_count\":12,\"edge_count\":2,\"coupling_ratio\":0.17},\
            {\"name\":\"video\",\"symbol_count\":28,\"edge_count\":9,\"coupling_ratio\":0.32}\
        ]}";
        let (code, _stdout, stderr) = drive_capture_error(&["face"], stdin);
        assert_ne!(code, 0, "ambiguous detection is fatal: stderr={stderr:?}");
        assert!(
            stderr.contains("cannot auto-pick a grouping field"),
            "stderr names the §11.2 condition: {stderr:?}",
        );
        assert!(
            stderr.contains("string fields:"),
            "stderr has string-fields section: {stderr:?}",
        );
        assert!(
            stderr.contains("name (10 distinct)"),
            "stderr lists `name` with its cardinality: {stderr:?}",
        );
        assert!(
            stderr.contains("numeric fields:"),
            "stderr has numeric-fields section: {stderr:?}",
        );
        for field in ["symbol_count", "edge_count", "coupling_ratio"] {
            assert!(
                stderr.contains(field),
                "stderr lists numeric field `{field}`: {stderr:?}",
            );
        }
        assert!(stderr.contains("hint:"), "stderr has hint line: {stderr:?}",);
        assert!(
            stderr.contains("--score=FIELD"),
            "hint suggests --score=FIELD: {stderr:?}",
        );
        assert!(
            stderr.contains("--by=FIELD"),
            "hint suggests --by=FIELD: {stderr:?}",
        );
    }

    /// Acceptance: docs/design.md §11.2 — re-running the same input with
    /// `--score=symbol_count` (one of the numeric fields the hint
    /// surfaced) succeeds and the provenance line names the chosen axis.
    #[test]
    fn score_hint_follow_through_succeeds() {
        let stdin = "{\"modules\":[\
            {\"name\":\"core\",\"symbol_count\":42,\"edge_count\":12,\"coupling_ratio\":0.3},\
            {\"name\":\"cli\",\"symbol_count\":30,\"edge_count\":8,\"coupling_ratio\":0.27},\
            {\"name\":\"util\",\"symbol_count\":15,\"edge_count\":3,\"coupling_ratio\":0.2},\
            {\"name\":\"web\",\"symbol_count\":50,\"edge_count\":20,\"coupling_ratio\":0.4},\
            {\"name\":\"db\",\"symbol_count\":35,\"edge_count\":11,\"coupling_ratio\":0.31},\
            {\"name\":\"auth\",\"symbol_count\":18,\"edge_count\":5,\"coupling_ratio\":0.28},\
            {\"name\":\"net\",\"symbol_count\":22,\"edge_count\":7,\"coupling_ratio\":0.32},\
            {\"name\":\"render\",\"symbol_count\":40,\"edge_count\":15,\"coupling_ratio\":0.38},\
            {\"name\":\"audio\",\"symbol_count\":12,\"edge_count\":2,\"coupling_ratio\":0.17},\
            {\"name\":\"video\",\"symbol_count\":28,\"edge_count\":9,\"coupling_ratio\":0.32}\
        ]}";
        let (code, stdout, stderr) = drive(&["face", "--score=symbol_count", "--verbose"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(
            stderr.contains("items by symbol_count (auto, from json)"),
            "provenance names the score field as the axis: stderr={stderr:?}",
        );
    }
}

mod path_like_separators {
    //! Multi-separator path-like detection: `.` in addition to `/` and
    //! `::`. The dotted-namespace case auto-routes to Prefix and the
    //! file-extension-only case stays out of Prefix.

    use super::drive;

    /// Acceptance: docs/design.md §4.5 + §5.3 — dotted-namespace strings
    /// with a meaningful 2-segment split auto-route to Prefix, and
    /// auto-depth picks 2-segment labels (`com.acme`, `com.example`)
    /// rather than collapsing everything to depth 1 (`com`).
    #[test]
    fn dotted_namespace_uses_prefix_at_depth_two() {
        let stdin = "[\
            {\"namespace\":\"com.example.foo.Bar\"},\
            {\"namespace\":\"com.example.bar.Baz\"},\
            {\"namespace\":\"com.example.qux.Qux\"},\
            {\"namespace\":\"com.acme.alpha.Alpha\"},\
            {\"namespace\":\"com.acme.beta.Beta\"},\
            {\"namespace\":\"com.acme.gamma.Gamma\"}\
        ]";
        let (code, stdout, stderr) = drive(&["face", "--verbose"], stdin);
        assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
        assert!(
            stderr.contains("items by namespace (auto, from json)"),
            "provenance names namespace as auto axis: stderr={stderr:?}",
        );
        assert!(
            stdout.contains("com.acme") && stdout.contains("com.example"),
            "expected 2-segment dotted prefix labels in stdout: {stdout:?}",
        );
    }

    /// Acceptance: docs/design.md §4.5 — file-extension-only data
    /// (single-dot, no path-segment hierarchy) must not auto-pick
    /// Prefix. With small cardinality the auto-strategy resolves to
    /// Exact; the cluster set is one line per distinct value, never the
    /// `(other)`-mostly bucket that a path-prefix split would produce.
    #[test]
    fn file_extension_only_does_not_use_prefix() {
        let stdin = "[\
            {\"file\":\"Foo.swift\"},\
            {\"file\":\"Foo.swift\"},\
            {\"file\":\"Bar.swift\"},\
            {\"file\":\"Bar.swift\"},\
            {\"file\":\"Baz.kt\"},\
            {\"file\":\"Baz.kt\"}\
        ]";
        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 envelope");
        let strategy = v["result"]["axes"][0]["strategy"]
            .as_str()
            .expect("strategy on first axis");
        assert!(
            matches!(strategy, "exact" | "top"),
            "single-dot filename data must not pick prefix: strategy={strategy:?}, envelope={v}",
        );
        let labels: Vec<&str> = v["clusters"]
            .as_array()
            .expect("clusters[] is array")
            .iter()
            .filter_map(|c| c["label"].as_str())
            .collect();
        assert!(
            !labels.contains(&"(other)"),
            "no `(other)` bucket from prefix splitting: labels={labels:?}",
        );
        // Each distinct file name should be its own cluster.
        for value in ["Foo.swift", "Bar.swift", "Baz.kt"] {
            assert!(
                labels.contains(&value),
                "expected distinct cluster for `{value}`: labels={labels:?}",
            );
        }
    }
}