chore: refactored and cleaned up 14f81eb7
Steve · 2026-05-06 21:52 3 file(s) · +119 −121
Cargo.lock +24 −0
218 218
 "feedparser-rs",
219 219
 "open",
220 220
 "ratatui",
221 +
 "textwrap",
221 222
 "ureq",
222 223
]
223 224
2420 2421
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
2421 2422
2422 2423
[[package]]
2424 +
name = "smawk"
2425 +
version = "0.3.2"
2426 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2427 +
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
2428 +
2429 +
[[package]]
2423 2430
name = "socket2"
2424 2431
version = "0.6.3"
2425 2432
source = "registry+https://github.com/rust-lang/crates.io-index"
2613 2620
 "wezterm-dynamic",
2614 2621
 "wezterm-input-types",
2615 2622
 "winapi",
2623 +
]
2624 +
2625 +
[[package]]
2626 +
name = "textwrap"
2627 +
version = "0.16.2"
2628 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2629 +
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
2630 +
dependencies = [
2631 +
 "smawk",
2632 +
 "unicode-linebreak",
2633 +
 "unicode-width",
2616 2634
]
2617 2635
2618 2636
[[package]]
2861 2879
version = "1.0.24"
2862 2880
source = "registry+https://github.com/rust-lang/crates.io-index"
2863 2881
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2882 +
2883 +
[[package]]
2884 +
name = "unicode-linebreak"
2885 +
version = "0.1.5"
2886 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2887 +
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
2864 2888
2865 2889
[[package]]
2866 2890
name = "unicode-segmentation"
Cargo.toml +1 −0
16 16
open = "5"
17 17
chrono = "0.4"
18 18
ratatui = "0.30.0"
19 +
textwrap = "0.16"
19 20
20 21
# Read the optimization guideline for more details: https://ratatui.rs/recipes/apps/release-your-app/#optimizations
21 22
[profile.release]
src/main.rs +94 −121
1 -
use chrono::NaiveDateTime;
1 +
use chrono::{DateTime, Datelike, Utc};
2 +
use color_eyre::eyre::WrapErr;
2 3
use crossterm::event::{KeyCode, KeyEvent};
3 -
use feedparser_rs::{Entry, ParsedFeed, parse_url};
4 +
use feedparser_rs::{ParsedFeed, parse_url};
4 5
use ratatui::{
5 6
    DefaultTerminal, Frame,
6 7
    layout::{Constraint, Direction, Layout},
9 10
    widgets::{Block, List, ListItem, ListState, Padding},
10 11
};
11 12
13 +
struct Item {
14 +
    title: String,
15 +
    author: String,
16 +
    date: String,
17 +
    url: Option<String>,
18 +
    published: Option<DateTime<Utc>>,
19 +
}
20 +
12 21
fn normalize_url(s: &str) -> String {
13 22
    if s.starts_with("http://") || s.starts_with("https://") {
14 23
        s.to_string()
18 27
}
19 28
20 29
fn is_bare_domain(url: &str) -> bool {
21 -
    let rest = url
22 -
        .strip_prefix("https://")
23 -
        .or_else(|| url.strip_prefix("http://"))
24 -
        .unwrap_or(url);
25 -
    let path = rest.find('/').map(|i| &rest[i..]).unwrap_or("");
26 -
    path.trim_matches('/').is_empty()
30 +
    let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
31 +
    rest.split_once('/')
32 +
        .map_or(true, |(_, p)| p.trim_matches('/').is_empty())
27 33
}
28 34
29 35
fn find_feed_link(html: &str, base_url: &str) -> Option<String> {
70 76
    Some(quote[..end].to_string())
71 77
}
72 78
73 -
fn discover_feed(input: &str) -> color_eyre::Result<String> {
79 +
fn build_agent() -> ureq::Agent {
80 +
    ureq::Agent::config_builder()
81 +
        .timeout_global(Some(std::time::Duration::from_secs(10)))
82 +
        .build()
83 +
        .into()
84 +
}
85 +
86 +
fn discover_feed(agent: &ureq::Agent, input: &str) -> color_eyre::Result<String> {
74 87
    let url = normalize_url(input);
75 88
    if !is_bare_domain(&url) {
76 89
        return Ok(url);
77 90
    }
78 -
    let timeout = std::time::Duration::from_secs(10);
79 -
    let agent = ureq::Agent::config_builder()
80 -
        .timeout_global(Some(timeout))
81 -
        .build()
82 -
        .into();
83 -
    let html = ureq::Agent::new_with_config(agent)
91 +
    let html = agent
84 92
        .get(&url)
85 93
        .call()
86 -
        .map_err(|e| color_eyre::eyre::eyre!("Failed to fetch {url}: {e}"))?
94 +
        .wrap_err_with(|| format!("fetch {url}"))?
87 95
        .body_mut()
88 96
        .read_to_string()
89 -
        .map_err(|e| color_eyre::eyre::eyre!("Failed to read response from {url}: {e}"))?;
97 +
        .wrap_err_with(|| format!("read {url}"))?;
90 98
    if let Some(feed_url) = find_feed_link(&html, &url) {
91 99
        return Ok(feed_url);
92 100
    }
104 112
    ];
105 113
    for path in PATHS {
106 114
        let candidate = format!("{base}{path}");
107 -
        if ureq::get(&candidate)
115 +
        if agent
116 +
            .get(&candidate)
108 117
            .call()
109 -
            .map(|r: ureq::http::Response<ureq::Body>| r.status() == 200)
118 +
            .map(|r| r.status() == 200)
110 119
            .unwrap_or(false)
111 120
        {
112 121
            return Ok(candidate);
115 124
    Err(color_eyre::eyre::eyre!("No feed found for: {input}"))
116 125
}
117 126
127 +
fn load_feeds(urls: &[String]) -> Vec<ParsedFeed> {
128 +
    let agent = build_agent();
129 +
    urls.iter()
130 +
        .filter_map(|url| {
131 +
            let resolved = discover_feed(&agent, url)
132 +
                .map_err(|e| eprintln!("warning: skipping {url}: {e}"))
133 +
                .ok()?;
134 +
            parse_url(&resolved, None, None, None)
135 +
                .map_err(|e| eprintln!("warning: failed to parse feed {url}: {e}"))
136 +
                .ok()
137 +
        })
138 +
        .collect()
139 +
}
140 +
141 +
fn collect_items(feeds: Vec<ParsedFeed>) -> Vec<Item> {
142 +
    let mut items: Vec<Item> = feeds
143 +
        .into_iter()
144 +
        .flat_map(|f| {
145 +
            let feed_title = f.feed.title.clone();
146 +
            f.entries.into_iter().map(move |e| {
147 +
                let author = e
148 +
                    .authors
149 +
                    .first()
150 +
                    .and_then(|a| a.name.as_ref().map(|n| n.to_string()))
151 +
                    .or_else(|| feed_title.as_ref().map(|t| t.to_string()))
152 +
                    .unwrap_or_else(|| "anon".into());
153 +
                let date = e.published.map(fmt_date).unwrap_or_else(|| "-".into());
154 +
                Item {
155 +
                    title: e
156 +
                        .title
157 +
                        .map(|t| t.to_string())
158 +
                        .unwrap_or_else(|| "(untitled)".into()),
159 +
                    author,
160 +
                    date,
161 +
                    url: e.links.into_iter().next().map(|l| l.href.to_string()),
162 +
                    published: e.published,
163 +
                }
164 +
            })
165 +
        })
166 +
        .collect();
167 +
    items.sort_by(|a, b| b.published.cmp(&a.published));
168 +
    items
169 +
}
170 +
118 171
fn main() -> color_eyre::Result<()> {
119 172
    color_eyre::install()?;
120 173
    let mut urls: Vec<String> = std::env::args().skip(1).collect();
136 189
        eprintln!("  export BULLETS_FEEDS=https://example.com/feed.xml,https://other.com/rss");
137 190
        std::process::exit(1);
138 191
    }
139 -
    let feeds: Vec<ParsedFeed> = urls
140 -
        .iter()
141 -
        .filter_map(|url| {
142 -
            let resolved = match discover_feed(url) {
143 -
                Ok(r) => r,
144 -
                Err(e) => {
145 -
                    eprintln!("warning: skipping {url}: {e}");
146 -
                    return None;
147 -
                }
148 -
            };
149 -
            match parse_url(&resolved, None, None, None) {
150 -
                Ok(feed) => Some(feed),
151 -
                Err(e) => {
152 -
                    eprintln!("warning: failed to parse feed {url}: {e}");
153 -
                    None
154 -
                }
155 -
            }
156 -
        })
157 -
        .collect();
158 192
193 +
    let feeds = load_feeds(&urls);
159 194
    if feeds.is_empty() {
160 195
        eprintln!("No feeds loaded successfully.");
161 196
        std::process::exit(1);
162 197
    }
163 198
164 -
    let mut entries: Vec<(&Entry, Option<&str>)> = feeds
165 -
        .iter()
166 -
        .flat_map(|f| {
167 -
            let title = f.feed.title.as_deref();
168 -
            f.entries.iter().map(move |e| (e, title))
169 -
        })
170 -
        .collect();
171 -
    entries.sort_by(|a, b| {
172 -
        let da = a.0.published.as_ref().map(|d| d.to_string());
173 -
        let db = b.0.published.as_ref().map(|d| d.to_string());
174 -
        db.cmp(&da)
175 -
    });
176 -
177 -
    if entries.is_empty() {
199 +
    let items = collect_items(feeds);
200 +
    if items.is_empty() {
178 201
        eprintln!("No entries found in any feed.");
179 202
        std::process::exit(1);
180 203
    }
181 -
    ratatui::run(|t| app(t, &entries))?;
204 +
205 +
    ratatui::run(|t| app(t, &items))?;
182 206
    Ok(())
183 207
}
184 208
185 -
fn app(terminal: &mut DefaultTerminal, entries: &[(&Entry, Option<&str>)]) -> std::io::Result<()> {
209 +
fn app(terminal: &mut DefaultTerminal, items: &[Item]) -> std::io::Result<()> {
186 210
    let mut state = ListState::default();
187 211
    state.select(Some(0));
188 212
189 213
    loop {
190 -
        terminal.draw(|f| render(f, entries, &mut state))?;
214 +
        terminal.draw(|f| render(f, items, &mut state))?;
191 215
192 216
        if let crossterm::event::Event::Key(KeyEvent { code, .. }) = crossterm::event::read()? {
193 -
            let len = entries.len();
217 +
            let len = items.len();
194 218
            match code {
195 219
                KeyCode::Char('q') => break,
196 220
                KeyCode::Char('j') | KeyCode::Down => {
206 230
                }
207 231
                KeyCode::Enter => {
208 232
                    if let Some(i) = state.selected() {
209 -
                        if let Some(url) = entries[i].0.links.first().map(|l| l.href.as_str()) {
233 +
                        if let Some(url) = items[i].url.as_deref() {
210 234
                            let _ = open::that(url);
211 235
                        }
212 236
                    }
219 243
    Ok(())
220 244
}
221 245
222 -
fn fmt_date(raw: &str) -> String {
223 -
    let Ok(dt) = NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S UTC") else {
224 -
        return raw.to_string();
225 -
    };
226 -
    let day = dt
227 -
        .format("%e")
228 -
        .to_string()
229 -
        .trim()
230 -
        .parse::<u32>()
231 -
        .unwrap_or(0);
246 +
fn fmt_date(dt: DateTime<Utc>) -> String {
247 +
    let day = dt.day();
232 248
    let suffix = match day {
233 249
        1 | 21 | 31 => "st",
234 250
        2 | 22 => "nd",
238 254
    format!("{} {}{}, {}", dt.format("%B"), day, suffix, dt.format("%Y"))
239 255
}
240 256
241 -
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
242 -
    if max_width == 0 {
243 -
        return vec![text.to_string()];
244 -
    }
245 -
    let mut lines: Vec<String> = Vec::new();
246 -
    let mut current = String::new();
247 -
    for word in text.split_whitespace() {
248 -
        if current.is_empty() {
249 -
            current.push_str(word);
250 -
        } else if current.len() + 1 + word.len() <= max_width {
251 -
            current.push(' ');
252 -
            current.push_str(word);
253 -
        } else {
254 -
            lines.push(current);
255 -
            current = word.to_string();
256 -
        }
257 -
    }
258 -
    if !current.is_empty() {
259 -
        lines.push(current);
260 -
    }
261 -
    if lines.is_empty() {
262 -
        lines.push(String::new());
263 -
    }
264 -
    lines
265 -
}
266 -
267 -
fn render(frame: &mut Frame, entries: &[(&Entry, Option<&str>)], state: &mut ListState) {
257 +
fn render(frame: &mut Frame, items: &[Item], state: &mut ListState) {
268 258
    let outer = frame.area();
269 259
    let [_, center, _] = Layout::default()
270 260
        .direction(Direction::Horizontal)
283 273
    let author_style = Style::new()
284 274
        .fg(Color::DarkGray)
285 275
        .add_modifier(Modifier::ITALIC);
286 -
    let highlight = Style::new();
287 276
288 277
    let selected = state.selected();
289 -
    let items: Vec<ListItem> = entries
278 +
    let list_items: Vec<ListItem> = items
290 279
        .iter()
291 280
        .enumerate()
292 -
        .map(|(i, (e, feed_title))| {
281 +
        .map(|(i, item)| {
293 282
            let bar = if selected == Some(i) { "▌ " } else { "  " };
294 -
            let date = e
295 -
                .published
296 -
                .as_ref()
297 -
                .map(|d| fmt_date(&d.to_string()))
298 -
                .unwrap_or_else(|| "-".into());
299 -
            let title = e.title.as_deref().unwrap_or("(untitled)");
300 -
            let author = e
301 -
                .authors
302 -
                .first()
303 -
                .and_then(|a| a.name.as_deref())
304 -
                .or(*feed_title)
305 -
                .unwrap_or("anon");
306 -
307 -
            let mut lines = vec![Line::from(vec![Span::raw(bar), Span::styled(date, dim)])];
308 -
            for wrapped in wrap_text(title, title_width) {
309 -
                lines.push(Line::from(vec![Span::raw(bar), Span::raw(wrapped)]));
283 +
            let mut lines = vec![Line::from(vec![
284 +
                Span::raw(bar),
285 +
                Span::styled(item.date.clone(), dim),
286 +
            ])];
287 +
            for wrapped in textwrap::wrap(&item.title, title_width.max(1)) {
288 +
                lines.push(Line::from(vec![Span::raw(bar), Span::raw(wrapped.into_owned())]));
310 289
            }
311 290
            lines.push(Line::from(vec![
312 291
                Span::raw(bar),
313 -
                Span::styled(author.to_string(), author_style),
292 +
                Span::styled(item.author.clone(), author_style),
314 293
            ]));
315 294
            lines.push(Line::from(""));
316 295
319 298
        .collect();
320 299
321 300
    frame.render_widget(block, center);
322 -
    frame.render_stateful_widget(
323 -
        List::new(items)
324 -
            .highlight_style(highlight)
325 -
            .highlight_symbol(""),
326 -
        inner,
327 -
        state,
328 -
    );
301 +
    frame.render_stateful_widget(List::new(list_items).highlight_symbol(""), inner, state);
329 302
}