first commit
This commit is contained in:
2170
Cargo.lock
generated
Normal file
2170
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "tech-news-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.26"
|
||||
crossterm = "0.27"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rss = "2"
|
||||
343
src/app.rs
Normal file
343
src/app.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ratatui::widgets::ListState;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::news::{fetch_article_content, fetch_stories, html_to_text, Story, SOURCES};
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
pub enum Tab {
|
||||
TopStories,
|
||||
Saved,
|
||||
}
|
||||
|
||||
// ── Async messages ───────────────────────────────────────────────────────────
|
||||
|
||||
pub enum AppMessage {
|
||||
StoriesLoaded(Vec<Story>),
|
||||
ContentLoaded(u64, String),
|
||||
LoadError(String),
|
||||
}
|
||||
|
||||
// ── App state ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct App {
|
||||
// Top Stories tab
|
||||
pub stories: Vec<Story>,
|
||||
pub selected: usize,
|
||||
pub list_state: ListState,
|
||||
|
||||
// Saved tab
|
||||
pub saved_stories: Vec<Story>,
|
||||
pub saved_selected: usize,
|
||||
pub saved_list_state: ListState,
|
||||
|
||||
// Source (only affects TopStories tab)
|
||||
pub current_source: usize,
|
||||
|
||||
// Shared
|
||||
pub current_tab: Tab,
|
||||
pub content: Option<String>,
|
||||
pub loading_stories: bool,
|
||||
pub loading_content: bool,
|
||||
pub error: Option<String>,
|
||||
pub content_scroll: u16,
|
||||
|
||||
// Feedback message shown in the status bar (e.g. "Saved!" / "Removed.")
|
||||
pub status_msg: Option<String>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let saved_stories = load_from_disk();
|
||||
let mut saved_list_state = ListState::default();
|
||||
if !saved_stories.is_empty() {
|
||||
saved_list_state.select(Some(0));
|
||||
}
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(Some(0));
|
||||
|
||||
Self {
|
||||
stories: Vec::new(),
|
||||
selected: 0,
|
||||
list_state,
|
||||
|
||||
saved_stories,
|
||||
saved_selected: 0,
|
||||
saved_list_state,
|
||||
|
||||
current_source: 0,
|
||||
current_tab: Tab::TopStories,
|
||||
content: None,
|
||||
loading_stories: false,
|
||||
loading_content: false,
|
||||
error: None,
|
||||
content_scroll: 0,
|
||||
status_msg: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Story loading ────────────────────────────────────────────────────────
|
||||
|
||||
pub fn load_stories(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||
let source_idx = self.current_source;
|
||||
self.loading_stories = true;
|
||||
self.error = None;
|
||||
self.content = None;
|
||||
self.stories.clear();
|
||||
tokio::spawn(async move {
|
||||
match fetch_stories(source_idx).await {
|
||||
Ok(stories) => {
|
||||
let _ = tx.send(AppMessage::StoriesLoaded(stories)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(AppMessage::LoadError(e)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn next_source(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||
if self.current_tab == Tab::TopStories {
|
||||
self.current_source = (self.current_source + 1) % SOURCES.len();
|
||||
self.load_stories(tx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev_source(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||
if self.current_tab == Tab::TopStories {
|
||||
self.current_source = self.current_source
|
||||
.checked_sub(1)
|
||||
.unwrap_or(SOURCES.len() - 1);
|
||||
self.load_stories(tx);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_content_for(&mut self, story: Story, tx: mpsc::Sender<AppMessage>) {
|
||||
self.content = None;
|
||||
self.content_scroll = 0;
|
||||
self.loading_content = false;
|
||||
|
||||
let id = story.id;
|
||||
|
||||
if let Some(text) = &story.text {
|
||||
self.content = Some(html_to_text(text));
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(url) = story.url.clone() {
|
||||
self.loading_content = true;
|
||||
tokio::spawn(async move {
|
||||
let content = fetch_article_content(&url).await;
|
||||
let _ = tx.send(AppMessage::ContentLoaded(id, content)).await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
self.content = Some("No article URL available for this story.".to_string());
|
||||
}
|
||||
|
||||
pub fn load_content(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||
if let Some(story) = self.stories.get(self.selected).cloned() {
|
||||
self.load_content_for(story, tx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_saved_content(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||
if let Some(story) = self.saved_stories.get(self.saved_selected).cloned() {
|
||||
self.load_content_for(story, tx);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message handler ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn handle_message(&mut self, msg: AppMessage, tx: mpsc::Sender<AppMessage>) {
|
||||
match msg {
|
||||
AppMessage::StoriesLoaded(stories) => {
|
||||
self.stories = stories;
|
||||
self.loading_stories = false;
|
||||
self.selected = 0;
|
||||
self.list_state.select(Some(0));
|
||||
if self.current_tab == Tab::TopStories {
|
||||
self.load_content(tx);
|
||||
}
|
||||
}
|
||||
AppMessage::ContentLoaded(id, content) => {
|
||||
// Only apply if the message matches the currently visible story
|
||||
let current_id = match self.current_tab {
|
||||
Tab::TopStories => self.stories.get(self.selected).map(|s| s.id),
|
||||
Tab::Saved => self.saved_stories.get(self.saved_selected).map(|s| s.id),
|
||||
};
|
||||
if current_id == Some(id) {
|
||||
self.content = Some(content);
|
||||
self.loading_content = false;
|
||||
}
|
||||
}
|
||||
AppMessage::LoadError(e) => {
|
||||
self.error = Some(e);
|
||||
self.loading_stories = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────
|
||||
|
||||
pub fn next_story(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||
match self.current_tab {
|
||||
Tab::TopStories => {
|
||||
if !self.stories.is_empty() && self.selected < self.stories.len() - 1 {
|
||||
self.selected += 1;
|
||||
self.list_state.select(Some(self.selected));
|
||||
self.load_content(tx);
|
||||
}
|
||||
}
|
||||
Tab::Saved => {
|
||||
if !self.saved_stories.is_empty()
|
||||
&& self.saved_selected < self.saved_stories.len() - 1
|
||||
{
|
||||
self.saved_selected += 1;
|
||||
self.saved_list_state.select(Some(self.saved_selected));
|
||||
self.load_saved_content(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev_story(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||
match self.current_tab {
|
||||
Tab::TopStories => {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
self.list_state.select(Some(self.selected));
|
||||
self.load_content(tx);
|
||||
}
|
||||
}
|
||||
Tab::Saved => {
|
||||
if self.saved_selected > 0 {
|
||||
self.saved_selected -= 1;
|
||||
self.saved_list_state.select(Some(self.saved_selected));
|
||||
self.load_saved_content(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_content_down(&mut self) {
|
||||
self.content_scroll = self.content_scroll.saturating_add(3);
|
||||
}
|
||||
|
||||
pub fn scroll_content_up(&mut self) {
|
||||
self.content_scroll = self.content_scroll.saturating_sub(3);
|
||||
}
|
||||
|
||||
// ── Tab switching ────────────────────────────────────────────────────────
|
||||
|
||||
pub fn switch_tab(&mut self, tab: Tab, tx: mpsc::Sender<AppMessage>) {
|
||||
if self.current_tab == tab {
|
||||
return;
|
||||
}
|
||||
self.current_tab = tab;
|
||||
self.content = None;
|
||||
self.content_scroll = 0;
|
||||
self.status_msg = None;
|
||||
|
||||
match tab {
|
||||
Tab::TopStories => {
|
||||
if !self.stories.is_empty() {
|
||||
self.load_content(tx);
|
||||
}
|
||||
}
|
||||
Tab::Saved => {
|
||||
if self.saved_stories.is_empty() {
|
||||
self.content =
|
||||
Some("No saved stories yet.\n\nPress 's' on any story to save it.".to_string());
|
||||
} else {
|
||||
self.saved_list_state.select(Some(self.saved_selected));
|
||||
self.load_saved_content(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save / unsave ────────────────────────────────────────────────────────
|
||||
|
||||
pub fn toggle_save(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||
// Identify which story is currently focused
|
||||
let id = match self.current_tab {
|
||||
Tab::TopStories => self.stories.get(self.selected).map(|s| s.id),
|
||||
Tab::Saved => self.saved_stories.get(self.saved_selected).map(|s| s.id),
|
||||
};
|
||||
|
||||
let Some(id) = id else { return };
|
||||
|
||||
if let Some(pos) = self.saved_stories.iter().position(|s| s.id == id) {
|
||||
// Already saved → remove
|
||||
self.saved_stories.remove(pos);
|
||||
|
||||
if self.current_tab == Tab::Saved {
|
||||
if self.saved_stories.is_empty() {
|
||||
self.saved_selected = 0;
|
||||
self.saved_list_state.select(None);
|
||||
self.content = Some(
|
||||
"No saved stories yet.\n\nPress 's' on any story to save it.".to_string(),
|
||||
);
|
||||
} else {
|
||||
self.saved_selected =
|
||||
self.saved_selected.min(self.saved_stories.len() - 1);
|
||||
self.saved_list_state.select(Some(self.saved_selected));
|
||||
self.load_saved_content(tx);
|
||||
}
|
||||
}
|
||||
|
||||
self.status_msg = Some("Removed from saved.".to_string());
|
||||
} else {
|
||||
// Not saved → save
|
||||
let story = match self.current_tab {
|
||||
Tab::TopStories => self.stories.get(self.selected).cloned(),
|
||||
Tab::Saved => None,
|
||||
};
|
||||
if let Some(story) = story {
|
||||
self.saved_stories.push(story);
|
||||
self.status_msg = Some("Saved!".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
self.save_to_disk();
|
||||
}
|
||||
|
||||
pub fn is_saved(&self, id: u64) -> bool {
|
||||
self.saved_stories.iter().any(|s| s.id == id)
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
fn save_to_disk(&self) {
|
||||
let path = save_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(&self.saved_stories) {
|
||||
std::fs::write(path, json).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_file_path() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(home)
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("tech-news")
|
||||
.join("saved.json")
|
||||
}
|
||||
|
||||
fn load_from_disk() -> Vec<Story> {
|
||||
let path = save_file_path();
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
79
src/main.rs
Normal file
79
src/main.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
mod app;
|
||||
mod news;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use app::{App, AppMessage, Tab};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let result = run_app(&mut terminal).await;
|
||||
|
||||
// Always restore terminal even on error
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_app(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (tx, mut rx) = mpsc::channel::<AppMessage>(100);
|
||||
let mut app = App::new();
|
||||
|
||||
// Kick off the initial story fetch
|
||||
app.load_stories(tx.clone());
|
||||
|
||||
loop {
|
||||
// Drain all pending async messages before drawing
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
app.handle_message(msg, tx.clone());
|
||||
}
|
||||
|
||||
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||
|
||||
// Poll for keyboard input with a short timeout so messages keep flowing
|
||||
if event::poll(std::time::Duration::from_millis(200))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||
KeyCode::Down | KeyCode::Char('j') => app.next_story(tx.clone()),
|
||||
KeyCode::Up | KeyCode::Char('k') => app.prev_story(tx.clone()),
|
||||
KeyCode::PageDown => app.scroll_content_down(),
|
||||
KeyCode::PageUp => app.scroll_content_up(),
|
||||
KeyCode::Char('r') => app.load_stories(tx.clone()),
|
||||
KeyCode::Char('s') => app.toggle_save(tx.clone()),
|
||||
KeyCode::Char('1') => app.switch_tab(Tab::TopStories, tx.clone()),
|
||||
KeyCode::Char('2') => app.switch_tab(Tab::Saved, tx.clone()),
|
||||
KeyCode::Right => app.next_source(tx.clone()),
|
||||
KeyCode::Left => app.prev_source(tx.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
355
src/news.rs
Normal file
355
src/news.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Story ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Story {
|
||||
pub id: u64,
|
||||
pub title: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub score: Option<i32>,
|
||||
pub by: Option<String>,
|
||||
pub descendants: Option<i32>,
|
||||
pub time: Option<u64>,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
// ── Source definitions ────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SourceDef {
|
||||
pub label: &'static str,
|
||||
pub kind: SourceKind,
|
||||
}
|
||||
|
||||
pub enum SourceKind {
|
||||
HackerNews,
|
||||
Reddit(&'static str), // subreddit name
|
||||
Rss(&'static str), // feed URL
|
||||
}
|
||||
|
||||
pub static SOURCES: &[SourceDef] = &[
|
||||
SourceDef { label: "HN Tech", kind: SourceKind::HackerNews },
|
||||
SourceDef { label: "Games", kind: SourceKind::Reddit("gaming") },
|
||||
SourceDef { label: "Science", kind: SourceKind::Reddit("science") },
|
||||
SourceDef { label: "Programming", kind: SourceKind::Reddit("programming") },
|
||||
SourceDef { label: "BBC News", kind: SourceKind::Rss("https://feeds.bbci.co.uk/news/rss.xml") },
|
||||
SourceDef { label: "NASA", kind: SourceKind::Rss("https://www.nasa.gov/news-release/feed/") },
|
||||
];
|
||||
|
||||
// ── Dispatcher ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn fetch_stories(source_idx: usize) -> Result<Vec<Story>, String> {
|
||||
match &SOURCES[source_idx].kind {
|
||||
SourceKind::HackerNews => fetch_hn_stories(10).await.map_err(|e| e.to_string()),
|
||||
SourceKind::Reddit(sub) => fetch_reddit_stories(sub).await,
|
||||
SourceKind::Rss(url) => fetch_rss_stories(url).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hacker News ───────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn fetch_hn_stories(count: usize) -> Result<Vec<Story>, reqwest::Error> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let ids: Vec<u64> = client
|
||||
.get("https://hacker-news.firebaseio.com/v0/topstories.json")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let top_ids: Vec<u64> = ids.into_iter().take(count).collect();
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for id in top_ids {
|
||||
let client = client.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let url = format!("https://hacker-news.firebaseio.com/v0/item/{}.json", id);
|
||||
match client.get(&url).send().await {
|
||||
Ok(r) => r.json::<Story>().await.ok(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let mut stories = Vec::new();
|
||||
for h in handles {
|
||||
if let Ok(Some(s)) = h.await {
|
||||
stories.push(s);
|
||||
}
|
||||
}
|
||||
Ok(stories)
|
||||
}
|
||||
|
||||
// ── Reddit JSON API ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RedditListing {
|
||||
data: RedditListingData,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct RedditListingData {
|
||||
children: Vec<RedditChild>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct RedditChild {
|
||||
data: RedditPostData,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct RedditPostData {
|
||||
title: String,
|
||||
url: Option<String>,
|
||||
permalink: String,
|
||||
author: String,
|
||||
score: i32,
|
||||
num_comments: i32,
|
||||
created_utc: f64,
|
||||
selftext: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn fetch_reddit_stories(subreddit: &str) -> Result<Vec<Story>, String> {
|
||||
let url = format!(
|
||||
"https://www.reddit.com/r/{}/top.json?limit=10&t=day",
|
||||
subreddit
|
||||
);
|
||||
let client = reqwest::Client::builder()
|
||||
// Reddit requires a descriptive user-agent
|
||||
.user_agent("tech-news-reader/1.0")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let listing: RedditListing = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let stories = listing
|
||||
.data
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|child| {
|
||||
let d = child.data;
|
||||
// Prefer the external link; fall back to the Reddit thread
|
||||
let story_url = match &d.url {
|
||||
Some(u) if !u.contains("reddit.com") => Some(u.clone()),
|
||||
_ => Some(format!("https://www.reddit.com{}", d.permalink)),
|
||||
};
|
||||
Story {
|
||||
id: str_hash(&d.permalink),
|
||||
title: Some(d.title),
|
||||
url: story_url,
|
||||
score: Some(d.score),
|
||||
by: Some(d.author),
|
||||
descendants: Some(d.num_comments),
|
||||
time: Some(d.created_utc as u64),
|
||||
text: d.selftext.filter(|s| !s.is_empty()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(stories)
|
||||
}
|
||||
|
||||
// ── RSS feeds (BBC, NASA, …) ──────────────────────────────────────────────────
|
||||
|
||||
pub async fn fetch_rss_stories(feed_url: &str) -> Result<Vec<Story>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let bytes = client
|
||||
.get(feed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let channel = rss::Channel::read_from(std::io::Cursor::new(bytes))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let stories = channel
|
||||
.items()
|
||||
.iter()
|
||||
.take(10)
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let url = item.link().map(|s| s.to_string());
|
||||
Story {
|
||||
id: url.as_deref().map(str_hash).unwrap_or(i as u64),
|
||||
title: item.title().map(|s| s.to_string()),
|
||||
url,
|
||||
score: None,
|
||||
by: item
|
||||
.author()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
item.dublin_core_ext()
|
||||
.and_then(|dc| dc.creators().first())
|
||||
.map(|s| s.to_string())
|
||||
}),
|
||||
descendants: None,
|
||||
time: item.pub_date().and_then(parse_rfc2822),
|
||||
text: item.description().map(|s| s.to_string()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(stories)
|
||||
}
|
||||
|
||||
// ── Article content ───────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn fetch_article_content(url: &str) -> String {
|
||||
let client = match reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return format!("Failed to create HTTP client: {}", e),
|
||||
};
|
||||
|
||||
match client.get(url).send().await {
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(html) => html_to_text(&html),
|
||||
Err(e) => format!("Failed to read response body:\n{}", e),
|
||||
},
|
||||
Err(e) => format!(
|
||||
"Could not fetch article content.\n\nError: {}\n\nOpen in your browser:\n{}",
|
||||
e, url
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTML → plain text ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn html_to_text(html: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut in_tag = false;
|
||||
let mut in_skip = false;
|
||||
let mut tag_buf = String::new();
|
||||
|
||||
for ch in html.chars() {
|
||||
if ch == '<' {
|
||||
in_tag = true;
|
||||
tag_buf.clear();
|
||||
} else if ch == '>' && in_tag {
|
||||
in_tag = false;
|
||||
let tag = tag_buf.trim().to_lowercase();
|
||||
let first_word = tag.split_whitespace().next().unwrap_or("");
|
||||
let closing = first_word.starts_with('/');
|
||||
let name = first_word.trim_start_matches('/');
|
||||
|
||||
if name == "script" || name == "style" {
|
||||
in_skip = !closing;
|
||||
} else if !in_skip {
|
||||
match name {
|
||||
"br" => result.push('\n'),
|
||||
"p" | "/p" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "li"
|
||||
| "tr" | "blockquote" => {
|
||||
if !result.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else if in_tag {
|
||||
tag_buf.push(ch);
|
||||
} else if !in_skip {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("'", "'")
|
||||
.replace("/", "/");
|
||||
|
||||
let mut cleaned = String::new();
|
||||
let mut blank_count = 0u32;
|
||||
for line in decoded.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
blank_count += 1;
|
||||
if blank_count == 1 {
|
||||
cleaned.push('\n');
|
||||
}
|
||||
} else {
|
||||
blank_count = 0;
|
||||
cleaned.push_str(trimmed);
|
||||
cleaned.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
cleaned.trim().to_string()
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn str_hash(s: &str) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
s.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Parse an RFC 2822 date string (e.g. "Sat, 11 Apr 2026 12:00:00 +0000")
|
||||
/// into a Unix timestamp. Returns None if the format is unrecognised.
|
||||
fn parse_rfc2822(s: &str) -> Option<u64> {
|
||||
// Drop optional "DDD, " prefix
|
||||
let s = match s.find(',') {
|
||||
Some(pos) => s[pos + 1..].trim(),
|
||||
None => s.trim(),
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = s.split_whitespace().collect();
|
||||
if parts.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let day: i64 = parts[0].parse().ok()?;
|
||||
let month: i64 = match parts[1] {
|
||||
"Jan" => 1, "Feb" => 2, "Mar" => 3, "Apr" => 4,
|
||||
"May" => 5, "Jun" => 6, "Jul" => 7, "Aug" => 8,
|
||||
"Sep" => 9, "Oct" => 10, "Nov" => 11, "Dec" => 12,
|
||||
_ => return None,
|
||||
};
|
||||
let year: i64 = parts[2].parse().ok()?;
|
||||
|
||||
let hms: Vec<i64> = parts[3].split(':').filter_map(|p| p.parse().ok()).collect();
|
||||
let (h, m, sec) = match hms.as_slice() {
|
||||
[h, m, s, ..] => (*h, *m, *s),
|
||||
[h, m] => (*h, *m, 0),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Julian Day Number → unix timestamp
|
||||
let ym = (month - 14) / 12;
|
||||
let yyyy = year + 4800 + ym;
|
||||
let mm = month - 12 * ym - 3;
|
||||
let jdn = day + (153 * mm + 2) / 5 + 365 * yyyy + yyyy / 4 - yyyy / 100 + yyyy / 400 - 32045;
|
||||
let days = jdn - 2_440_588; // JDN of 1970-01-01
|
||||
if days < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(days as u64 * 86_400 + h as u64 * 3_600 + m as u64 * 60 + sec as u64)
|
||||
}
|
||||
438
src/ui.rs
Normal file
438
src/ui.rs
Normal file
@@ -0,0 +1,438 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::{App, Tab};
|
||||
use crate::news::SOURCES;
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||
let size = f.size();
|
||||
|
||||
// Layout: tab bar (1) | source bar (1) | main panels (flex) | status bar (1)
|
||||
let outer = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(size);
|
||||
|
||||
let tab_area = outer[0];
|
||||
let source_area = outer[1];
|
||||
let main_area = outer[2];
|
||||
let status_area = outer[3];
|
||||
|
||||
// Main area: left 30% | right 70%
|
||||
let panes = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(main_area);
|
||||
|
||||
draw_tab_bar(f, app, tab_area);
|
||||
draw_source_bar(f, app, source_area);
|
||||
draw_stories_panel(f, app, panes[0]);
|
||||
draw_content_panel(f, app, panes[1]);
|
||||
draw_status_bar(f, app, status_area);
|
||||
}
|
||||
|
||||
// ── Tab bar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_tab_bar(f: &mut Frame, app: &App, area: Rect) {
|
||||
let active = Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let inactive = Style::default().fg(Color::DarkGray).bg(Color::Black);
|
||||
|
||||
let saved_label = if app.saved_stories.is_empty() {
|
||||
" [2] Saved ".to_string()
|
||||
} else {
|
||||
format!(" [2] Saved ({}) ", app.saved_stories.len())
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
" [1] Top Stories ",
|
||||
if app.current_tab == Tab::TopStories { active } else { inactive },
|
||||
),
|
||||
Span::styled(" ", Style::default().bg(Color::Black)),
|
||||
Span::styled(
|
||||
saved_label,
|
||||
if app.current_tab == Tab::Saved { active } else { inactive },
|
||||
),
|
||||
]);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(line).style(Style::default().bg(Color::Black)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Source bar ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_source_bar(f: &mut Frame, app: &App, area: Rect) {
|
||||
let active = app.current_tab == Tab::TopStories;
|
||||
|
||||
let mut spans = vec![Span::styled(" ← ", Style::default().fg(Color::DarkGray))];
|
||||
|
||||
for (i, src) in SOURCES.iter().enumerate() {
|
||||
let is_selected = i == app.current_source;
|
||||
let style = if is_selected && active {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_selected {
|
||||
// on Saved tab: show current source but dimmed
|
||||
Style::default().fg(Color::DarkGray).bg(Color::Black).add_modifier(Modifier::BOLD)
|
||||
} else if active {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default().fg(Color::Black) // almost invisible on Saved tab
|
||||
};
|
||||
spans.push(Span::styled(format!(" {} ", src.label), style));
|
||||
if i < SOURCES.len() - 1 {
|
||||
spans.push(Span::styled(" ", Style::default().bg(Color::Black)));
|
||||
}
|
||||
}
|
||||
|
||||
spans.push(Span::styled(" →", Style::default().fg(Color::DarkGray)));
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Left panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_stories_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
let panel_title = match app.current_tab {
|
||||
Tab::TopStories => " HN Top Stories ",
|
||||
Tab::Saved => " Saved Stories ",
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
panel_title,
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
|
||||
match app.current_tab {
|
||||
Tab::TopStories => draw_top_stories_list(f, app, area, block),
|
||||
Tab::Saved => draw_saved_list(f, app, area, block),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_top_stories_list(
|
||||
f: &mut Frame,
|
||||
app: &mut App,
|
||||
area: Rect,
|
||||
block: Block,
|
||||
) {
|
||||
if app.loading_stories {
|
||||
f.render_widget(
|
||||
Paragraph::new(" Loading stories…")
|
||||
.block(block)
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(err) = app.error.clone() {
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("Error:\n\n{}", err))
|
||||
.block(block)
|
||||
.style(Style::default().fg(Color::Red))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if app.stories.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(" No stories.\n Press 'r' to refresh.")
|
||||
.block(block)
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.stories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, story)| {
|
||||
let title = story.title.as_deref().unwrap_or("Untitled");
|
||||
let score = story.score.unwrap_or(0);
|
||||
let time_str = story.time.map(format_time_ago).unwrap_or_default();
|
||||
let bookmark = if app.is_saved(story.id) {
|
||||
Span::styled("★ ", Style::default().fg(Color::Yellow))
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
|
||||
let title_line = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:2}. ", i + 1),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
bookmark,
|
||||
Span::raw(title.to_string()),
|
||||
]);
|
||||
let meta_line = Line::from(vec![Span::styled(
|
||||
format!(" ★{} {}", score, time_str),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)]);
|
||||
|
||||
ListItem::new(Text::from(vec![title_line, meta_line]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(block)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(Color::Blue)
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("▶ ");
|
||||
|
||||
f.render_stateful_widget(list, area, &mut app.list_state);
|
||||
}
|
||||
|
||||
fn draw_saved_list(f: &mut Frame, app: &mut App, area: Rect, block: Block) {
|
||||
if app.saved_stories.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(" No saved stories.\n\n Press 's' on any\n story to save it.")
|
||||
.block(block)
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.saved_stories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, story)| {
|
||||
let title = story.title.as_deref().unwrap_or("Untitled");
|
||||
let score = story.score.unwrap_or(0);
|
||||
let time_str = story.time.map(format_time_ago).unwrap_or_default();
|
||||
|
||||
let title_line = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:2}. ", i + 1),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled("★ ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(title.to_string()),
|
||||
]);
|
||||
let meta_line = Line::from(vec![Span::styled(
|
||||
format!(" ★{} {}", score, time_str),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)]);
|
||||
|
||||
ListItem::new(Text::from(vec![title_line, meta_line]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(block)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(Color::Blue)
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("▶ ");
|
||||
|
||||
f.render_stateful_widget(list, area, &mut app.saved_list_state);
|
||||
}
|
||||
|
||||
// ── Right panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_content_panel(f: &mut Frame, app: &App, area: Rect) {
|
||||
let active_stories = match app.current_tab {
|
||||
Tab::TopStories => &app.stories,
|
||||
Tab::Saved => &app.saved_stories,
|
||||
};
|
||||
let active_idx = match app.current_tab {
|
||||
Tab::TopStories => app.selected,
|
||||
Tab::Saved => app.saved_selected,
|
||||
};
|
||||
|
||||
let selected = active_stories.get(active_idx);
|
||||
|
||||
let panel_title = selected
|
||||
.and_then(|s| s.title.as_deref())
|
||||
.unwrap_or("Tech News Reader");
|
||||
|
||||
let block = Block::default()
|
||||
.title(format!(" {} ", panel_title))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::DarkGray));
|
||||
|
||||
if active_stories.is_empty() {
|
||||
let msg = match app.current_tab {
|
||||
Tab::TopStories => "\n Fetching top stories from Hacker News…",
|
||||
Tab::Saved => "\n No saved stories yet.\n\n Navigate to Top Stories and\n press 's' to save a story.",
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(msg)
|
||||
.block(block)
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
if let Some(story) = selected {
|
||||
let score = story.score.unwrap_or(0);
|
||||
let author = story.by.as_deref().unwrap_or("unknown");
|
||||
let comments = story.descendants.unwrap_or(0);
|
||||
let time_str = story.time.map(format_time_ago).unwrap_or_default();
|
||||
let saved_tag = if app.is_saved(story.id) {
|
||||
Span::styled(" ★ saved", Style::default().fg(Color::Yellow))
|
||||
} else {
|
||||
Span::raw("")
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ★ ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(format!("{} pts", score), Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" · "),
|
||||
Span::styled(format!("by {}", author), Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" · "),
|
||||
Span::raw(time_str),
|
||||
Span::raw(" · "),
|
||||
Span::styled(
|
||||
format!("{} comments", comments),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
saved_tag,
|
||||
]));
|
||||
|
||||
if let Some(url) = &story.url {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" URL: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(url.as_str(), Style::default().fg(Color::Blue)),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
" ".to_string() + &"─".repeat(area.width.saturating_sub(4) as usize),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
if app.loading_content {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Loading article content…",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
} else if let Some(content) = &app.content {
|
||||
for line in content.lines() {
|
||||
lines.push(Line::from(format!(" {}", line)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(Text::from(lines))
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((app.content_scroll, 0));
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
// ── Status bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) {
|
||||
let right_side = if let Some(msg) = &app.status_msg {
|
||||
Span::styled(
|
||||
format!(" {}", msg),
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
Span::raw("")
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
key(" ↑/k"),
|
||||
sep(" up "),
|
||||
key("↓/j"),
|
||||
sep(" down "),
|
||||
key("←/→"),
|
||||
sep(" source "),
|
||||
key("PgUp/PgDn"),
|
||||
sep(" scroll "),
|
||||
key("s"),
|
||||
sep(" save "),
|
||||
key("1"),
|
||||
sep("/"),
|
||||
key("2"),
|
||||
sep(" tabs "),
|
||||
key("r"),
|
||||
sep(" refresh "),
|
||||
key("q"),
|
||||
sep(" quit"),
|
||||
right_side,
|
||||
]);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(line).style(Style::default().bg(Color::DarkGray)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn key(label: &'static str) -> Span<'static> {
|
||||
Span::styled(label, Style::default().fg(Color::Black).bg(Color::Cyan))
|
||||
}
|
||||
|
||||
fn sep(label: &'static str) -> Span<'static> {
|
||||
Span::styled(
|
||||
label,
|
||||
Style::default().bg(Color::DarkGray).fg(Color::White),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn format_time_ago(unix_time: u64) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let diff = now.saturating_sub(unix_time);
|
||||
if diff < 60 {
|
||||
"just now".to_string()
|
||||
} else if diff < 3600 {
|
||||
format!("{}m ago", diff / 60)
|
||||
} else if diff < 86400 {
|
||||
format!("{}h ago", diff / 3600)
|
||||
} else {
|
||||
format!("{}d ago", diff / 86400)
|
||||
}
|
||||
}
|
||||
1
target/.rustc_info.json
Normal file
1
target/.rustc_info.json
Normal file
@@ -0,0 +1 @@
|
||||
{"rustc_fingerprint":12575832732247077041,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.94.1 (e408947bf 2026-03-25)\nbinary: rustc\ncommit-hash: e408947bfd200af42db322daf0fadfe7e26d3bd1\ncommit-date: 2026-03-25\nhost: x86_64-unknown-linux-gnu\nrelease: 1.94.1\nLLVM version: 21.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/arthur/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
3
target/CACHEDIR.TAG
Normal file
3
target/CACHEDIR.TAG
Normal file
@@ -0,0 +1,3 @@
|
||||
Signature: 8a477f597d28d172789f06886806bc55
|
||||
# This file is a cache directory tag created by cargo.
|
||||
# For information about cache directory tags see https://bford.info/cachedir/
|
||||
0
target/debug/.cargo-lock
Normal file
0
target/debug/.cargo-lock
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
cb5984a63912944e
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"default\", \"fresh-rust\", \"nightly\", \"serde\", \"std\"]","target":5388200169723499962,"profile":12994027242049262075,"path":619616282504145460,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/allocator-api2-c8f86b29da3b8e3b/dep-lib-allocator_api2","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
a92755eb0cc170f7
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"builders\", \"default\", \"derive_builder\", \"never\"]","declared_features":"[\"builders\", \"default\", \"derive_builder\", \"never\", \"serde\", \"with-serde\"]","target":13033617321090678130,"profile":15657897354478470176,"path":908793774483672517,"deps":[[1462335029370885857,"quick_xml",false,1722954145813896942],[3856126590694406759,"chrono",false,7232410654852562550],[6219554740863759696,"derive_builder",false,7578984412507588111],[12978237078517444867,"never",false,15288010464058099274],[17752799662437466077,"diligent_date_parser",false,16700558642039594699]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atom_syndication-ade6759b54edb6da/dep-lib-atom_syndication","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
885ad53e32e2f9fe
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":15657897354478470176,"path":8988350338904927053,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atomic-waker-843b52043047247e/dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
7fea944c97db42e7
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":2225463790103693989,"path":6839797016381145001,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/autocfg-9ef4b25a9bd36cd2/dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
BIN
target/debug/.fingerprint/base64-e5cc050039038c59/dep-lib-base64
Normal file
BIN
target/debug/.fingerprint/base64-e5cc050039038c59/dep-lib-base64
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
b9648a5c8b369634
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":15657897354478470176,"path":16618588621241649313,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/base64-e5cc050039038c59/dep-lib-base64","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
a68ad499da59f93b
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"arbitrary\", \"bytemuck\", \"example_generated\", \"serde\", \"serde_core\", \"std\"]","target":7691312148208718491,"profile":15657897354478470176,"path":6020575681240194064,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/bitflags-b60e409526351a4f/dep-lib-bitflags","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
BIN
target/debug/.fingerprint/bytes-394efa973d3a621d/dep-lib-bytes
Normal file
BIN
target/debug/.fingerprint/bytes-394efa973d3a621d/dep-lib-bytes
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
32ddc84b25e855aa
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"extra-platforms\", \"serde\", \"std\"]","target":11402411492164584411,"profile":5585765287293540646,"path":6468508731397481850,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/bytes-394efa973d3a621d/dep-lib-bytes","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
2cfb69c12a6b273b
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[]","target":10353004457644949388,"profile":15657897354478470176,"path":15323988964894262287,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cassowary-21a48475a531bb09/dep-lib-cassowary","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
8ad186d6d0e2cc34
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13710694652376480987,"profile":15657897354478470176,"path":16945805339708472456,"deps":[[14156967978702956262,"rustversion",false,2681997257224848000]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/castaway-0888e1655b1a9c88/dep-lib-castaway","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
BIN
target/debug/.fingerprint/cc-2a2381873bc497da/dep-lib-cc
Normal file
BIN
target/debug/.fingerprint/cc-2a2381873bc497da/dep-lib-cc
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
1
target/debug/.fingerprint/cc-2a2381873bc497da/lib-cc
Normal file
1
target/debug/.fingerprint/cc-2a2381873bc497da/lib-cc
Normal file
@@ -0,0 +1 @@
|
||||
5997136faccfba20
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"jobserver\", \"parallel\"]","target":11042037588551934598,"profile":4333757155065362140,"path":14161913320590019938,"deps":[[8410525223747752176,"shlex",false,8656756483659441355],[9159843920629750842,"find_msvc_tools",false,13402027261808900383]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cc-2a2381873bc497da/dep-lib-cc","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
BIN
target/debug/.fingerprint/cfg-if-240b169be3bd5d4e/dep-lib-cfg_if
Normal file
BIN
target/debug/.fingerprint/cfg-if-240b169be3bd5d4e/dep-lib-cfg_if
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
19e929c603a03493
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":15657897354478470176,"path":13005896658499936504,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cfg-if-240b169be3bd5d4e/dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
BIN
target/debug/.fingerprint/chrono-14e6bc6a6850799f/dep-lib-chrono
Normal file
BIN
target/debug/.fingerprint/chrono-14e6bc6a6850799f/dep-lib-chrono
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
76be3cf02baf5e64
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"alloc\"]","declared_features":"[\"__internal_bench\", \"alloc\", \"arbitrary\", \"clock\", \"core-error\", \"default\", \"defmt\", \"iana-time-zone\", \"js-sys\", \"libc\", \"now\", \"oldtime\", \"pure-rust-locales\", \"rkyv\", \"rkyv-16\", \"rkyv-32\", \"rkyv-64\", \"rkyv-validation\", \"serde\", \"std\", \"unstable-locales\", \"wasm-bindgen\", \"wasmbind\", \"winapi\", \"windows-link\"]","target":15315924755136109342,"profile":15657897354478470176,"path":1602253831651938195,"deps":[[5157631553186200874,"num_traits",false,12991895983547534235]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/chrono-14e6bc6a6850799f/dep-lib-chrono","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
9b886f8893e5d6ae
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"arbitrary\", \"bytes\", \"markup\", \"proptest\", \"quickcheck\", \"rkyv\", \"serde\", \"smallvec\"]","target":12681387934967326413,"profile":15657897354478470176,"path":10759431940353196692,"deps":[[1127187624154154345,"castaway",false,3804665171801461130],[5532778797167691009,"itoa",false,8818657059401401231],[6400797066282925533,"ryu",false,16350468532189042288],[7667230146095136825,"cfg_if",false,10607278960434342169],[13785866025199020095,"static_assertions",false,14862852550781130700]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/compact_str-efab33ad941c7db8/dep-lib-compact_str","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
a28d4cf3718f00b6
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"bracketed-paste\", \"default\", \"events\", \"windows\"]","declared_features":"[\"bracketed-paste\", \"default\", \"event-stream\", \"events\", \"filedescriptor\", \"serde\", \"use-dev-tty\", \"windows\"]","target":7162149947039624270,"profile":15657897354478470176,"path":15856675640070458482,"deps":[[4627466251042474366,"signal_hook_mio",false,3915479502761970361],[10703860158168350592,"mio",false,12055936156763945462],[12111499963430175700,"libc",false,2799162022118210411],[12459942763388630573,"parking_lot",false,10945704542167786807],[16909888598953886583,"bitflags",false,4321584112857287334],[17154765528929363175,"signal_hook",false,10674799042310853444]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/crossterm-0ecb4b119fa3a89c/dep-lib-crossterm","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
3f538784ad9af30f
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"default\", \"suggestions\"]","declared_features":"[\"default\", \"diagnostics\", \"suggestions\"]","target":10425393644641512883,"profile":4791074740661137825,"path":9429561734597034915,"deps":[[391311489375721310,"darling_macro",false,2560528864453042054],[7492649247881633246,"darling_core",false,13530443843825157893]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/darling-fac78cc8855a079a/dep-lib-darling","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
05c79114fdcac5bb
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"strsim\", \"suggestions\"]","declared_features":"[\"diagnostics\", \"strsim\", \"suggestions\"]","target":13428977600034985537,"profile":2225463790103693989,"path":10695231833177288263,"deps":[[1345404220202658316,"fnv",false,5820204425362057141],[4289358735036141001,"proc_macro2",false,16644934188774033358],[10420560437213941093,"syn",false,10887743778332594988],[11166530783118767604,"strsim",false,8015746165858899359],[13111758008314797071,"quote",false,10965562302966806733],[15383437925411509181,"ident_case",false,6797437418825428054]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/darling_core-97f0605d3fffe38b/dep-lib-darling_core","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
869fdb9a6fd38823
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[]","target":15692157989113707310,"profile":2225463790103693989,"path":11994422053592691527,"deps":[[7492649247881633246,"darling_core",false,13530443843825157893],[10420560437213941093,"syn",false,10887743778332594988],[13111758008314797071,"quote",false,10965562302966806733]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/darling_macro-9fdaee6df028cfa9/dep-lib-darling_macro","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
0fc6bc012bf62d69
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"default\", \"std\"]","declared_features":"[\"alloc\", \"clippy\", \"default\", \"std\"]","target":8513585915772363107,"profile":9398156148949759868,"path":18303750549334772989,"deps":[[16001110498200919332,"derive_builder_macro",false,1547462938064423890]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/derive_builder-a8db94dcfaf1df2d/dep-lib-derive_builder","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
51980137f55a24cc
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"lib_has_std\"]","declared_features":"[\"alloc\", \"clippy\", \"lib_has_std\"]","target":15805722739128704647,"profile":2225463790103693989,"path":7483024297130915841,"deps":[[496455418292392305,"darling",false,1149432399953089343],[4289358735036141001,"proc_macro2",false,16644934188774033358],[10420560437213941093,"syn",false,10887743778332594988],[13111758008314797071,"quote",false,10965562302966806733]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/derive_builder_core-8cfa3811d44c4fc6/dep-lib-derive_builder_core","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
d28b69e657b17915
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[\"lib_has_std\"]","declared_features":"[\"alloc\", \"clippy\", \"lib_has_std\"]","target":15229808779680689443,"profile":2225463790103693989,"path":16050740893568096425,"deps":[[4003231138667150418,"derive_builder_core",false,14709982292135221329],[10420560437213941093,"syn",false,10887743778332594988]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/derive_builder_macro-06d851d5c70afe9f/dep-lib-derive_builder_macro","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
cb9e59019a4dc4e7
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[]","target":4843579053871636606,"profile":15657897354478470176,"path":75932892441500446,"deps":[[3856126590694406759,"chrono",false,7232410654852562550]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/diligent-date-parser-2b4a70e922d1d666/dep-lib-diligent_date_parser","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
95e7de704ca4e740
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"default\", \"std\"]","target":9331843185013996172,"profile":2225463790103693989,"path":6518293226941545540,"deps":[[4289358735036141001,"proc_macro2",false,16644934188774033358],[10420560437213941093,"syn",false,10887743778332594988],[13111758008314797071,"quote",false,10965562302966806733]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/displaydoc-e177775816be846e/dep-lib-displaydoc","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
BIN
target/debug/.fingerprint/either-ed099fd5024e423c/dep-lib-either
Normal file
BIN
target/debug/.fingerprint/either-ed099fd5024e423c/dep-lib-either
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
20d6a142db72b9d7
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user