first commit

This commit is contained in:
2026-04-13 22:28:09 -06:00
commit 516926cef5
2185 changed files with 7731 additions and 0 deletions

2170
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

13
Cargo.toml Normal file
View 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
View 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
View 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
View 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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ")
.replace("&#x27;", "'")
.replace("&#x2F;", "/");
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
View 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
View 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
View 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
View File

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
cb5984a63912944e

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
a92755eb0cc170f7

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
885ad53e32e2f9fe

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
7fea944c97db42e7

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
b9648a5c8b369634

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
a68ad499da59f93b

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
32ddc84b25e855aa

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
2cfb69c12a6b273b

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
8ad186d6d0e2cc34

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
5997136faccfba20

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
19e929c603a03493

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
76be3cf02baf5e64

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
9b886f8893e5d6ae

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
a28d4cf3718f00b6

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
3f538784ad9af30f

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
05c79114fdcac5bb

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
869fdb9a6fd38823

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
0fc6bc012bf62d69

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
51980137f55a24cc

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
95e7de704ca4e740

View File

@@ -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}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
20d6a142db72b9d7

Some files were not shown because too many files have changed in this diff Show More