face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Output rendering for the seven `--format=` styles (§8 of `docs/design.md`).
//!
//! [`OutputFormat`] is the controlled vocabulary; [`RenderOptions`]
//! carries presentation knobs (color, width). [`render`] is the single
//! entry point that dispatches to a per-format module:
//!
//! - [`human`]   — tree-rendered, ASCII-bar, width-aware (§8.3).
//! - [`json_out`] — nested envelope, flat envelope, jsonl-items (§7, §7.1, §7.2).
//! - [`delimited`] — TSV and RFC-4180 CSV.
//! - [`markdown`] — Markdown headings + fenced item blocks.
//!
//! TTY detection, terminal-width detection, `NO_COLOR` / `CLICOLOR_FORCE`
//! handling, and provenance lines are the CLI's responsibility — this
//! module is pure: input is `&Envelope`, output is `String`.

use serde::{Deserialize, Serialize};

use crate::{Envelope, FaceError};

mod delimited;
mod human;
mod json_out;
mod markdown;

/// Output format selected by `--format=STYLE`.
///
/// Wire form is kebab-case so `json-flat` and `jsonl-items` round-trip
/// through `serde` exactly as the CLI accepts them. Defaults to
/// [`OutputFormat::Human`] per §8.1 (sticky regardless of TTY).
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum OutputFormat {
    /// Tree-rendered, ASCII-bar, width-aware (§8.3).
    #[default]
    Human,
    /// Nested envelope per §7.
    Json,
    /// Flat envelope per §7.1 (wire form: `json-flat`).
    JsonFlat,
    /// One JSON item per line, no envelope (wire form: `jsonl-items`).
    JsonlItems,
    /// Tab-separated cluster summary.
    Tsv,
    /// RFC-4180 comma-separated cluster summary.
    Csv,
    /// Markdown — headings per cluster, fenced item blocks.
    Markdown,
}

/// Options controlling presentation.
///
/// The CLI wires TTY detection and terminal width into these; the
/// library defaults are neutral (no color, no width cap) so that
/// programmatic callers get deterministic output.
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RenderOptions {
    /// Emit ANSI color escapes. Only honored by [`OutputFormat::Human`].
    pub color: bool,
    /// Wrap the human format to this many columns. Bars are dropped
    /// first, then score ranges, when a line exceeds the width.
    /// `None` means no width cap.
    pub width: Option<usize>,
}

/// Render an envelope as the chosen format.
///
/// # Errors
///
/// Returns [`FaceError`] only when the underlying serializer fails.
/// All current formats are infallible in practice, but the signature
/// is `Result` so future formats (or upstream `serde_json` errors)
/// surface cleanly without an API break.
///
/// # Examples
///
/// ```
/// use face_core::{render, Envelope, OutputFormat, RenderOptions};
///
/// let env = Envelope::default();
/// let out = render(&env, OutputFormat::Human, &RenderOptions::default()).unwrap();
/// // Empty envelope renders as just the header line.
/// assert!(out.starts_with("face: 0 items"));
/// ```
pub fn render(
    envelope: &Envelope,
    format: OutputFormat,
    opts: &RenderOptions,
) -> Result<String, FaceError> {
    match format {
        OutputFormat::Human => Ok(human::render(envelope, opts)),
        OutputFormat::Json => json_out::render_nested(envelope),
        OutputFormat::JsonFlat => json_out::render_flat(envelope),
        OutputFormat::JsonlItems => Ok(json_out::render_jsonl_items(envelope)),
        OutputFormat::Tsv => Ok(delimited::render_tsv(envelope)),
        OutputFormat::Csv => Ok(delimited::render_csv(envelope)),
        OutputFormat::Markdown => Ok(markdown::render(envelope)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trip_format_serde() {
        // The wire form for each variant is the kebab-case name. The
        // round-trip catches any future refactor that flips the rename
        // mode (camelCase, snake_case, etc.) without updating the spec.
        let cases = [
            (OutputFormat::Human, "\"human\""),
            (OutputFormat::Json, "\"json\""),
            (OutputFormat::JsonFlat, "\"json-flat\""),
            (OutputFormat::JsonlItems, "\"jsonl-items\""),
            (OutputFormat::Tsv, "\"tsv\""),
            (OutputFormat::Csv, "\"csv\""),
            (OutputFormat::Markdown, "\"markdown\""),
        ];
        for (fmt, wire) in cases {
            let s = serde_json::to_string(&fmt).unwrap();
            assert_eq!(s, wire, "serialize {fmt:?}");
            let back: OutputFormat = serde_json::from_str(wire).unwrap();
            assert_eq!(back, fmt, "deserialize {wire}");
        }
    }

    #[test]
    fn default_is_human() {
        assert_eq!(OutputFormat::default(), OutputFormat::Human);
    }

    #[test]
    fn render_dispatches_to_each_format() {
        // Smoke: each format produces *something* on a default envelope
        // without erroring. Per-format shape is tested in the format
        // submodules; this just confirms dispatch wiring.
        let env = Envelope::default();
        let opts = RenderOptions::default();
        for fmt in [
            OutputFormat::Human,
            OutputFormat::Json,
            OutputFormat::JsonFlat,
            OutputFormat::JsonlItems,
            OutputFormat::Tsv,
            OutputFormat::Csv,
            OutputFormat::Markdown,
        ] {
            let _ = render(&env, fmt, &opts).unwrap();
        }
    }
}