face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for `--format=markdown` (§8 of `docs/design.md`).
//!
//! Pins:
//! - H1 for the envelope (or, soft-match, a top-level heading line).
//! - H2 per top-level cluster, H3 per nested.
//! - `result.input_total` and per-cluster `total` values appear.
//! - Page items are inside a fenced code block.
//!
//! Substring-level assertions throughout — no byte-for-byte matching.

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

fn fixture_envelope() -> Envelope {
    serde_json::from_value(json!({
        "result": {
            "input_total": 38,
            "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": 20,
                "score_min": 0.7,
                "score_max": 0.95,
                "clusters": []
            },
            {
                "id": "kind:feat",
                "label": "feat",
                "axis": "kind",
                "value": "feat",
                "total": 18,
                "score_min": 0.5,
                "score_max": 0.85,
                "clusters": []
            }
        ],
        "page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
    }))
    .expect("fixture envelope")
}

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")
}

fn fixture_with_page() -> Envelope {
    serde_json::from_value(json!({
        "result": {
            "input_total": 2,
            "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": 2,
                "score_min": null,
                "score_max": null,
                "clusters": []
            }
        ],
        "page": {
            "cluster_id": "kind:bug",
            "page": 1,
            "per_page": 20,
            "total_items": 2,
            "items": [
                { "title": "alpha" },
                { "title": "beta" }
            ]
        }
    }))
    .expect("fixture with page")
}

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

mod headings {
    //! Heading levels per spec: H1 envelope, H2 top-level cluster, H3 nested.

    use super::*;

    /// Lookup helper: does any line start with the given heading prefix
    /// (after optional leading whitespace)?
    fn has_heading_starting_with(out: &str, prefix: &str) -> bool {
        out.lines().any(|l| l.trim_start().starts_with(prefix))
    }

    #[test]
    fn h1_for_envelope_or_total_present() {
        let env = fixture_envelope();
        let out = md(&env);
        // Soft-match: at least one heading at the top level. Spec is
        // H1 for the envelope, but any leading heading (`#`, `##`, etc.)
        // is acceptable; the strictest reading "starts with `# `" is
        // covered by the broader contains check.
        let any_heading = out.lines().any(|l| {
            let t = l.trim_start();
            t.starts_with('#') && t.contains(' ')
        });
        assert!(any_heading, "expected at least one heading; output:\n{out}");
    }

    #[test]
    fn h2_per_top_level_cluster() {
        let env = fixture_envelope();
        let out = md(&env);
        // The brief locks H2 per top-level cluster — `## bug`, `## feat`.
        assert!(
            has_heading_starting_with(&out, "## bug"),
            "missing `## bug` heading; output:\n{out}",
        );
        assert!(
            has_heading_starting_with(&out, "## feat"),
            "missing `## feat` heading; output:\n{out}",
        );
    }

    #[test]
    fn h3_for_nested_cluster() {
        let env = fixture_two_axis();
        let out = md(&env);
        let h3_excellent = has_heading_starting_with(&out, "### excellent");
        let h3_strong = has_heading_starting_with(&out, "### strong");
        assert!(
            h3_excellent || h3_strong,
            "expected H3 for at least one inner cluster; output:\n{out}",
        );
    }
}

mod content {
    //! Counts: envelope total + per-cluster totals appear in the output.

    use super::*;

    #[test]
    fn total_count_present() {
        let env = fixture_envelope();
        let out = md(&env);
        assert!(
            out.contains("38"),
            "envelope input_total `38` missing; output:\n{out}",
        );
    }

    #[test]
    fn cluster_counts_appear() {
        let env = fixture_envelope();
        let out = md(&env);
        // Cluster totals (20 for bug, 18 for feat) should show up
        // somewhere inside their respective sections.
        assert!(
            out.contains("20"),
            "bug cluster total `20` missing; output:\n{out}"
        );
        assert!(
            out.contains("18"),
            "feat cluster total `18` missing; output:\n{out}"
        );
    }
}

mod page {
    //! Page items wrapped in a fenced code block.

    use super::*;

    #[test]
    fn page_items_in_fenced_block() {
        let env = fixture_with_page();
        let out = md(&env);
        // Soft-match: triple-backtick fence appears anywhere in the
        // output. The brief allows ` ```json ` or any fence variant.
        assert!(
            out.contains("```"),
            "expected a fenced code block for page items; output:\n{out}",
        );
    }
}

mod escaping {
    //! Adversarial cluster labels (markdown specials) must be escaped
    //! so the rendered output stays well-formed: a `**bold**` label
    //! cannot reshape the surrounding heading, and a `` ` `` label
    //! cannot inject inline code.

    use super::*;

    /// Build a fixture envelope where the top-level cluster's label
    /// contains markdown specials that would corrupt an unsafe render.
    fn fixture_with_adversarial_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": format!("kind:{label}"),
                    "label": label,
                    "axis": "kind",
                    "value": label,
                    "total": 1,
                    "score_min": null,
                    "score_max": null,
                    "clusters": []
                }
            ],
            "page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
        }))
        .expect("adversarial fixture envelope")
    }

    #[test]
    fn adversarial_label_does_not_break_headings() {
        // A label that, unescaped, would inject inline-bold or a
        // nested heading. After escaping, the H2 line still starts
        // with `## ` and the special characters appear as
        // backslash-escaped literals.
        let env = fixture_with_adversarial_label("**bold** label");
        let out = md(&env);

        // There must be a heading line for the cluster — start at H2
        // because top-level clusters render as H2.
        let heading_line = out
            .lines()
            .find(|l| l.starts_with("## "))
            .unwrap_or_else(|| panic!("expected an H2 heading line; output:\n{out}"));

        // The leading `**` from the label is escaped (`\*\*`).
        assert!(
            heading_line.contains(r"\*\*"),
            "asterisks in label should be backslash-escaped; heading={heading_line:?} output:\n{out}",
        );

        // The literal label characters survive in the heading (we
        // didn't drop them, just escaped them).
        assert!(
            heading_line.contains("bold"),
            "label substring `bold` survives escaping; heading={heading_line:?}",
        );

        // Sanity: no second-line heading-level injection (escaping
        // prevents a `# heading` label from spawning a runaway H1).
        let env2 = fixture_with_adversarial_label("# heading");
        let out2 = md(&env2);
        // The `#` from the label appears only inside the H2 line as
        // `\#`; no orphan H1 line elsewhere in the body.
        let stray_h1 = out2
            .lines()
            .filter(|l| l.starts_with("# ") && !l.starts_with("# face"))
            .count();
        assert_eq!(
            stray_h1, 0,
            "`# heading` label must not produce a stray H1; output:\n{out2}",
        );
    }
}