1use std::io::Read;
8use std::path::PathBuf;
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use clap::{Args, Parser, Subcommand, ValueEnum};
13
14use crate::config::{CachePolicy, DEFAULT_USER_AGENT, FetchParams};
15use crate::error::RssError;
16use crate::model::ContentFormat;
17use crate::output::OutputFormat;
18
19#[derive(Debug, Parser)]
21#[command(name = "rss", version, about, long_about = None)]
22pub struct Cli {
23 #[command(subcommand)]
24 pub command: Command,
25
26 #[arg(long, global = true, value_name = "DIR")]
28 pub cache_dir: Option<PathBuf>,
29
30 #[arg(short, long, global = true)]
32 pub quiet: bool,
33
34 #[arg(short, long, global = true, action = clap::ArgAction::Count)]
36 pub verbose: u8,
37
38 #[arg(long, global = true)]
40 pub no_color: bool,
41}
42
43#[derive(Debug, Subcommand)]
44pub enum Command {
45 Fetch(FetchArgs),
47 Discover(DiscoverArgs),
49 Show(ShowArgs),
51 Schema(SchemaArgs),
53 Cache(CacheArgs),
55 Mcp,
57}
58
59#[derive(Debug, Clone, Copy, Default, ValueEnum)]
61pub enum FormatArg {
62 #[default]
63 Json,
64 Ndjson,
65 Text,
66}
67
68impl From<FormatArg> for OutputFormat {
69 fn from(f: FormatArg) -> Self {
70 match f {
71 FormatArg::Json => OutputFormat::Json,
72 FormatArg::Ndjson => OutputFormat::Ndjson,
73 FormatArg::Text => OutputFormat::Text,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, Default, ValueEnum)]
80pub enum ContentArg {
81 #[default]
82 Markdown,
83 Text,
84 Html,
85 None,
86}
87
88impl From<ContentArg> for ContentFormat {
89 fn from(c: ContentArg) -> Self {
90 match c {
91 ContentArg::Markdown => ContentFormat::Markdown,
92 ContentArg::Text => ContentFormat::Text,
93 ContentArg::Html => ContentFormat::Html,
94 ContentArg::None => ContentFormat::None,
95 }
96 }
97}
98
99#[derive(Debug, Args)]
100pub struct FetchArgs {
101 #[arg(value_name = "URL")]
103 pub urls: Vec<String>,
104
105 #[arg(long, value_name = "FILE")]
107 pub opml: Option<PathBuf>,
108
109 #[arg(long, value_name = "FILE")]
111 pub input: Option<PathBuf>,
112
113 #[arg(long, value_enum, default_value_t = FormatArg::Json)]
115 pub format: FormatArg,
116
117 #[arg(long)]
121 pub ndjson_records: bool,
122
123 #[arg(long, value_enum, default_value_t = ContentArg::Markdown)]
125 pub content: ContentArg,
126
127 #[arg(long, value_name = "N")]
129 pub limit: Option<usize>,
130
131 #[arg(long, value_name = "N")]
134 pub max_content_chars: Option<usize>,
135
136 #[arg(long, value_name = "WHEN")]
139 pub since: Option<String>,
140
141 #[arg(long, default_value_t = 8, value_name = "N")]
143 pub concurrency: usize,
144
145 #[arg(long, default_value_t = 30, value_name = "SECS")]
147 pub timeout: u64,
148
149 #[arg(long)]
151 pub no_cache: bool,
152
153 #[arg(long, value_name = "DUR", conflicts_with_all = ["no_cache", "refresh"])]
156 pub max_age: Option<String>,
157
158 #[arg(long, conflicts_with = "no_cache")]
160 pub refresh: bool,
161
162 #[arg(long, value_name = "STRING")]
164 pub user_agent: Option<String>,
165}
166
167impl FetchArgs {
168 pub fn cache_policy(&self) -> Result<CachePolicy, RssError> {
170 if self.no_cache {
171 Ok(CachePolicy::NoCache)
172 } else if let Some(ma) = &self.max_age {
173 Ok(CachePolicy::MaxAge(parse_duration(ma)?))
174 } else {
175 Ok(CachePolicy::Revalidate)
177 }
178 }
179
180 pub fn to_params(&self) -> Result<FetchParams, RssError> {
182 Ok(FetchParams {
183 content_format: self.content.into(),
184 limit: self.limit,
185 max_content_chars: self.max_content_chars,
186 since: self.since.as_deref().map(parse_since).transpose()?,
187 concurrency: self.concurrency.max(1),
188 timeout: Duration::from_secs(self.timeout),
189 user_agent: self
190 .user_agent
191 .clone()
192 .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()),
193 cache_policy: self.cache_policy()?,
194 })
195 }
196
197 pub fn collect_urls(&self) -> Result<Vec<String>, RssError> {
200 let mut urls: Vec<String> = Vec::new();
201 let push = |u: String, urls: &mut Vec<String>| {
202 let u = u.trim().to_string();
203 if !u.is_empty() && !u.starts_with('#') && !urls.contains(&u) {
204 urls.push(u);
205 }
206 };
207
208 for u in &self.urls {
209 if u == "-" {
210 let mut buf = String::new();
211 std::io::stdin().read_to_string(&mut buf)?;
212 for line in buf.lines() {
213 push(line.to_string(), &mut urls);
214 }
215 } else {
216 push(u.clone(), &mut urls);
217 }
218 }
219
220 if let Some(path) = &self.input {
221 let text = std::fs::read_to_string(path)?;
222 for line in text.lines() {
223 push(line.to_string(), &mut urls);
224 }
225 }
226
227 if let Some(path) = &self.opml {
228 let text = std::fs::read_to_string(path)?;
229 let doc = opml::OPML::from_str(&text)
230 .map_err(|e| RssError::Usage(format!("invalid OPML: {e}")))?;
231 collect_opml_urls(&doc.body.outlines, &mut |u| push(u, &mut urls));
232 }
233
234 if urls.is_empty() {
235 return Err(RssError::Usage(
236 "no feed URLs provided (pass URLs, --input, --opml, or `-` for stdin)".into(),
237 ));
238 }
239 Ok(urls)
240 }
241}
242
243fn collect_opml_urls(outlines: &[opml::Outline], push: &mut impl FnMut(String)) {
244 for o in outlines {
245 if let Some(url) = &o.xml_url {
246 push(url.clone());
247 }
248 collect_opml_urls(&o.outlines, push);
249 }
250}
251
252#[derive(Debug, Args)]
253pub struct DiscoverArgs {
254 #[arg(value_name = "SITE_URL")]
256 pub site_url: String,
257
258 #[arg(long, value_enum, default_value_t = FormatArg::Json)]
260 pub format: FormatArg,
261
262 #[arg(long, default_value_t = 30, value_name = "SECS")]
264 pub timeout: u64,
265
266 #[arg(long, value_name = "STRING")]
268 pub user_agent: Option<String>,
269}
270
271#[derive(Debug, Args)]
272pub struct ShowArgs {
273 #[arg(value_name = "FEED_URL")]
275 pub feed_url: String,
276
277 #[arg(long, value_name = "ITEM_KEY")]
280 pub id: String,
281
282 #[arg(long)]
285 pub refresh: bool,
286
287 #[arg(long, value_enum, default_value_t = ContentArg::Markdown)]
289 pub content: ContentArg,
290
291 #[arg(long, value_name = "N")]
293 pub max_content_chars: Option<usize>,
294
295 #[arg(long, value_enum, default_value_t = FormatArg::Json)]
297 pub format: FormatArg,
298}
299
300#[derive(Debug, Args)]
301pub struct SchemaArgs {
302 #[arg(long, default_value = "fetch", value_parser = ["fetch", "discover"])]
304 pub command: String,
305}
306
307#[derive(Debug, Args)]
308pub struct CacheArgs {
309 #[command(subcommand)]
310 pub action: CacheAction,
311}
312
313#[derive(Debug, Subcommand)]
314pub enum CacheAction {
315 Path,
317 List {
319 #[arg(long, value_enum, default_value_t = FormatArg::Json)]
321 format: FormatArg,
322 },
323 Clear,
325}
326
327pub fn parse_since(s: &str) -> Result<DateTime<Utc>, RssError> {
329 let s = s.trim();
330 if let Ok(d) = parse_duration(s) {
332 let d = chrono::Duration::from_std(d)
333 .map_err(|e| RssError::Usage(format!("duration too large: {e}")))?;
334 return Ok(Utc::now() - d);
335 }
336 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
338 return Ok(dt.with_timezone(&Utc));
339 }
340 if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
342 && let Some(dt) = date.and_hms_opt(0, 0, 0)
343 {
344 return Ok(DateTime::from_naive_utc_and_offset(dt, Utc));
345 }
346 Err(RssError::Usage(format!(
347 "invalid --since value '{s}' (use e.g. '2h', '7d', or '2026-06-01')"
348 )))
349}
350
351pub fn parse_duration(s: &str) -> Result<Duration, RssError> {
353 let s = s.trim();
354 let (num, unit) = s.split_at(
355 s.find(|c: char| !c.is_ascii_digit())
356 .ok_or_else(|| RssError::Usage(format!("invalid duration '{s}'")))?,
357 );
358 let n: u64 = num
359 .parse()
360 .map_err(|_| RssError::Usage(format!("invalid duration '{s}'")))?;
361 let secs = match unit {
362 "s" => n,
363 "m" => n * 60,
364 "h" => n * 3600,
365 "d" => n * 86400,
366 "w" => n * 604800,
367 other => return Err(RssError::Usage(format!("unknown duration unit '{other}'"))),
368 };
369 Ok(Duration::from_secs(secs))
370}