1use crate::model::{DiscoverOutput, FeedStatus, FetchOutput};
7
8const 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
30fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum OutputFormat {
42 #[default]
44 Json,
45 Ndjson,
47 Text,
49}
50
51pub 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
65pub 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
93fn 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 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
139fn render_fetch_text(out: &FetchOutput, color: bool) -> String {
141 let mut lines: Vec<String> = Vec::new();
142
143 for feed in &out.feeds {
144 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 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
186pub 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
200fn 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 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 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 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]")); 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}