Skip to main content

face_core/
format.rs

1//! Output rendering for the seven `--format=` styles (§8 of `docs/design.md`).
2//!
3//! [`OutputFormat`] is the controlled vocabulary; [`RenderOptions`]
4//! carries presentation knobs (color, width). [`render`] is the single
5//! entry point that dispatches to a per-format module:
6//!
7//! - [`human`]   — tree-rendered, ASCII-bar, width-aware (§8.3).
8//! - [`json_out`] — nested envelope, flat envelope, jsonl-items (§7, §7.1, §7.2).
9//! - [`delimited`] — TSV and RFC-4180 CSV.
10//! - [`markdown`] — Markdown headings + fenced item blocks.
11//!
12//! TTY detection, terminal-width detection, `NO_COLOR` / `CLICOLOR_FORCE`
13//! handling, and provenance lines are the CLI's responsibility — this
14//! module is pure: input is `&Envelope`, output is `String`.
15
16use serde::{Deserialize, Serialize};
17
18use crate::{Envelope, FaceError};
19
20mod delimited;
21mod human;
22mod json_out;
23mod markdown;
24
25/// Output format selected by `--format=STYLE`.
26///
27/// Wire form is kebab-case so `json-flat` and `jsonl-items` round-trip
28/// through `serde` exactly as the CLI accepts them. Defaults to
29/// [`OutputFormat::Human`] per §8.1 (sticky regardless of TTY).
30#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
31#[serde(rename_all = "kebab-case")]
32#[non_exhaustive]
33pub enum OutputFormat {
34    /// Tree-rendered, ASCII-bar, width-aware (§8.3).
35    #[default]
36    Human,
37    /// Nested envelope per §7.
38    Json,
39    /// Flat envelope per §7.1 (wire form: `json-flat`).
40    JsonFlat,
41    /// One JSON item per line, no envelope (wire form: `jsonl-items`).
42    JsonlItems,
43    /// Tab-separated cluster summary.
44    Tsv,
45    /// RFC-4180 comma-separated cluster summary.
46    Csv,
47    /// Markdown — headings per cluster, fenced item blocks.
48    Markdown,
49}
50
51/// Options controlling presentation.
52///
53/// The CLI wires TTY detection and terminal width into these; the
54/// library defaults are neutral (no color, no width cap) so that
55/// programmatic callers get deterministic output.
56#[derive(Debug, Clone, Default)]
57#[non_exhaustive]
58pub struct RenderOptions {
59    /// Emit ANSI color escapes. Only honored by [`OutputFormat::Human`].
60    pub color: bool,
61    /// Wrap the human format to this many columns. Bars are dropped
62    /// first, then score ranges, when a line exceeds the width.
63    /// `None` means no width cap.
64    pub width: Option<usize>,
65}
66
67/// Render an envelope as the chosen format.
68///
69/// # Errors
70///
71/// Returns [`FaceError`] only when the underlying serializer fails.
72/// All current formats are infallible in practice, but the signature
73/// is `Result` so future formats (or upstream `serde_json` errors)
74/// surface cleanly without an API break.
75///
76/// # Examples
77///
78/// ```
79/// use face_core::{render, Envelope, OutputFormat, RenderOptions};
80///
81/// let env = Envelope::default();
82/// let out = render(&env, OutputFormat::Human, &RenderOptions::default()).unwrap();
83/// // Empty envelope renders as just the header line.
84/// assert!(out.starts_with("face: 0 items"));
85/// ```
86pub fn render(
87    envelope: &Envelope,
88    format: OutputFormat,
89    opts: &RenderOptions,
90) -> Result<String, FaceError> {
91    match format {
92        OutputFormat::Human => Ok(human::render(envelope, opts)),
93        OutputFormat::Json => json_out::render_nested(envelope),
94        OutputFormat::JsonFlat => json_out::render_flat(envelope),
95        OutputFormat::JsonlItems => Ok(json_out::render_jsonl_items(envelope)),
96        OutputFormat::Tsv => Ok(delimited::render_tsv(envelope)),
97        OutputFormat::Csv => Ok(delimited::render_csv(envelope)),
98        OutputFormat::Markdown => Ok(markdown::render(envelope)),
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn round_trip_format_serde() {
108        // The wire form for each variant is the kebab-case name. The
109        // round-trip catches any future refactor that flips the rename
110        // mode (camelCase, snake_case, etc.) without updating the spec.
111        let cases = [
112            (OutputFormat::Human, "\"human\""),
113            (OutputFormat::Json, "\"json\""),
114            (OutputFormat::JsonFlat, "\"json-flat\""),
115            (OutputFormat::JsonlItems, "\"jsonl-items\""),
116            (OutputFormat::Tsv, "\"tsv\""),
117            (OutputFormat::Csv, "\"csv\""),
118            (OutputFormat::Markdown, "\"markdown\""),
119        ];
120        for (fmt, wire) in cases {
121            let s = serde_json::to_string(&fmt).unwrap();
122            assert_eq!(s, wire, "serialize {fmt:?}");
123            let back: OutputFormat = serde_json::from_str(wire).unwrap();
124            assert_eq!(back, fmt, "deserialize {wire}");
125        }
126    }
127
128    #[test]
129    fn default_is_human() {
130        assert_eq!(OutputFormat::default(), OutputFormat::Human);
131    }
132
133    #[test]
134    fn render_dispatches_to_each_format() {
135        // Smoke: each format produces *something* on a default envelope
136        // without erroring. Per-format shape is tested in the format
137        // submodules; this just confirms dispatch wiring.
138        let env = Envelope::default();
139        let opts = RenderOptions::default();
140        for fmt in [
141            OutputFormat::Human,
142            OutputFormat::Json,
143            OutputFormat::JsonFlat,
144            OutputFormat::JsonlItems,
145            OutputFormat::Tsv,
146            OutputFormat::Csv,
147            OutputFormat::Markdown,
148        ] {
149            let _ = render(&env, fmt, &opts).unwrap();
150        }
151    }
152}