mirador 0.16.0

An opinionated personal dashboard for your terminal: world clocks, a calendar, weather, tasks, notes, a market watchlist, and live CPU and network graphs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! Just enough RSS to answer "what happened in the world".
//!
//! Three fields out of each `<item>`: the title, the link and the date. Not the
//! description — every feed measured carries between eighty and four hundred
//! characters of real article prose there, and that is somebody's writing
//! rather than a fact about the world. A headline is a fact; a summary is a
//! work. Titles only also happens to solve the space problem, which is a nice
//! coincidence and not the reason.
//!
//! **RSS 2.0 only.** Every feed sampled while designing this was RSS 2.0 —
//! BBC, Ars Technica, NASA, Phys.org and Hacker News. Atom exists and is not
//! read; a feed that produces no items says so in the panel rather than looking
//! empty, which is the honest failure for something this narrow.
//!
//! Parsing goes through `quick-xml` rather than being hand-rolled, which is the
//! opposite of the call [`crate::ical`] made and worth explaining. iCalendar is
//! line-based and can be read with `split` and a state machine. XML has
//! entities, CDATA and namespaces, and hand-decoding those is exactly how
//! `Don&#8217;t` reaches the screen. Measured before choosing: `quick-xml` adds
//! two crates where `rss` adds twenty-three and `feed-rs` fifty-eight, against
//! the hundred and twenty-three already here.

use anyhow::{Context, Result};
use jiff::Zoned;
use quick_xml::events::Event as XmlEvent;

/// One headline.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Story {
    /// The feed's display name, filled in by the panel — a feed does not know
    /// what you call it, and `BBC WORLD` reads better than the channel title.
    pub source: String,
    pub title: String,
    pub link: String,
    /// When it was published, if the feed said so in a form that parsed.
    ///
    /// The three feeds sampled emitted `GMT`, `+0000` and `EDT`, and jiff reads
    /// all three — including the obsolete zone abbreviations, which RFC 2822
    /// permits an implementation to treat as unknown and which it instead maps
    /// correctly (`EDT` to -04:00). That was checked rather than assumed; the
    /// assumption had been the other way.
    ///
    /// `None` therefore means a missing or genuinely malformed date. The story
    /// still shows, without an age — the same choice the watchlist makes for a
    /// missing price. Dropping it would hide news over a formatting slip.
    pub published: Option<Zoned>,
}

/// Pull the stories out of an RSS document.
///
/// Tolerant by design: an item with no title is skipped, an unparseable date
/// becomes `None`, and unknown elements are ignored. A feed is somebody else's
/// output and will contain things this does not expect.
pub fn parse(xml: &str) -> Result<Vec<Story>> {
    let mut reader = quick_xml::Reader::from_str(xml);
    // Deliberately *not* `trim_text(true)`. An entity splits an element's text
    // into several events, so a headline like `‘Dead stars’ may have appetites`
    // arrives as text, entity, text, entity, text — and trimming each piece
    // eats the space that followed the closing quote, producing
    // `'Dead stars'may have`. Seen on a real feed within a minute of the panel
    // first running. The assembled value is trimmed once, at the end.
    reader.config_mut().trim_text(false);

    let mut stories = Vec::new();
    let mut in_item = false;
    // Which element's text is currently being collected. `None` between them.
    let mut field: Option<Field> = None;
    let mut current = Partial::default();

    loop {
        match reader.read_event().context("reading the feed as XML")? {
            XmlEvent::Start(tag) => {
                match local_name(tag.name().as_ref()) {
                    b"item" => {
                        in_item = true;
                        current = Partial::default();
                    }
                    // Only inside an item: a feed's channel has a `<title>` and
                    // a `<link>` of its own, and reading those as a story is how
                    // the outlet's own name ends up as the first headline.
                    b"title" if in_item => field = Some(Field::Title),
                    b"link" if in_item => field = Some(Field::Link),
                    b"pubDate" if in_item => field = Some(Field::Date),
                    _ => {}
                }
            }
            XmlEvent::End(tag) => match local_name(tag.name().as_ref()) {
                b"item" => {
                    in_item = false;
                    if let Some(story) = current.take() {
                        stories.push(story);
                    }
                }
                _ => field = None,
            },
            // CDATA and plain text both reach here; `quick-xml` unescapes
            // entities for us, which is most of why it is here at all.
            XmlEvent::Text(text) => {
                if let Some(which) = field {
                    let value = text.decode().unwrap_or_default().into_owned();
                    current.set(which, &value);
                }
            }
            XmlEvent::CData(data) => {
                if let Some(which) = field {
                    let value = String::from_utf8_lossy(&data).into_owned();
                    current.set(which, &value);
                }
            }
            // `&amp;` and `&#8217;` arrive as their own events rather than as
            // part of the text around them, so ignoring these silently deletes
            // every ampersand and every typographic quote — which is how a link
            // came out as `?at_medium=RSSat_campaign=x` the first time.
            //
            // Worth noting against the choice to use a library at all: it does
            // not do this for you. What it does do is get the *splitting* right,
            // which is the part a hand-rolled scanner gets wrong.
            XmlEvent::GeneralRef(reference) => {
                if let Some(which) = field
                    && let Some(text) = resolve_entity(&reference)
                {
                    current.set(which, &text);
                }
            }
            XmlEvent::Eof => break,
            _ => {}
        }
    }

    Ok(stories)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
    Title,
    Link,
    Date,
}

#[derive(Debug, Default)]
struct Partial {
    title: String,
    link: String,
    date: String,
}

impl Partial {
    fn set(&mut self, which: Field, value: &str) {
        // Appended rather than assigned: an entity splits an element's text
        // into several events, so `AT&T` arrives as `AT`, `&`, `T`.
        let slot = match which {
            Field::Title => &mut self.title,
            Field::Link => &mut self.link,
            Field::Date => &mut self.date,
        };
        slot.push_str(value);
    }

    /// A story, if there is enough of one. A headline with no text is not news.
    fn take(&mut self) -> Option<Story> {
        let title = self.title.trim();
        if title.is_empty() {
            return None;
        }
        Some(Story {
            source: String::new(),
            title: clip(title, MAX_TITLE),
            link: clip(self.link.trim(), MAX_LINK),
            published: parse_date(self.date.trim()),
        })
    }
}

/// The longest headline kept. Real ones run to about 150 characters; this is
/// generous enough that nothing genuine is ever clipped.
const MAX_TITLE: usize = 400;

/// The longest link kept. Real ones are well under 200.
const MAX_LINK: usize = 1_000;

/// The longest feed name kept, applied where the panel names the feed.
pub const MAX_SOURCE: usize = 80;

/// Keep the first `limit` characters of `text`.
///
/// This is the bound that was missing, and the reason it matters is not tidiness
/// — it is that everything downstream is per *frame*. A story's title is wrapped
/// on every draw and its source is uppercased on every draw, so an unbounded
/// string here becomes unbounded work sixty times a minute. Measured before
/// fixing: a 2 MB headline wrapped to 40,000 lines and cost **72 ms a frame**,
/// and `ureq`'s body cap allows five times that. A feed you do not control
/// should not be able to decide how much work the dashboard does.
///
/// Cutting on a character boundary rather than a byte one, so a clipped headline
/// is still a string. The panel truncates again for display, in *cells*; this
/// bound is about what is worth holding at all.
fn clip(text: &str, limit: usize) -> String {
    if text.chars().count() <= limit {
        return text.to_string();
    }
    text.chars().take(limit).collect()
}

/// The text an entity reference stands for.
///
/// Numeric references are resolved by `quick-xml`. Named ones are not, because
/// XML only predefines five and anything else has to come from a DTD nobody is
/// going to fetch — so the five are spelled out here, plus `nbsp`, which feeds
/// emit constantly despite it being an HTML entity rather than an XML one.
/// Anything else is dropped rather than shown raw: `&hellip;` in a headline is
/// noise, and there is no honest way to guess.
fn resolve_entity(reference: &quick_xml::events::BytesRef<'_>) -> Option<String> {
    if let Ok(Some(character)) = reference.resolve_char_ref() {
        return Some(character.to_string());
    }
    let name = reference.decode().ok()?;
    Some(
        match name.as_ref() {
            "amp" => "&",
            "lt" => "<",
            "gt" => ">",
            "quot" => "\"",
            "apos" => "'",
            "nbsp" => " ",
            _ => return None,
        }
        .to_string(),
    )
}

/// An RFC 2822 date, or `None` if the feed wrote one this cannot read.
fn parse_date(raw: &str) -> Option<Zoned> {
    if raw.is_empty() {
        return None;
    }
    jiff::fmt::rfc2822::parse(raw).ok()
}

/// The element name with any namespace prefix removed.
///
/// Feeds use `dc:date` and `media:content` freely, and matching on the whole
/// qualified name would miss anything a feed chose to namespace.
fn local_name(name: &[u8]) -> &[u8] {
    match name.iter().rposition(|byte| *byte == b':') {
        Some(colon) => &name[colon + 1..],
        None => name,
    }
}

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

    /// The bound that was missing. Everything downstream of a story runs per
    /// *frame* — the title is wrapped on every draw, the source uppercased on
    /// every draw — so an unbounded field here is unbounded work sixty times a
    /// minute. Measured before the fix: a 2 MB headline wrapped to 40,000 lines
    /// and cost 72 ms a frame, and `ureq`'s body cap allows five times that.
    ///
    /// A feed is the one input to this program that somebody else writes.
    #[test]
    fn a_hostile_feed_cannot_decide_how_much_work_the_dashboard_does() {
        let huge = "word ".repeat(400_000);
        let xml = format!(
            "<rss><channel><item><title>{huge}</title>\
             <link>http://example.com/{huge}</link></item></channel></rss>"
        );
        let stories = parse(&xml).expect("parses");
        let story = stories.first().expect("one story");

        assert!(
            story.title.chars().count() <= MAX_TITLE,
            "a {} character headline survived",
            story.title.chars().count()
        );
        assert!(
            story.link.chars().count() <= MAX_LINK,
            "a {} character link survived",
            story.link.chars().count()
        );

        // The property that actually matters: what it costs to draw.
        let wrapped = crate::grid::wrap(&story.title, 50);
        assert!(
            wrapped.len() <= 40,
            "the headline still wraps to {} lines a frame",
            wrapped.len()
        );
    }

    /// And an ordinary headline is untouched — a bound that clips real news is
    /// worse than no bound.
    #[test]
    fn a_headline_of_ordinary_length_is_not_clipped() {
        let title = "Astronomers find a planet where it rains glass sideways, \
                     and the discovery may rewrite how we think gas giants form";
        let xml = format!("<rss><channel><item><title>{title}</title></item></channel></rss>");
        let stories = parse(&xml).expect("parses");
        assert_eq!(stories[0].title, title, "a real headline was clipped");
    }

    /// Captured from real feeds, and deliberately awkward: CDATA titles, an
    /// entity inside a link, an entity that splits a title into three text
    /// events, the three date spellings seen in the wild, an item with no date
    /// and an item with no title at all.
    const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>The Feed Itself</title>
    <link>https://example.org</link>
    <item>
      <title><![CDATA[Wildfire now nine miles from Bordeaux, mayor warns]]></title>
      <description><![CDATA[Some prose that must not be read.]]></description>
      <link>https://example.org/a?at_medium=RSS&amp;at_campaign=x</link>
      <pubDate>Mon, 27 Jul 2026 17:50:42 GMT</pubDate>
    </item>
    <item>
      <title>&#8216;Dead stars&#8217; may have surprisingly healthy appetites</title>
      <link>https://example.org/f</link>
      <pubDate>Mon, 27 Jul 2026 12:00:00 GMT</pubDate>
    </item>
    <item>
      <title>AT&amp;T reverses course on the physical button</title>
      <link>https://example.org/b</link>
      <pubDate>Mon, 27 Jul 2026 15:58:34 +0000</pubDate>
    </item>
    <item>
      <title>Europa Clipper returns its first images</title>
      <link>https://example.org/c</link>
      <pubDate>Mon, 27 Jul 2026 15:40:06 EDT</pubDate>
    </item>
    <item>
      <title>A story its feed forgot to date</title>
      <link>https://example.org/d</link>
    </item>
    <item>
      <link>https://example.org/e</link>
      <pubDate>Mon, 27 Jul 2026 10:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>
"#;

    #[test]
    fn the_feeds_own_title_is_not_a_story() {
        let stories = parse(SAMPLE).expect("parses");
        assert!(
            !stories.iter().any(|s| s.title == "The Feed Itself"),
            "the channel's own title became a headline: {:?}",
            stories.iter().map(|s| &s.title).collect::<Vec<_>>()
        );
        assert_eq!(stories.len(), 5, "five items have titles, one does not");
    }

    /// `quick-xml` is here rather than a hand-rolled scanner precisely for
    /// this: CDATA and entities, decoded rather than shown.
    #[test]
    fn cdata_and_entities_come_out_as_text() {
        let stories = parse(SAMPLE).expect("parses");
        assert_eq!(
            stories[0].title,
            "Wildfire now nine miles from Bordeaux, mayor warns"
        );
        assert_eq!(
            stories[0].link, "https://example.org/a?at_medium=RSS&at_campaign=x",
            "&amp; in a URL is decoded, not left as written"
        );
        assert_eq!(
            stories[2].title, "AT&T reverses course on the physical button",
            "an entity splits the text into several events and must be rejoined"
        );
    }

    /// The bug that reached the screen within a minute of the panel first
    /// running: an entity splits the text around it, and trimming each piece
    /// separately eats the space that followed. `'Dead stars'may have`.
    #[test]
    fn a_space_next_to_an_entity_survives() {
        let stories = parse(SAMPLE).expect("parses");
        let quoted = stories
            .iter()
            .find(|s| s.title.contains("Dead stars"))
            .expect("present");
        assert_eq!(
            quoted.title,
            "\u{2018}Dead stars\u{2019} may have surprisingly healthy appetites"
        );
    }

    /// Every date form seen in the wild reads, including the obsolete zone
    /// abbreviations — `EDT` becomes -04:00 rather than being discarded or,
    /// worse, silently read as UTC and shown four hours out. A story whose date
    /// is missing or malformed still appears, without an age.
    #[test]
    fn the_date_forms_feeds_actually_emit_all_read() {
        let stories = parse(SAMPLE).expect("parses");
        let by_title = |want: &str| {
            stories
                .iter()
                .find(|s| s.title.starts_with(want))
                .unwrap_or_else(|| panic!("`{want}` is missing"))
        };

        assert!(by_title("Wildfire").published.is_some(), "GMT reads");
        assert!(by_title("AT&T").published.is_some(), "+0000 reads");

        // The one worth pinning: an obsolete abbreviation must reach the right
        // offset, not be quietly taken as UTC and shown four hours out.
        let europa = by_title("Europa").published.as_ref().expect("EDT reads");
        assert_eq!(
            europa.offset().seconds(),
            -4 * 3600,
            "EDT is -04:00, not UTC"
        );

        assert!(
            by_title("A story its feed forgot").published.is_none(),
            "a missing date costs the age, not the story"
        );
    }

    #[test]
    fn something_that_is_not_a_feed_is_an_error_or_an_empty_list() {
        // Not XML at all.
        assert!(parse("<<<not xml").is_err() || parse("<<<not xml").unwrap().is_empty());
        // Valid XML, no items.
        assert!(parse("<html><body>hello</body></html>").unwrap().is_empty());
    }

    #[test]
    fn a_namespaced_element_is_matched_by_its_local_name() {
        assert_eq!(local_name(b"dc:creator"), b"creator");
        assert_eq!(local_name(b"title"), b"title");
    }
}