Skip to main content

fastpaper/
cli.rs

1use clap::{Parser, Subcommand, ValueEnum};
2use std::path::PathBuf;
3
4use crate::registry::{self, Source};
5use crate::sources::{Direction, SearchQuery, SortField, SortOrder};
6
7/// Fast academic paper search, download & read
8#[derive(Parser)]
9#[command(name = "fastpaper", version, about, long_about = None)]
10pub struct Cli {
11    #[command(subcommand)]
12    pub command: Commands,
13
14    #[command(flatten)]
15    pub global: GlobalOpts,
16}
17
18#[derive(clap::Args)]
19pub struct GlobalOpts {
20    /// Increase verbosity (-v, -vv, -vvv)
21    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
22    pub verbose: u8,
23
24    /// Suppress non-essential output
25    #[arg(short, long, global = true)]
26    pub quiet: bool,
27
28    /// Output format
29    #[arg(short, long, global = true, default_value = "table")]
30    pub format: OutputFormat,
31}
32
33/// Each command does exactly one thing:
34/// `search` finds papers, `get` reads metadata, `download` saves a PDF,
35/// `read` extracts text from a PDF already on disk.
36#[derive(Subcommand)]
37pub enum Commands {
38    /// Search papers in one academic source
39    Search(SearchArgs),
40
41    /// Fetch metadata for a single paper by identifier
42    Get(GetArgs),
43
44    /// Download a paper's PDF (default: ./papers)
45    Download(DownloadArgs),
46
47    /// Walk citation edges: what cites a paper, or what it cites
48    Cite(CiteArgs),
49
50    /// Extract text from a local PDF file
51    Read(ReadArgs),
52
53    /// List available sources and what each can do
54    Sources(SourcesArgs),
55
56    /// Generate shell completions
57    Completions { shell: clap_complete::Shell },
58}
59
60// ── search ──────────────────────────────────────
61
62#[derive(clap::Args)]
63pub struct SearchArgs {
64    /// Academic source to search
65    pub source: Source,
66
67    /// Search query string
68    pub query: String,
69
70    /// Max results
71    #[arg(short = 'n', long, default_value = "10")]
72    pub limit: u32,
73
74    /// Skip the first N results
75    #[arg(long, default_value = "0")]
76    pub offset: u32,
77
78    /// Sort by field
79    #[arg(long)]
80    pub sort: Option<SortField>,
81
82    /// Sort direction
83    #[arg(long, default_value = "desc")]
84    pub order: SortOrder,
85
86    /// Papers in a specific year
87    #[arg(long)]
88    pub year: Option<u16>,
89
90    /// Papers published on or after this date (YYYY-MM-DD)
91    #[arg(long)]
92    pub after: Option<String>,
93
94    /// Papers published on or before this date (YYYY-MM-DD)
95    #[arg(long)]
96    pub before: Option<String>,
97
98    /// Filter by author
99    #[arg(long)]
100    pub author: Option<String>,
101
102    /// Field of study / category
103    #[arg(long)]
104    pub field: Option<String>,
105
106    /// Only open access papers
107    #[arg(long)]
108    pub open_access: bool,
109
110    /// Return patents only (europepmc, xueshu)
111    #[arg(long)]
112    pub patents: bool,
113
114    /// Write results to a file instead of stdout
115    #[arg(short, long)]
116    pub output: Option<PathBuf>,
117}
118
119impl SearchArgs {
120    pub fn to_query(&self) -> SearchQuery {
121        SearchQuery {
122            query: self.query.clone(),
123            limit: self.limit,
124            offset: self.offset,
125            sort: self.sort,
126            order: self.order,
127            year: self.year,
128            after: self.after.clone(),
129            before: self.before.clone(),
130            author: self.author.clone(),
131            field: self.field.clone(),
132            open_access: self.open_access,
133            patents: self.patents,
134        }
135    }
136}
137
138// ── get ─────────────────────────────────────────
139
140#[derive(clap::Args)]
141pub struct GetArgs {
142    /// Source name, or the identifier itself when no source is given
143    #[arg(value_name = "SOURCE_OR_ID")]
144    pub first: String,
145
146    /// Identifier (DOI, arXiv ID, PMID, PMC ID, ...) when a source is given
147    #[arg(value_name = "ID")]
148    pub second: Option<String>,
149}
150
151impl GetArgs {
152    pub fn resolve(&self) -> Result<(Option<Source>, &str), String> {
153        resolve_source_and_id(&self.first, self.second.as_deref())
154    }
155}
156
157// ── cite ────────────────────────────────────────
158
159#[derive(clap::Args)]
160pub struct CiteArgs {
161    /// Source name, or the identifier itself when no source is given
162    #[arg(value_name = "SOURCE_OR_ID")]
163    pub first: String,
164
165    /// Identifier when a source is given
166    #[arg(value_name = "ID")]
167    pub second: Option<String>,
168
169    /// Which way to walk: papers citing this one, or the ones it cites
170    #[arg(long, default_value = "incoming")]
171    pub direction: Direction,
172
173    /// Max edges to return
174    #[arg(short = 'n', long, default_value = "20")]
175    pub limit: u32,
176
177    /// Write results to a file instead of stdout
178    #[arg(short, long)]
179    pub output: Option<PathBuf>,
180}
181
182impl CiteArgs {
183    pub fn resolve(&self) -> Result<(Option<Source>, &str), String> {
184        resolve_source_and_id(&self.first, self.second.as_deref())
185    }
186}
187
188// ── download ────────────────────────────────────
189
190#[derive(clap::Args)]
191pub struct DownloadArgs {
192    /// Source name, or the identifier itself when no source is given
193    #[arg(value_name = "SOURCE_OR_ID")]
194    pub first: String,
195
196    /// Identifier when a source is given
197    #[arg(value_name = "ID")]
198    pub second: Option<String>,
199
200    /// Directory to save into
201    #[arg(
202        short,
203        long,
204        env = "FASTPAPER_DOWNLOAD_DIR",
205        default_value = "./papers"
206    )]
207    pub dir: PathBuf,
208
209    /// Overwrite an existing file
210    #[arg(long)]
211    pub overwrite: bool,
212}
213
214impl DownloadArgs {
215    pub fn resolve(&self) -> Result<(Option<Source>, &str), String> {
216        resolve_source_and_id(&self.first, self.second.as_deref())
217    }
218}
219
220/// Disambiguate the `[source] <id>` forms.
221///
222/// The first positional cannot be typed as `Source`: clap would reject
223/// `get 10.1038/nature12373` during parsing, before we ever get to look at it.
224/// So both arrive as strings and the rule is positional — one argument is an
225/// identifier, two means the first names a source.
226fn resolve_source_and_id<'a>(
227    first: &'a str,
228    second: Option<&'a str>,
229) -> Result<(Option<Source>, &'a str), String> {
230    match second {
231        None => Ok((None, first)),
232        Some(id) => match Source::from_name(first) {
233            Some(source) => Ok((Some(source), id)),
234            None => Err(format!(
235                "'{}' is not a known source.\nValid sources: {}",
236                first,
237                registry::ALL
238                    .iter()
239                    .map(|s| s.name())
240                    .collect::<Vec<_>>()
241                    .join(", ")
242            )),
243        },
244    }
245}
246
247// ── read ────────────────────────────────────────
248
249#[derive(clap::Args)]
250pub struct ReadArgs {
251    /// Path to a local PDF file
252    pub path: PathBuf,
253
254    /// Extract a specific section
255    #[arg(long, default_value = "full")]
256    pub section: Section,
257
258    /// Truncate output to N characters
259    #[arg(long)]
260    pub max_length: Option<usize>,
261
262    /// Write content to a file instead of stdout
263    #[arg(short, long)]
264    pub output: Option<PathBuf>,
265}
266
267// ── sources ─────────────────────────────────────
268
269#[derive(clap::Args)]
270pub struct SourcesArgs {
271    /// Show per-source search filters and caveats
272    #[arg(long)]
273    pub capabilities: bool,
274}
275
276// ── enums ───────────────────────────────────────
277
278#[derive(ValueEnum, Clone, Copy, Debug)]
279pub enum OutputFormat {
280    Table,
281    Json,
282    Jsonl,
283    Csv,
284    Bibtex,
285}
286
287#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
288pub enum Section {
289    Abstract,
290    Introduction,
291    Methods,
292    Results,
293    Discussion,
294    Conclusion,
295    References,
296    Full,
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn one_argument_is_an_identifier() {
305        let (source, id) = resolve_source_and_id("10.1038/nature12373", None).unwrap();
306        assert_eq!(source, None);
307        assert_eq!(id, "10.1038/nature12373");
308    }
309
310    #[test]
311    fn two_arguments_name_a_source() {
312        let (source, id) = resolve_source_and_id("arxiv", Some("2301.08745")).unwrap();
313        assert_eq!(source, Some(Source::Arxiv));
314        assert_eq!(id, "2301.08745");
315    }
316
317    #[test]
318    fn unknown_source_name_is_rejected_with_the_valid_list() {
319        let err = resolve_source_and_id("arxvi", Some("2301.08745")).unwrap_err();
320        assert!(err.contains("arxvi"), "should quote the bad token: {}", err);
321        assert!(err.contains("arxiv"), "should list valid sources: {}", err);
322    }
323
324    // A lone identifier that happens to look like nothing is still an
325    // identifier -- source detection happens later, in the get command.
326    #[test]
327    fn one_argument_is_never_treated_as_a_source() {
328        let (source, id) = resolve_source_and_id("arxiv", None).unwrap();
329        assert_eq!(source, None);
330        assert_eq!(id, "arxiv");
331    }
332}