face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for `--format=tsv` and `--format=csv` (§8 of `docs/design.md`).
//!
//! Pins:
//! - Header row appears first; columns: `id, label, count, score_min, score_max, axis_depth`.
//! - One row per cluster (depth-first); root + children counted.
//! - `score_min`/`score_max` cells empty when `None`.
//! - TSV: tabs inside labels are replaced (no literal tab in cells).
//! - CSV: RFC-4180 quoting for embedded `,`, `"`, and newlines.

use face_core::{Envelope, OutputFormat, RenderOptions, render};
use serde_json::json;

/// Two-axis fixture: 1 outer cluster + 2 inner clusters = 3 total.
fn fixture_two_axis() -> Envelope {
    serde_json::from_value(json!({
        "result": {
            "input_total": 38,
            "skipped": 0,
            "axes": [
                { "field": "kind", "strategy": "exact", "auto": false },
                { "field": "score", "strategy": "bands", "count": 3, "auto": true }
            ],
            "detection": {
                "format": "json",
                "items_path": ".",
                "score_path": "score",
                "preset": null,
                "fallback_reason": null
            }
        },
        "meta": {},
        "clusters": [
            {
                "id": "kind:bug",
                "label": "bug",
                "axis": "kind",
                "value": "bug",
                "total": 20,
                "score_min": 0.7,
                "score_max": 0.95,
                "clusters": [
                    {
                        "id": "kind:bug,score:excellent",
                        "label": "excellent",
                        "axis": "score",
                        "value": "excellent",
                        "total": 12,
                        "score_min": 0.85,
                        "score_max": 0.95,
                        "clusters": []
                    },
                    {
                        "id": "kind:bug,score:strong",
                        "label": "strong",
                        "axis": "score",
                        "value": "strong",
                        "total": 8,
                        "score_min": 0.7,
                        "score_max": 0.85,
                        "clusters": []
                    }
                ]
            }
        ],
        "page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
    }))
    .expect("two-axis fixture envelope")
}

/// Single-cluster fixture with a custom label, used to test escaping.
fn fixture_with_label(label: &str) -> Envelope {
    serde_json::from_value(json!({
        "result": {
            "input_total": 1,
            "skipped": 0,
            "axes": [{ "field": "kind", "strategy": "exact", "auto": false }],
            "detection": {
                "format": "json",
                "items_path": ".",
                "score_path": null,
                "preset": null,
                "fallback_reason": null
            }
        },
        "meta": {},
        "clusters": [
            {
                "id": "kind:x",
                "label": label,
                "axis": "kind",
                "value": "x",
                "total": 1,
                "score_min": null,
                "score_max": null,
                "clusters": []
            }
        ],
        "page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
    }))
    .expect("label fixture")
}

/// Single-cluster fixture with `score_min`/`score_max` left as `None`.
fn fixture_no_scores() -> Envelope {
    serde_json::from_value(json!({
        "result": {
            "input_total": 1,
            "skipped": 0,
            "axes": [{ "field": "kind", "strategy": "exact", "auto": false }],
            "detection": {
                "format": "json",
                "items_path": ".",
                "score_path": null,
                "preset": null,
                "fallback_reason": null
            }
        },
        "meta": {},
        "clusters": [
            {
                "id": "kind:bug",
                "label": "bug",
                "axis": "kind",
                "value": "bug",
                "total": 5,
                "score_min": null,
                "score_max": null,
                "clusters": []
            }
        ],
        "page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
    }))
    .expect("no-scores fixture")
}

/// Expected column order, locked by the slice-8 brief.
const EXPECTED_COLUMNS: &[&str] = &[
    "id",
    "label",
    "count",
    "score_min",
    "score_max",
    "axis_depth",
];

mod tsv {
    //! TSV: tab-separated, no quoting; tabs in labels replaced with a
    //! safe character.

    use super::*;

    fn tsv(env: &Envelope) -> String {
        render(env, OutputFormat::Tsv, &RenderOptions::default()).expect("render tsv")
    }

    #[test]
    fn header_present() {
        let env = fixture_two_axis();
        let out = tsv(&env);
        let first = out.lines().next().expect("at least one line");
        // Header is tab-separated.
        let cells: Vec<&str> = first.split('\t').collect();
        assert_eq!(
            cells.len(),
            EXPECTED_COLUMNS.len(),
            "header column count mismatch; first line: {first:?}",
        );
    }

    #[test]
    fn column_order() {
        let env = fixture_two_axis();
        let out = tsv(&env);
        let first = out.lines().next().expect("header line");
        let cells: Vec<&str> = first.split('\t').collect();
        for (i, expected) in EXPECTED_COLUMNS.iter().enumerate() {
            assert_eq!(
                cells.get(i).copied(),
                Some(*expected),
                "column {i} mismatch in header {first:?}",
            );
        }
    }

    #[test]
    fn row_per_cluster_depth_first() {
        let env = fixture_two_axis();
        let out = tsv(&env);
        // 1 header + 3 data rows = 4 non-empty lines.
        let row_count = out.lines().filter(|l| !l.is_empty()).count();
        assert_eq!(
            row_count, 4,
            "expected 1 header + 3 cluster rows; got {row_count}; output:\n{out}",
        );
    }

    #[test]
    fn score_min_max_empty_when_none() {
        let env = fixture_no_scores();
        let out = tsv(&env);
        // Row index 1 is the single cluster row.
        let row = out.lines().nth(1).expect("data row present");
        let cells: Vec<&str> = row.split('\t').collect();
        // Per locked column order: index 3 = score_min, 4 = score_max.
        assert_eq!(
            cells.get(3).copied(),
            Some(""),
            "score_min cell should be empty for None; row: {row:?}",
        );
        assert_eq!(
            cells.get(4).copied(),
            Some(""),
            "score_max cell should be empty for None; row: {row:?}",
        );
    }

    #[test]
    fn tab_in_label_replaced_with_space() {
        let env = fixture_with_label("bad\tlabel");
        let out = tsv(&env);
        let row = out.lines().nth(1).expect("data row present");
        let cells: Vec<&str> = row.split('\t').collect();
        // Per the locked column order, label is column 1.
        let label_cell = cells.get(1).copied().expect("label cell");
        assert!(
            !label_cell.contains('\t'),
            "label cell must not contain a literal tab; got {label_cell:?}",
        );
        // Soft-match: the original label content (sans tab) is still recognizable.
        assert!(
            label_cell.contains("bad") && label_cell.contains("label"),
            "tab replacement should preserve neighboring text; got {label_cell:?}",
        );
    }
}

mod csv {
    //! CSV: RFC-4180 quoting on embedded `,`, `"`, and newlines.

    use super::*;

    fn csv(env: &Envelope) -> String {
        render(env, OutputFormat::Csv, &RenderOptions::default()).expect("render csv")
    }

    #[test]
    fn header_present() {
        let env = fixture_two_axis();
        let out = csv(&env);
        let first = out.lines().next().expect("at least one line");
        // RFC-4180 header: column names joined by `,`. None of the
        // expected names contain quoting-triggering chars, so a plain
        // split on `,` is enough.
        let cells: Vec<&str> = first.split(',').collect();
        assert_eq!(
            cells.len(),
            EXPECTED_COLUMNS.len(),
            "header column count mismatch; first line: {first:?}",
        );
        for (i, expected) in EXPECTED_COLUMNS.iter().enumerate() {
            assert_eq!(
                cells.get(i).copied(),
                Some(*expected),
                "column {i} mismatch in CSV header {first:?}",
            );
        }
    }

    #[test]
    fn quotes_label_with_comma() {
        let env = fixture_with_label("a,b");
        let out = csv(&env);
        let row = out.lines().nth(1).expect("data row present");
        // RFC-4180: a label containing `,` must be quoted.
        assert!(
            row.contains("\"a,b\""),
            "expected quoted label `\"a,b\"`; row: {row:?}",
        );
    }

    #[test]
    fn escapes_embedded_quote() {
        // Per RFC-4180 §2.7: embedded `"` is escaped as `""` and the
        // entire field is wrapped in quotes.
        let env = fixture_with_label("a\"b");
        let out = csv(&env);
        let row = out.lines().nth(1).expect("data row present");
        assert!(
            row.contains("\"a\"\"b\""),
            "expected RFC-4180 escaped quote `\"a\"\"b\"`; row: {row:?}",
        );
    }

    #[test]
    fn quotes_label_with_newline() {
        let env = fixture_with_label("a\nb");
        let out = csv(&env);
        // The newline lives inside a quoted field and should not split
        // the data row at the line level. Quote-aware parsing sees
        // header + 1 data row; a naive line count sees header + 2.
        // Soft-match: after the header we should see an opening quote
        // and the quoted content.
        let after_header = out
            .split_once('\n')
            .map(|(_, rest)| rest)
            .expect("header line followed by data");
        assert!(
            after_header.contains("\"a\nb\""),
            "expected quoted multi-line label; output:\n{out}",
        );
    }

    #[test]
    fn score_columns_empty_when_none() {
        let env = fixture_no_scores();
        let out = csv(&env);
        let row = out.lines().nth(1).expect("data row present");
        // Per locked column order: id,label,count,score_min,score_max,axis_depth.
        // For this fixture neither id (`kind:bug`) nor label (`bug`) needs
        // quoting, so a plain split on `,` exposes the cells directly.
        let cells: Vec<&str> = row.split(',').collect();
        assert_eq!(
            cells.get(3).copied(),
            Some(""),
            "score_min cell should be empty for None; row: {row:?}",
        );
        assert_eq!(
            cells.get(4).copied(),
            Some(""),
            "score_max cell should be empty for None; row: {row:?}",
        );
    }
}