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}