Skip to main content

rss_cli/
output.rs

1//! Rendering of results to json / ndjson / text, plus JSON Schema emission.
2//!
3//! [`OutputFormat`] and [`schema_for`] are foundation (frozen + implemented). The
4//! `render_*` functions are **owned by the `cli` agent**.
5
6use crate::model::{DiscoverOutput, FeedStatus, FetchOutput};
7
8// --- Tiny ANSI styling helpers (no-ops when `color` is false) ---------------
9
10const BOLD: &str = "\x1b[1m";
11const DIM: &str = "\x1b[2m";
12const RESET: &str = "\x1b[0m";
13
14fn bold(s: &str, color: bool) -> String {
15    if color {
16        format!("{BOLD}{s}{RESET}")
17    } else {
18        s.to_string()
19    }
20}
21
22fn dim(s: &str, color: bool) -> String {
23    if color {
24        format!("{DIM}{s}{RESET}")
25    } else {
26        s.to_string()
27    }
28}
29
30/// Stable, lowercase label for a feed status (mirrors the JSON `snake_case` form).
31fn status_label(status: FeedStatus) -> &'static str {
32    match status {
33        FeedStatus::Ok => "ok",
34        FeedStatus::NotModified => "not_modified",
35        FeedStatus::Error => "error",
36    }
37}
38
39/// Output format selected via `--format`.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum OutputFormat {
42    /// One pretty-printed JSON object (the full [`FetchOutput`] / [`DiscoverOutput`]).
43    #[default]
44    Json,
45    /// Newline-delimited JSON: one `Item` per line (each carries `feed_url`).
46    Ndjson,
47    /// Human-readable text (the only format that may use color).
48    Text,
49}
50
51/// The authoritative JSON Schema for a command's output (`rss schema --command <cmd>`).
52///
53/// `command` is `"fetch"` or `"discover"`. Returns the schema as a JSON value derived from
54/// the `#[derive(JsonSchema)]` model types — this is the source of truth for the contract.
55pub fn schema_for(command: &str) -> serde_json::Value {
56    match command {
57        "fetch" => serde_json::to_value(schemars::schema_for!(FetchOutput))
58            .unwrap_or(serde_json::Value::Null),
59        "discover" => serde_json::to_value(schemars::schema_for!(DiscoverOutput))
60            .unwrap_or(serde_json::Value::Null),
61        _ => serde_json::Value::Null,
62    }
63}
64
65/// Render a [`FetchOutput`] in the given format. **Owner: `cli` agent.**
66///
67/// - `Json`: `serde_json::to_string_pretty(out)`.
68/// - `Ndjson`: one line per `Item` across all feeds; feed-level errors go to stderr (the
69///   caller handles that) — this function returns only the stdout payload. When
70///   `ndjson_records` is set, emit self-contained tagged records instead (see
71///   [`render_fetch_ndjson_records`]).
72/// - `Text`: a compact human summary; use `color` to decide on ANSI styling.
73pub fn render_fetch(
74    out: &FetchOutput,
75    format: OutputFormat,
76    color: bool,
77    ndjson_records: bool,
78) -> String {
79    match format {
80        OutputFormat::Json => serde_json::to_string_pretty(out).unwrap_or_default(),
81        OutputFormat::Ndjson if ndjson_records => render_fetch_ndjson_records(out),
82        OutputFormat::Ndjson => out
83            .feeds
84            .iter()
85            .flat_map(|f| f.items.iter())
86            .map(|item| serde_json::to_string(item).unwrap_or_default())
87            .collect::<Vec<_>>()
88            .join("\n"),
89        OutputFormat::Text => render_fetch_text(out, color),
90    }
91}
92
93/// Self-contained NDJSON: every line is a record tagged with a `type` discriminator
94/// (`"item"`, `"error"`, or a final `"summary"`), so a consumer reading only stdout never
95/// loses feed-level errors or the aggregate counts (which the bare-item stream drops to
96/// stderr). Opt-in via `--ndjson-records`; the default stream stays pure `Item` lines for
97/// back-compatibility. See ADR-0012.
98fn render_fetch_ndjson_records(out: &FetchOutput) -> String {
99    let mut lines: Vec<String> = Vec::new();
100
101    let tagged = |value: serde_json::Value, kind: &str| -> String {
102        let mut value = value;
103        if let Some(obj) = value.as_object_mut() {
104            obj.insert(
105                "type".to_string(),
106                serde_json::Value::String(kind.to_string()),
107            );
108        }
109        value.to_string()
110    };
111
112    for item in out.feeds.iter().flat_map(|f| f.items.iter()) {
113        if let Ok(v) = serde_json::to_value(item) {
114            lines.push(tagged(v, "item"));
115        }
116    }
117    for err in &out.errors {
118        if let Ok(v) = serde_json::to_value(err) {
119            lines.push(tagged(v, "error"));
120        }
121    }
122    // A trailing summary record so the stream is self-describing about totals and bounding.
123    let summary = serde_json::json!({
124        "type": "summary",
125        "schema_version": out.schema_version,
126        "fetched_at": out.fetched_at,
127        "feeds": out.feeds.len(),
128        "errors": out.errors.len(),
129        "total_items": out.total_items,
130        "total_content_tokens_est": out.total_content_tokens_est,
131        "warnings": out.warnings,
132        "truncation": out.truncation,
133    });
134    lines.push(summary.to_string());
135
136    lines.join("\n")
137}
138
139/// Concise, deterministic human summary of a [`FetchOutput`].
140fn render_fetch_text(out: &FetchOutput, color: bool) -> String {
141    let mut lines: Vec<String> = Vec::new();
142
143    for feed in &out.feeds {
144        // Header: title (or feed_url) · status · item count.
145        let head = feed.title.as_deref().unwrap_or(&feed.feed_url);
146        let cache = if feed.from_cache { " (cached)" } else { "" };
147        lines.push(format!(
148            "{}  [{}]{}  {} item(s)",
149            bold(head, color),
150            status_label(feed.status),
151            cache,
152            feed.items.len(),
153        ));
154
155        for item in &feed.items {
156            let published = item.published.as_deref().unwrap_or("-");
157            let title = item.title.as_deref().unwrap_or("-");
158            let url = item.url.as_deref().unwrap_or("-");
159            lines.push(format!(
160                "  - {}  {}  {}",
161                dim(published, color),
162                title,
163                dim(url, color),
164            ));
165        }
166
167        if let Some(err) = &feed.error {
168            lines.push(format!("  ! {}: {}", err.code, err.message));
169        }
170    }
171
172    // Mirror feed-level errors as a trailing summary for quick scanning.
173    if !out.errors.is_empty() {
174        lines.push(bold(&format!("{} error(s):", out.errors.len()), color));
175        for err in &out.errors {
176            match &err.feed_url {
177                Some(url) => lines.push(format!("  ! [{}] {}: {}", err.code, url, err.message)),
178                None => lines.push(format!("  ! [{}] {}", err.code, err.message)),
179            }
180        }
181    }
182
183    lines.join("\n")
184}
185
186/// Render a [`DiscoverOutput`] in the given format. **Owner: `cli` agent.**
187pub fn render_discover(out: &DiscoverOutput, format: OutputFormat, color: bool) -> String {
188    match format {
189        OutputFormat::Json => serde_json::to_string_pretty(out).unwrap_or_default(),
190        OutputFormat::Ndjson => out
191            .feeds
192            .iter()
193            .map(|feed| serde_json::to_string(feed).unwrap_or_default())
194            .collect::<Vec<_>>()
195            .join("\n"),
196        OutputFormat::Text => render_discover_text(out, color),
197    }
198}
199
200/// Concise, deterministic human summary of a [`DiscoverOutput`].
201fn render_discover_text(out: &DiscoverOutput, color: bool) -> String {
202    let mut lines: Vec<String> = Vec::new();
203    lines.push(format!(
204        "{}  {} feed(s)",
205        bold(&out.site_url, color),
206        out.feeds.len(),
207    ));
208    for feed in &out.feeds {
209        let title = feed.title.as_deref().unwrap_or("-");
210        lines.push(format!(
211            "  {}  [{}]  {}",
212            feed.url,
213            feed.feed_type,
214            dim(title, color),
215        ));
216    }
217    lines.join("\n")
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::model::{ContentFormat, DiscoveredFeed, FeedResult, FeedStatus, IdSource, Item};
224
225    fn sample_item(id: &str, title: &str, url: &str) -> Item {
226        Item {
227            id: id.to_string(),
228            id_source: IdSource::Link,
229            feed_url: "https://example.com/feed.xml".to_string(),
230            title: Some(title.to_string()),
231            url: Some(url.to_string()),
232            authors: vec!["Ada".to_string()],
233            published: Some("2026-01-02T03:04:05Z".to_string()),
234            updated: None,
235            summary: Some("a summary".to_string()),
236            content: Some("body".to_string()),
237            content_format: ContentFormat::Markdown,
238            content_tokens_est: 1,
239            content_truncated: false,
240            content_hash: Some("00112233aabbccdd".to_string()),
241            categories: vec![],
242            enclosures: vec![],
243            guid: None,
244        }
245    }
246
247    fn sample_output() -> FetchOutput {
248        let mut out = FetchOutput::new("2026-06-01T00:00:00Z".to_string());
249        let items = vec![
250            sample_item("1", "First Post", "https://example.com/1"),
251            sample_item("2", "Second Post", "https://example.com/2"),
252        ];
253        let content_tokens_est_total = items.iter().map(|i| u64::from(i.content_tokens_est)).sum();
254        out.feeds.push(FeedResult {
255            feed_url: "https://example.com/feed.xml".to_string(),
256            status: FeedStatus::Ok,
257            from_cache: false,
258            title: Some("Example Feed".to_string()),
259            site_url: Some("https://example.com".to_string()),
260            updated: None,
261            item_count: items.len(),
262            content_tokens_est_total,
263            items,
264            error: None,
265        });
266        out.total_items = out.feeds.iter().map(|f| f.items.len()).sum();
267        out.total_content_tokens_est = out
268            .feeds
269            .iter()
270            .flat_map(|f| &f.items)
271            .map(|i| u64::from(i.content_tokens_est))
272            .sum();
273        out
274    }
275
276    #[test]
277    fn fetch_json_roundtrips() {
278        let out = sample_output();
279        let json = render_fetch(&out, OutputFormat::Json, false, false);
280        let parsed: FetchOutput = serde_json::from_str(&json).expect("valid json roundtrip");
281        assert_eq!(parsed.feeds.len(), 1);
282        assert_eq!(parsed.feeds[0].items.len(), 2);
283        assert_eq!(parsed.total_items, 2);
284    }
285
286    #[test]
287    fn fetch_ndjson_one_line_per_item() {
288        let out = sample_output();
289        let ndjson = render_fetch(&out, OutputFormat::Ndjson, false, false);
290        assert_eq!(ndjson.lines().count(), 2);
291        // Each line is independently parseable as an Item.
292        for line in ndjson.lines() {
293            let _: Item = serde_json::from_str(line).expect("each line is an Item");
294        }
295    }
296
297    #[test]
298    fn fetch_ndjson_records_are_tagged_and_self_contained() {
299        use crate::model::ErrorObj;
300        let mut out = sample_output();
301        out.errors
302            .push(ErrorObj::new("FEED_FETCH_FAILED", "boom").with_feed("https://bad.example/x"));
303
304        let ndjson = render_fetch(&out, OutputFormat::Ndjson, false, true);
305        let records: Vec<serde_json::Value> = ndjson
306            .lines()
307            .map(|l| serde_json::from_str(l).expect("each record line is JSON"))
308            .collect();
309
310        // 2 items + 1 error + 1 summary.
311        assert_eq!(records.len(), 4);
312        assert_eq!(records[0]["type"], "item");
313        assert_eq!(records[0]["id"], "1");
314        assert_eq!(records[2]["type"], "error");
315        assert_eq!(records[2]["code"], "FEED_FETCH_FAILED");
316        let summary = records.last().unwrap();
317        assert_eq!(summary["type"], "summary");
318        assert_eq!(summary["total_items"], 2);
319        assert_eq!(summary["errors"], 1);
320    }
321
322    #[test]
323    fn fetch_text_contains_titles() {
324        let out = sample_output();
325        let text = render_fetch(&out, OutputFormat::Text, false, false);
326        assert!(text.contains("Example Feed"));
327        assert!(text.contains("First Post"));
328        assert!(text.contains("Second Post"));
329        assert!(text.contains("2 item(s)"));
330    }
331
332    #[test]
333    fn fetch_text_color_wraps_but_preserves_titles() {
334        let out = sample_output();
335        let text = render_fetch(&out, OutputFormat::Text, true, false);
336        // The bare title is still present even with ANSI wrapping.
337        assert!(text.contains("Example Feed"));
338        assert!(text.contains(BOLD));
339    }
340
341    #[test]
342    fn fetch_text_renders_errors() {
343        use crate::model::ErrorObj;
344        let mut out = FetchOutput::new("2026-06-01T00:00:00Z".to_string());
345        let err = ErrorObj::new("FEED_FETCH_FAILED", "boom").with_feed("https://bad.example/x");
346        out.feeds
347            .push(FeedResult::error("https://bad.example/x", err.clone()));
348        out.errors.push(err);
349
350        let text = render_fetch(&out, OutputFormat::Text, false, false);
351        assert!(text.contains("[error]")); // status label for FeedStatus::Error
352        assert!(text.contains("FEED_FETCH_FAILED"));
353        assert!(text.contains("boom"));
354        assert!(text.contains("1 error(s):"));
355    }
356
357    #[test]
358    fn discover_renders_all_formats() {
359        let out = DiscoverOutput::new(
360            "https://example.com",
361            vec![
362                DiscoveredFeed {
363                    url: "https://example.com/feed.rss".to_string(),
364                    feed_type: "rss".to_string(),
365                    title: Some("RSS".to_string()),
366                },
367                DiscoveredFeed {
368                    url: "https://example.com/atom.xml".to_string(),
369                    feed_type: "atom".to_string(),
370                    title: None,
371                },
372            ],
373        );
374
375        let json = render_discover(&out, OutputFormat::Json, false);
376        let parsed: DiscoverOutput = serde_json::from_str(&json).expect("valid json roundtrip");
377        assert_eq!(parsed.feeds.len(), 2);
378
379        let ndjson = render_discover(&out, OutputFormat::Ndjson, false);
380        assert_eq!(ndjson.lines().count(), 2);
381
382        let text = render_discover(&out, OutputFormat::Text, false);
383        assert!(text.contains("https://example.com/feed.rss"));
384        assert!(text.contains("[atom]"));
385    }
386}