face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! `--format=markdown` rendering.
//!
//! Useful for pasting into a PR / issue / doc:
//!
//! - `# face` H1 with a `total_items: N` line below.
//! - Each top-level cluster becomes an H2; nesting bumps the heading
//!   level (H2 → H3 → H4).
//! - Counts and score ranges are emitted as bold-prefixed lines under
//!   each heading.
//! - When the page block carries items, they trail in a fenced
//!   ```` ```json ```` block.
//!
//! Heading level caps at H6 (Markdown's deepest); deeper nesting falls
//! back to a bold-bullet form so the document stays well-formed.

use crate::{Cluster, Envelope};

/// Top-level markdown render entry point.
pub(super) fn render(envelope: &Envelope) -> String {
    let mut out = String::new();
    out.push_str("# face\n\n");
    out.push_str(&format!("total_items: {}\n", envelope.result.input_total));

    if !envelope.clusters.is_empty() {
        out.push('\n');
        for cluster in &envelope.clusters {
            write_cluster(&mut out, cluster, 2);
        }
    }

    if !envelope.page.items.is_empty() {
        write_page_items(&mut out, &envelope.page.items);
    }

    out
}

fn write_cluster(out: &mut String, cluster: &Cluster, level: usize) {
    let level = level.min(6);
    let hashes = "#".repeat(level);
    out.push_str(&format!("{hashes} {}\n", escape_label(&cluster.label)));
    out.push_str(&format!("- count: {}\n", cluster.total));
    if let (Some(lo), Some(hi)) = (cluster.score_min, cluster.score_max) {
        // Same en-dash as the human format (§8.3) so the rendering
        // reads consistently across formats.
        out.push_str(&format!("- score: {lo:.2}\u{2013}{hi:.2}\n"));
    }
    out.push('\n');
    for child in &cluster.clusters {
        // Cap heading level at H6; deeper trees would otherwise emit
        // `#######` which most Markdown renderers downgrade silently.
        write_cluster(out, child, level + 1);
    }
}

/// Escape CommonMark special characters in a cluster label so an
/// adversarial value (e.g. `**bold**`, `# bold`, `` `code` ``) cannot
/// reshape the surrounding heading or bullet line.
///
/// The escape set follows CommonMark §2.4: `\` `` ` `` `*` `_` `{` `}`
/// `[` `]` `(` `)` `#` `+` `-` `.` `!` `|`. Each occurrence is
/// prefixed with a backslash. Whitespace and other punctuation
/// passes through unchanged.
fn escape_label(label: &str) -> String {
    const SPECIALS: &[char] = &[
        '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!', '|',
    ];
    let mut out = String::with_capacity(label.len());
    for c in label.chars() {
        if SPECIALS.contains(&c) {
            out.push('\\');
        }
        out.push(c);
    }
    out
}

/// Emit page items inside a fenced JSON code block. Picks a fence
/// length one tick longer than any backtick run found in the
/// serialized items, so a literal ` ``` ` inside an item cannot close
/// the fence early.
fn write_page_items(out: &mut String, items: &[serde_json::Value]) {
    // Pre-serialize so we can scan for backtick runs once.
    let serialized: Vec<String> = items
        .iter()
        .map(|item| {
            // `to_string` on a `serde_json::Value` is infallible — the
            // `Value` enum carries no non-string-keyed maps and no NaN
            // floats.
            serde_json::to_string(item).expect("serializing serde_json::Value is infallible")
        })
        .collect();

    let fence_len = pick_fence_length(&serialized);
    let fence = "`".repeat(fence_len);

    out.push('\n');
    out.push_str(&fence);
    out.push_str("json\n");
    for line in &serialized {
        out.push_str(line);
        out.push('\n');
    }
    out.push_str(&fence);
    out.push('\n');
}

/// Pick a fence length that's at least one tick longer than the
/// longest backtick run in any of the given lines, with a floor of
/// three (the conventional fence). CommonMark requires the closing
/// fence to be at least as long as the opener, so longer-than-content
/// guarantees the fence cannot close early.
fn pick_fence_length(serialized: &[String]) -> usize {
    let mut max_run = 0usize;
    for line in serialized {
        let mut run = 0usize;
        for c in line.chars() {
            if c == '`' {
                run += 1;
                if run > max_run {
                    max_run = run;
                }
            } else {
                run = 0;
            }
        }
    }
    (max_run + 1).max(3)
}

#[cfg(test)]
mod tests {
    use crate::format::{OutputFormat, RenderOptions, render};
    use crate::{Cluster, ClusterId, ClusterIdSegment, Envelope};
    use serde_json::json;

    fn cluster(axis: &str, value: &str, total: u64, children: Vec<Cluster>) -> Cluster {
        Cluster {
            id: ClusterId::new(vec![ClusterIdSegment {
                axis: axis.into(),
                value: value.into(),
            }]),
            label: value.to_string(),
            axis: axis.to_string(),
            value: json!(value),
            total,
            score_min: None,
            score_max: None,
            clusters: children,
        }
    }

    fn env_with(clusters: Vec<Cluster>, total: u64) -> Envelope {
        Envelope {
            result: crate::ResultBlock {
                input_total: total,
                ..crate::ResultBlock::default()
            },
            clusters,
            ..Envelope::default()
        }
    }

    #[test]
    fn headings_per_nesting_level() {
        let inner = cluster("score", "excellent", 12, vec![]);
        let outer = cluster("file", "src/cli.rs", 38, vec![inner]);
        let env = env_with(vec![outer], 38);
        let out = render(&env, OutputFormat::Markdown, &RenderOptions::default()).unwrap();
        assert!(out.starts_with("# face\n"));
        assert!(out.contains("total_items: 38"));
        // Labels are CommonMark-escaped: `.` becomes `\.` so an
        // adversarial label cannot reshape the heading. The plain `/`
        // is not a special character and passes through unchanged.
        assert!(out.contains("\n## src/cli\\.rs\n"), "{out}");
        assert!(out.contains("\n### excellent\n"));
        assert!(out.contains("- count: 38"));
        assert!(out.contains("- count: 12"));
    }

    #[test]
    fn heading_level_caps_at_six() {
        // Six levels of nesting should still produce well-formed
        // headings rather than runaway ###### chains.
        let mut leaf = cluster("a", "leaf", 1, vec![]);
        for i in 0..6 {
            leaf = cluster("a", &format!("level-{i}"), 1, vec![leaf]);
        }
        let env = env_with(vec![leaf], 1);
        let out = render(&env, OutputFormat::Markdown, &RenderOptions::default()).unwrap();
        // No headings beyond H6.
        assert!(!out.contains("####### "));
        assert!(out.contains("###### "));
    }

    #[test]
    fn page_items_emitted_as_fenced_json() {
        let env = Envelope {
            page: crate::Page {
                items: vec![json!({"text": "hello"}), json!({"text": "world"})],
                ..crate::Page::default()
            },
            ..env_with(vec![], 0)
        };
        let out = render(&env, OutputFormat::Markdown, &RenderOptions::default()).unwrap();
        assert!(out.contains("```json\n"));
        assert!(out.contains("{\"text\":\"hello\"}\n"));
        assert!(out.contains("{\"text\":\"world\"}\n"));
        assert!(out.contains("\n```\n"));
    }

    #[test]
    fn score_range_appears_when_set() {
        let env = env_with(
            vec![Cluster {
                score_min: Some(0.7),
                score_max: Some(0.85),
                ..cluster("file", "src/cli.rs", 1, vec![])
            }],
            1,
        );
        let out = render(&env, OutputFormat::Markdown, &RenderOptions::default()).unwrap();
        assert!(out.contains("- score: 0.70\u{2013}0.85"));
    }
}