1use anyhow::{bail, Context, Result};
2use chrono::{TimeZone, Utc};
3use reqwest::Client;
4use std::time::Duration;
5
6use crate::article_store::{ArticleStore, FilterParams};
7use crate::cache::CacheStore;
8use crate::cache::HttpMetadata;
9use crate::cli::Cli;
10use crate::config::{Config, FeedEntry};
11use crate::display;
12use crate::feed_source::{self, FetchResult};
13
14pub(crate) fn build_client() -> Client {
15 Client::builder()
16 .timeout(Duration::from_secs(10))
17 .connect_timeout(Duration::from_secs(5))
18 .gzip(true)
19 .brotli(true)
20 .build()
21 .unwrap_or_else(|_| Client::new())
22}
23
24pub async fn cmd_default(cli: &Cli, config: &Config, data_dir: &std::path::Path) -> Result<()> {
25 let feeds: Vec<&FeedEntry> = match (cli.tag.as_deref(), cli.name.as_deref()) {
26 (Some(t), _) => config.feeds_by_tag(t),
27 (_, Some(n)) => config.find_feed(n).into_iter().collect(),
28 _ => config.feeds.iter().collect(),
29 };
30
31 if feeds.is_empty() {
32 if config.feeds.is_empty() {
33 eprintln!("No feeds registered. Use `feed add <url>` to add one.");
34 } else {
35 eprintln!("No matching feeds found.");
36 }
37 return Ok(());
38 }
39
40 let owned_feeds: Vec<FeedEntry> = feeds.into_iter().cloned().collect();
41
42 let from = match &cli.from {
43 Some(from_str) => {
44 let from_date = from_str.parse::<chrono::NaiveDate>().map_err(|_| {
45 anyhow::anyhow!("Invalid date format: {}. Use YYYY-MM-DD", from_str)
46 })?;
47 Some(Utc.from_utc_datetime(&from_date.and_hms_opt(0, 0, 0).context("Invalid time")?))
48 }
49 None => None,
50 };
51
52 let filter_params = FilterParams {
53 show_read: cli.all,
54 from,
55 limit: cli.limit,
56 };
57
58 let mut store = ArticleStore::new(owned_feeds, config.clone(), data_dir.to_path_buf());
59
60 if cli.cli {
61 store.fetch(cli.cached).await;
62 let articles = store.query_articles(&filter_params);
63 if articles.is_empty() {
64 eprintln!("No articles found.");
65 return Ok(());
66 }
67 let items: Vec<display::DisplayItem> = articles
68 .iter()
69 .map(|a| display::DisplayItem {
70 title: &a.title,
71 url: &a.url,
72 published: a.published,
73 })
74 .collect();
75 let title = format!("{} feeds", store.feeds().len());
76 println!("{}", display::render_article_list(&title, &items));
77 } else {
78 store.fetch(true).await; crate::tui::run(store, filter_params).await?;
80 }
81
82 Ok(())
83}
84
85pub async fn cmd_fetch_article(url: &str) -> Result<()> {
86 let client = build_client();
87 let width = terminal_size::terminal_size()
88 .map(|(terminal_size::Width(w), _)| w as usize)
89 .unwrap_or(80);
90 let (title, text) =
91 crate::article::fetch_and_extract(&client, url, width.saturating_sub(2)).await?;
92 if !title.is_empty() {
93 println!("\n {}", title);
94 }
95 println!(" {}\n", url);
96 for line in text.lines() {
97 println!(" {}", line);
98 }
99 Ok(())
100}
101
102pub async fn cmd_fetch_feed(
103 url: &str,
104 config: &Config,
105 data_dir: &std::path::Path,
106 limit: Option<usize>,
107) -> Result<()> {
108 let client = build_client();
109 eprintln!("Resolving feed URL...");
110 let feed_url = feed_source::resolve_feed_url(&client, url).await?;
111 if feed_url != url {
112 eprintln!("Discovered feed: {}", feed_url);
113 }
114
115 let metadata = HttpMetadata {
116 etag: None,
117 last_modified: None,
118 };
119 let result = feed_source::fetch(&client, &feed_url, &metadata).await?;
120 let feed = match result {
121 FetchResult::Fetched(feed) => feed,
122 FetchResult::NotModified => {
123 eprintln!("Feed not modified");
124 return Ok(());
125 }
126 };
127
128 if config.cache.retention_days >= 0 {
129 CacheStore::new(data_dir).save_feed(
130 &feed_url,
131 &feed,
132 feed.etag.as_deref(),
133 feed.last_modified.as_deref(),
134 )?;
135 }
136
137 let entries: Vec<_> = feed
138 .entries
139 .iter()
140 .take(limit.unwrap_or(usize::MAX))
141 .collect();
142 let items: Vec<display::DisplayItem> = entries
143 .iter()
144 .map(|e| display::DisplayItem {
145 title: &e.title,
146 url: &e.url,
147 published: e.published,
148 })
149 .collect();
150 println!("{}", display::render_article_list(&feed.title, &items));
151 Ok(())
152}
153
154pub async fn cmd_add(
155 url: &str,
156 name: Option<&str>,
157 tags: &[String],
158 config_path: &std::path::Path,
159) -> Result<()> {
160 let client = build_client();
161 eprintln!("Resolving feed URL...");
162 let feed_url = feed_source::resolve_feed_url(&client, url).await?;
163 if feed_url != url {
164 eprintln!("Discovered feed: {}", feed_url);
165 }
166
167 let feed_name = match name {
168 Some(n) => n.to_string(),
169 None => {
170 eprintln!("Fetching feed title...");
171 let metadata = HttpMetadata {
172 etag: None,
173 last_modified: None,
174 };
175 let result = feed_source::fetch(&client, &feed_url, &metadata).await?;
176 match result {
177 FetchResult::Fetched(feed) => feed.title,
178 FetchResult::NotModified => "(unknown)".to_string(),
179 }
180 }
181 };
182
183 let mut config = Config::load(config_path)?;
184 config.add_feed(FeedEntry {
185 name: feed_name.clone(),
186 url: feed_url.to_string(),
187 tags: tags.to_vec(),
188 extractor: None,
189 });
190 config.save(config_path)?;
191
192 eprintln!("Added: {} ({})", feed_name, feed_url);
193 Ok(())
194}
195
196pub fn cmd_remove(target: &str, config_path: &std::path::Path) -> Result<()> {
197 let mut config = Config::load(config_path)?;
198 if config.remove_feed(target) {
199 config.save(config_path)?;
200 eprintln!("Removed: {}", target);
201 } else {
202 bail!("Feed not found: {}", target);
203 }
204 Ok(())
205}
206
207pub fn cmd_list(config: &Config) -> Result<()> {
208 println!("{}", display::render_feed_list(config));
209 Ok(())
210}
211
212pub fn cmd_tags(config: &Config) -> Result<()> {
213 println!("{}", display::render_tag_list(config));
214 Ok(())
215}