face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! `--format=tsv` and `--format=csv` rendering.
//!
//! Both formats share a column layout — `id, label, count, score_min,
//! score_max, axis_depth` — and emit one row per cluster in depth-first
//! order. TSV strips embedded tabs in labels (no quoting mechanism in
//! TSV); CSV applies RFC-4180 quoting on fields containing `,`, `"`,
//! `\n`, or `\r`.

use crate::{Cluster, Envelope};

const HEADER_COLS: [&str; 6] = [
    "id",
    "label",
    "count",
    "score_min",
    "score_max",
    "axis_depth",
];

/// `--format=tsv`: tab-separated rows with a header line.
pub(super) fn render_tsv(envelope: &Envelope) -> String {
    let mut out = String::new();
    out.push_str(&HEADER_COLS.join("\t"));
    out.push('\n');
    for cluster in &envelope.clusters {
        write_tsv_rows(&mut out, cluster, 0);
    }
    out
}

/// `--format=csv`: RFC-4180 quoted, comma-separated rows with a header
/// line.
pub(super) fn render_csv(envelope: &Envelope) -> String {
    let mut out = String::new();
    out.push_str(
        &HEADER_COLS
            .iter()
            .map(|c| csv_field(c))
            .collect::<Vec<_>>()
            .join(","),
    );
    out.push('\n');
    for cluster in &envelope.clusters {
        write_csv_rows(&mut out, cluster, 0);
    }
    out
}

fn write_tsv_rows(out: &mut String, cluster: &Cluster, depth: usize) {
    let row = [
        cluster.id.to_string(),
        tsv_label(&cluster.label),
        cluster.total.to_string(),
        format_score(cluster.score_min),
        format_score(cluster.score_max),
        depth.to_string(),
    ];
    out.push_str(&row.join("\t"));
    out.push('\n');
    for child in &cluster.clusters {
        write_tsv_rows(out, child, depth + 1);
    }
}

fn write_csv_rows(out: &mut String, cluster: &Cluster, depth: usize) {
    // `ClusterId::Display` already quotes values per §6.1, so the
    // rendered id may itself contain commas or quotes. Treat the whole
    // rendering as one CSV field and apply RFC-4180 escaping on top —
    // CSV decoders don't know about §6.1 cluster-id quoting.
    let row = [
        csv_field(&cluster.id.to_string()),
        csv_field(&cluster.label),
        csv_field(&cluster.total.to_string()),
        csv_field(&format_score(cluster.score_min)),
        csv_field(&format_score(cluster.score_max)),
        csv_field(&depth.to_string()),
    ];
    out.push_str(&row.join(","));
    out.push('\n');
    for child in &cluster.clusters {
        write_csv_rows(out, child, depth + 1);
    }
}

/// TSV has no escape mechanism; the only safe transform is to strip
/// embedded tabs (and \r/\n which would terminate the row). Replace
/// each with a single space.
fn tsv_label(s: &str) -> String {
    s.chars()
        .map(|c| match c {
            '\t' | '\n' | '\r' => ' ',
            other => other,
        })
        .collect()
}

/// RFC-4180: quote a field with `"..."` if it contains `,`, `"`, `\n`,
/// or `\r`; embedded `"` becomes `""`.
fn csv_field(s: &str) -> String {
    let needs_quoting = s.contains([',', '"', '\n', '\r']);
    if !needs_quoting {
        return s.to_string();
    }
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        if c == '"' {
            out.push_str("\"\"");
        } else {
            out.push(c);
        }
    }
    out.push('"');
    out
}

/// `score_min` / `score_max` are `Option<f64>`. `None` renders as the
/// empty string (an empty CSV/TSV cell); present values get four
/// decimal digits — wider than the human format's two-digit display so
/// downstream spreadsheets retain precision.
fn format_score(score: Option<f64>) -> String {
    match score {
        None => String::new(),
        Some(v) => format!("{v:.4}"),
    }
}

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

    fn cluster_with(
        axis: &str,
        value: &str,
        label: &str,
        total: u64,
        score: Option<(f64, f64)>,
        children: Vec<Cluster>,
    ) -> Cluster {
        let (score_min, score_max) = match score {
            Some((lo, hi)) => (Some(lo), Some(hi)),
            None => (None, None),
        };
        Cluster {
            id: ClusterId::new(vec![ClusterIdSegment {
                axis: axis.into(),
                value: value.into(),
            }]),
            label: label.to_string(),
            axis: axis.to_string(),
            value: json!(value),
            total,
            score_min,
            score_max,
            clusters: children,
        }
    }

    fn env_with(clusters: Vec<Cluster>) -> Envelope {
        Envelope {
            clusters,
            ..Envelope::default()
        }
    }

    #[test]
    fn tsv_header_and_rows() {
        let env = env_with(vec![cluster_with(
            "file",
            "src/cli.rs",
            "src/cli.rs",
            38,
            Some((0.7, 0.95)),
            vec![],
        )]);
        let out = render(&env, OutputFormat::Tsv, &RenderOptions::default()).unwrap();
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(
            lines[0], "id\tlabel\tcount\tscore_min\tscore_max\taxis_depth",
            "header line"
        );
        let cells: Vec<&str> = lines[1].split('\t').collect();
        assert_eq!(cells.len(), 6);
        assert_eq!(cells[0], "file:src/cli.rs");
        assert_eq!(cells[1], "src/cli.rs");
        assert_eq!(cells[2], "38");
        assert_eq!(cells[3], "0.7000");
        assert_eq!(cells[4], "0.9500");
        assert_eq!(cells[5], "0");
    }

    #[test]
    fn tsv_strips_embedded_tabs_in_label() {
        let env = env_with(vec![cluster_with(
            "k",
            "v",
            "before\tafter",
            1,
            None,
            vec![],
        )]);
        let out = render(&env, OutputFormat::Tsv, &RenderOptions::default()).unwrap();
        let row = out.lines().nth(1).unwrap();
        let cells: Vec<&str> = row.split('\t').collect();
        // 6 cells, no extra splits introduced by the embedded tab.
        assert_eq!(cells.len(), 6);
        assert_eq!(cells[1], "before after");
    }

    #[test]
    fn csv_quotes_special_chars() {
        let env = env_with(vec![cluster_with(
            "k",
            "v",
            "a, b \"quoted\"\nrow",
            1,
            None,
            vec![],
        )]);
        let out = render(&env, OutputFormat::Csv, &RenderOptions::default()).unwrap();
        // Header contains no quoting-required chars so it stays bare.
        assert!(out.starts_with("id,label,count,score_min,score_max,axis_depth\n"));
        // The single body row spans the rest of the output. The label
        // has a newline so the row spans past the first `\n`.
        let body = out
            .strip_prefix("id,label,count,score_min,score_max,axis_depth\n")
            .unwrap();
        assert!(
            body.contains("\"a, b \"\"quoted\"\"\nrow\""),
            "expected RFC-4180 quoting in body: {body:?}"
        );
    }

    #[test]
    fn csv_score_empty_when_none() {
        let env = env_with(vec![cluster_with("k", "v", "label", 7, None, vec![])]);
        let out = render(&env, OutputFormat::Csv, &RenderOptions::default()).unwrap();
        let row = out.lines().nth(1).unwrap();
        let cells: Vec<&str> = row.split(',').collect();
        assert_eq!(cells.len(), 6);
        assert_eq!(cells[3], "");
        assert_eq!(cells[4], "");
    }

    #[test]
    fn axis_depth_increases_with_nesting() {
        let inner = cluster_with("score", "excellent", "excellent", 12, None, vec![]);
        let outer = cluster_with("file", "src/cli.rs", "src/cli.rs", 38, None, vec![inner]);
        let env = env_with(vec![outer]);
        let out = render(&env, OutputFormat::Tsv, &RenderOptions::default()).unwrap();
        let depths: Vec<&str> = out
            .lines()
            .skip(1)
            .map(|l| l.split('\t').nth(5).unwrap())
            .collect();
        assert_eq!(depths, vec!["0", "1"]);
    }
}