Skip to main content

rss_cli/
cli.rs

1//! Command-line surface (frozen) + mechanical arg→params conversion.
2//!
3//! The clap structs here define the stable command surface. Rendering and dispatch
4//! behavior is wired in [`crate::main`] / [`crate::output`] (owned by the `cli` agent),
5//! but the flags, defaults, and the arg→[`FetchParams`] mapping below are foundation.
6
7use 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/// An AI-friendly RSS/Atom feed CLI.
20#[derive(Debug, Parser)]
21#[command(name = "rss", version, about, long_about = None)]
22pub struct Cli {
23    #[command(subcommand)]
24    pub command: Command,
25
26    /// Override the cache directory.
27    #[arg(long, global = true, value_name = "DIR")]
28    pub cache_dir: Option<PathBuf>,
29
30    /// Suppress all non-data output on stderr.
31    #[arg(short, long, global = true)]
32    pub quiet: bool,
33
34    /// Increase logging verbosity (repeatable). Logs go to stderr.
35    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
36    pub verbose: u8,
37
38    /// Disable ANSI color in text output (also respects the `NO_COLOR` env var).
39    #[arg(long, global = true)]
40    pub no_color: bool,
41}
42
43#[derive(Debug, Subcommand)]
44pub enum Command {
45    /// Fetch and parse one or more feeds, emitting structured items.
46    Fetch(FetchArgs),
47    /// Discover feeds advertised on a website homepage.
48    Discover(DiscoverArgs),
49    /// Show the full content of a single item by its stable id.
50    Show(ShowArgs),
51    /// Emit the JSON Schema of a command's output.
52    Schema(SchemaArgs),
53    /// Inspect or clear the local cache.
54    Cache(CacheArgs),
55    /// Run as a Model Context Protocol server (stdio transport).
56    Mcp,
57}
58
59/// Output format, as selected on the command line.
60#[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/// Content extraction format, as selected on the command line.
79#[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    /// Feed URLs to fetch. Use `-` to read URLs from stdin (one per line).
102    #[arg(value_name = "URL")]
103    pub urls: Vec<String>,
104
105    /// Read feed URLs from an OPML file (uses each outline's `xmlUrl`).
106    #[arg(long, value_name = "FILE")]
107    pub opml: Option<PathBuf>,
108
109    /// Read feed URLs from a text file (one per line; `#` comments allowed).
110    #[arg(long, value_name = "FILE")]
111    pub input: Option<PathBuf>,
112
113    /// Output format.
114    #[arg(long, value_enum, default_value_t = FormatArg::Json)]
115    pub format: FormatArg,
116
117    /// With `--format ndjson`, emit one tagged record per line
118    /// (`{"type":"item"|"error"|"summary", …}`) instead of bare items, so feed-level errors
119    /// and aggregate totals stay in the stdout stream. No effect on other formats.
120    #[arg(long)]
121    pub ndjson_records: bool,
122
123    /// Content extraction format for item bodies.
124    #[arg(long, value_enum, default_value_t = ContentArg::Markdown)]
125    pub content: ContentArg,
126
127    /// Maximum items per feed (newest first).
128    #[arg(long, value_name = "N")]
129    pub limit: Option<usize>,
130
131    /// Truncate each item body to at most this many characters (flagged `content_truncated`
132    /// in the output). Useful to fetch many items while skipping giant bodies.
133    #[arg(long, value_name = "N")]
134    pub max_content_chars: Option<usize>,
135
136    /// Only include items at/after this time: a duration (e.g. `2h`, `7d`) or an
137    /// ISO-8601 date/datetime.
138    #[arg(long, value_name = "WHEN")]
139    pub since: Option<String>,
140
141    /// Max feeds fetched concurrently.
142    #[arg(long, default_value_t = 8, value_name = "N")]
143    pub concurrency: usize,
144
145    /// Per-request timeout in seconds.
146    #[arg(long, default_value_t = 30, value_name = "SECS")]
147    pub timeout: u64,
148
149    /// Bypass the cache entirely (no read, no write).
150    #[arg(long)]
151    pub no_cache: bool,
152
153    /// Serve from cache without revalidating if the entry is younger than this
154    /// duration (e.g. `15m`, `1h`).
155    #[arg(long, value_name = "DUR", conflicts_with_all = ["no_cache", "refresh"])]
156    pub max_age: Option<String>,
157
158    /// Force revalidation, ignoring `--max-age`.
159    #[arg(long, conflicts_with = "no_cache")]
160    pub refresh: bool,
161
162    /// Override the User-Agent header.
163    #[arg(long, value_name = "STRING")]
164    pub user_agent: Option<String>,
165}
166
167impl FetchArgs {
168    /// Resolve the effective cache policy from the cache-related flags.
169    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            // `--refresh` and the default both revalidate.
176            Ok(CachePolicy::Revalidate)
177        }
178    }
179
180    /// Build the [`FetchParams`] this invocation should use.
181    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    /// Gather feed URLs from all input sources (positional args, `--opml`, `--input`,
198    /// and stdin via `-`), de-duplicated while preserving order.
199    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    /// Website homepage URL to scan for feed `<link>` tags.
255    #[arg(value_name = "SITE_URL")]
256    pub site_url: String,
257
258    /// Output format.
259    #[arg(long, value_enum, default_value_t = FormatArg::Json)]
260    pub format: FormatArg,
261
262    /// Per-request timeout in seconds.
263    #[arg(long, default_value_t = 30, value_name = "SECS")]
264    pub timeout: u64,
265
266    /// Override the User-Agent header.
267    #[arg(long, value_name = "STRING")]
268    pub user_agent: Option<String>,
269}
270
271#[derive(Debug, Args)]
272pub struct ShowArgs {
273    /// Feed URL containing the item.
274    #[arg(value_name = "FEED_URL")]
275    pub feed_url: String,
276
277    /// Stable item id, raw guid, or item permalink URL (from a prior `fetch`). A guid is the
278    /// reliable key across different feed URLs (the id is namespaced by feed URL).
279    #[arg(long, value_name = "ITEM_KEY")]
280    pub id: String,
281
282    /// Bypass the cache-first read and revalidate the live feed (may miss items that have
283    /// rolled out of the feed window).
284    #[arg(long)]
285    pub refresh: bool,
286
287    /// Content extraction format.
288    #[arg(long, value_enum, default_value_t = ContentArg::Markdown)]
289    pub content: ContentArg,
290
291    /// Truncate the item body to at most this many characters (flagged `content_truncated`).
292    #[arg(long, value_name = "N")]
293    pub max_content_chars: Option<usize>,
294
295    /// Output format.
296    #[arg(long, value_enum, default_value_t = FormatArg::Json)]
297    pub format: FormatArg,
298}
299
300#[derive(Debug, Args)]
301pub struct SchemaArgs {
302    /// Which command's output schema to emit.
303    #[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    /// Print the cache directory path.
316    Path,
317    /// List cached feeds.
318    List {
319        /// Output format.
320        #[arg(long, value_enum, default_value_t = FormatArg::Json)]
321        format: FormatArg,
322    },
323    /// Remove all cache entries.
324    Clear,
325}
326
327/// Parse a `--since` value: a relative duration (`2h`, `7d`) or an ISO-8601 instant.
328pub fn parse_since(s: &str) -> Result<DateTime<Utc>, RssError> {
329    let s = s.trim();
330    // Try a relative duration first.
331    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    // Full RFC-3339 datetime.
337    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
338        return Ok(dt.with_timezone(&Utc));
339    }
340    // Bare date (assume midnight UTC).
341    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
351/// Parse a simple duration like `30s`, `15m`, `2h`, `7d`, `1w`.
352pub 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}