use std::io::Read;
use std::path::PathBuf;
use std::time::Duration;
use chrono::{DateTime, Utc};
use clap::{Args, Parser, Subcommand, ValueEnum};
use crate::config::{CachePolicy, DEFAULT_USER_AGENT, FetchParams};
use crate::error::RssError;
use crate::model::ContentFormat;
use crate::output::OutputFormat;
#[derive(Debug, Parser)]
#[command(name = "rss", version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
#[arg(long, global = true, value_name = "DIR")]
pub cache_dir: Option<PathBuf>,
#[arg(short, long, global = true)]
pub quiet: bool,
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
#[arg(long, global = true)]
pub no_color: bool,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Fetch(FetchArgs),
Discover(DiscoverArgs),
Show(ShowArgs),
Schema(SchemaArgs),
Cache(CacheArgs),
Mcp,
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum FormatArg {
#[default]
Json,
Ndjson,
Text,
}
impl From<FormatArg> for OutputFormat {
fn from(f: FormatArg) -> Self {
match f {
FormatArg::Json => OutputFormat::Json,
FormatArg::Ndjson => OutputFormat::Ndjson,
FormatArg::Text => OutputFormat::Text,
}
}
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum ContentArg {
#[default]
Markdown,
Text,
Html,
None,
}
impl From<ContentArg> for ContentFormat {
fn from(c: ContentArg) -> Self {
match c {
ContentArg::Markdown => ContentFormat::Markdown,
ContentArg::Text => ContentFormat::Text,
ContentArg::Html => ContentFormat::Html,
ContentArg::None => ContentFormat::None,
}
}
}
#[derive(Debug, Args)]
pub struct FetchArgs {
#[arg(value_name = "URL")]
pub urls: Vec<String>,
#[arg(long, value_name = "FILE")]
pub opml: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
pub input: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = FormatArg::Json)]
pub format: FormatArg,
#[arg(long)]
pub ndjson_records: bool,
#[arg(long, value_enum, default_value_t = ContentArg::Markdown)]
pub content: ContentArg,
#[arg(long, value_name = "N")]
pub limit: Option<usize>,
#[arg(long, value_name = "N")]
pub max_content_chars: Option<usize>,
#[arg(long, value_name = "WHEN")]
pub since: Option<String>,
#[arg(long, default_value_t = 8, value_name = "N")]
pub concurrency: usize,
#[arg(long, default_value_t = 30, value_name = "SECS")]
pub timeout: u64,
#[arg(long)]
pub no_cache: bool,
#[arg(long, value_name = "DUR", conflicts_with_all = ["no_cache", "refresh"])]
pub max_age: Option<String>,
#[arg(long, conflicts_with = "no_cache")]
pub refresh: bool,
#[arg(long, value_name = "STRING")]
pub user_agent: Option<String>,
}
impl FetchArgs {
pub fn cache_policy(&self) -> Result<CachePolicy, RssError> {
if self.no_cache {
Ok(CachePolicy::NoCache)
} else if let Some(ma) = &self.max_age {
Ok(CachePolicy::MaxAge(parse_duration(ma)?))
} else {
Ok(CachePolicy::Revalidate)
}
}
pub fn to_params(&self) -> Result<FetchParams, RssError> {
Ok(FetchParams {
content_format: self.content.into(),
limit: self.limit,
max_content_chars: self.max_content_chars,
since: self.since.as_deref().map(parse_since).transpose()?,
concurrency: self.concurrency.max(1),
timeout: Duration::from_secs(self.timeout),
user_agent: self
.user_agent
.clone()
.unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()),
cache_policy: self.cache_policy()?,
})
}
pub fn collect_urls(&self) -> Result<Vec<String>, RssError> {
let mut urls: Vec<String> = Vec::new();
let push = |u: String, urls: &mut Vec<String>| {
let u = u.trim().to_string();
if !u.is_empty() && !u.starts_with('#') && !urls.contains(&u) {
urls.push(u);
}
};
for u in &self.urls {
if u == "-" {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
for line in buf.lines() {
push(line.to_string(), &mut urls);
}
} else {
push(u.clone(), &mut urls);
}
}
if let Some(path) = &self.input {
let text = std::fs::read_to_string(path)?;
for line in text.lines() {
push(line.to_string(), &mut urls);
}
}
if let Some(path) = &self.opml {
let text = std::fs::read_to_string(path)?;
let doc = opml::OPML::from_str(&text)
.map_err(|e| RssError::Usage(format!("invalid OPML: {e}")))?;
collect_opml_urls(&doc.body.outlines, &mut |u| push(u, &mut urls));
}
if urls.is_empty() {
return Err(RssError::Usage(
"no feed URLs provided (pass URLs, --input, --opml, or `-` for stdin)".into(),
));
}
Ok(urls)
}
}
fn collect_opml_urls(outlines: &[opml::Outline], push: &mut impl FnMut(String)) {
for o in outlines {
if let Some(url) = &o.xml_url {
push(url.clone());
}
collect_opml_urls(&o.outlines, push);
}
}
#[derive(Debug, Args)]
pub struct DiscoverArgs {
#[arg(value_name = "SITE_URL")]
pub site_url: String,
#[arg(long, value_enum, default_value_t = FormatArg::Json)]
pub format: FormatArg,
#[arg(long, default_value_t = 30, value_name = "SECS")]
pub timeout: u64,
#[arg(long, value_name = "STRING")]
pub user_agent: Option<String>,
}
#[derive(Debug, Args)]
pub struct ShowArgs {
#[arg(value_name = "FEED_URL")]
pub feed_url: String,
#[arg(long, value_name = "ITEM_KEY")]
pub id: String,
#[arg(long)]
pub refresh: bool,
#[arg(long, value_enum, default_value_t = ContentArg::Markdown)]
pub content: ContentArg,
#[arg(long, value_name = "N")]
pub max_content_chars: Option<usize>,
#[arg(long, value_enum, default_value_t = FormatArg::Json)]
pub format: FormatArg,
}
#[derive(Debug, Args)]
pub struct SchemaArgs {
#[arg(long, default_value = "fetch", value_parser = ["fetch", "discover"])]
pub command: String,
}
#[derive(Debug, Args)]
pub struct CacheArgs {
#[command(subcommand)]
pub action: CacheAction,
}
#[derive(Debug, Subcommand)]
pub enum CacheAction {
Path,
List {
#[arg(long, value_enum, default_value_t = FormatArg::Json)]
format: FormatArg,
},
Clear,
}
pub fn parse_since(s: &str) -> Result<DateTime<Utc>, RssError> {
let s = s.trim();
if let Ok(d) = parse_duration(s) {
let d = chrono::Duration::from_std(d)
.map_err(|e| RssError::Usage(format!("duration too large: {e}")))?;
return Ok(Utc::now() - d);
}
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&Utc));
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
&& let Some(dt) = date.and_hms_opt(0, 0, 0)
{
return Ok(DateTime::from_naive_utc_and_offset(dt, Utc));
}
Err(RssError::Usage(format!(
"invalid --since value '{s}' (use e.g. '2h', '7d', or '2026-06-01')"
)))
}
pub fn parse_duration(s: &str) -> Result<Duration, RssError> {
let s = s.trim();
let (num, unit) = s.split_at(
s.find(|c: char| !c.is_ascii_digit())
.ok_or_else(|| RssError::Usage(format!("invalid duration '{s}'")))?,
);
let n: u64 = num
.parse()
.map_err(|_| RssError::Usage(format!("invalid duration '{s}'")))?;
let secs = match unit {
"s" => n,
"m" => n * 60,
"h" => n * 3600,
"d" => n * 86400,
"w" => n * 604800,
other => return Err(RssError::Usage(format!("unknown duration unit '{other}'"))),
};
Ok(Duration::from_secs(secs))
}