use clap::{Args, Parser, Subcommand, ValueEnum};
const EXIT_CODES: &str = "\
Exit codes:
0 success
1 internal error
2 invalid usage or input (bad identifier, unknown option)
3 one or more works not found
4 provider unavailable (network, timeout, server error)
5 throttled (rate limit or spent usage budget)
6 credentials rejected
7 unsupported by the enabled providers
8 unexpected provider response
9 a citation does not check out (verify: mismatch or only plausible;
check: an entry reached --fail-on)
With several inputs (a batch on stdin), the exit code is the most serious
problem among them; results for every input are still written.
Environment:
OPENALEX_API_KEY OpenAlex key ($1/day free budget; $0.10/day without)
ASPERO_MAILTO contact address for polite API pools (Crossref)
ASPERO_CACHE_DIR where to keep the answer cache (default: platform cache dir)
Answers are cached (records 7 days, not-found 1 day, searches 1 day) and
served stale if a provider fails; JSON marks them \"served\": \"cache\"/\"stale\".";
#[derive(Debug, Parser)]
#[command(
name = "aspero",
version,
about = "Find, resolve and verify scholarly literature",
long_about = "Find, resolve and verify scholarly literature via OpenAlex and Crossref.\n\n\
Output is JSON when stdout is not a terminal, so agents can pipe it.",
after_help = EXIT_CODES,
)]
pub struct Cli {
#[command(flatten)]
pub global: Global,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Args)]
pub struct Global {
#[arg(long, global = true, value_enum, default_value_t = Format::Auto)]
pub format: Format,
#[arg(long, global = true)]
pub full: bool,
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
#[arg(long, global = true)]
pub no_cache: bool,
#[arg(long = "abstract", global = true)]
pub abstracts: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Format {
Auto,
Json,
Jsonl,
Table,
Text,
}
impl Format {
pub fn is_human(self) -> bool {
matches!(self, Format::Table | Format::Text)
}
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(after_help = "\
Accepts DOIs, arXiv IDs, PMIDs, OpenAlex IDs and their URLs, bare or prefixed:
10.1038/nature14539 doi:10.1038/nature14539 https://doi.org/10.1038/nature14539
arXiv:1706.03762v5 https://arxiv.org/abs/1706.03762
pmid:26017442 W2919115771
Bare digits are ambiguous; prefix them (pmid:, corpusid:).
JSON works:
authors [{\"name\": \"Yann LeCun\", \"orcid\": \"...\"}, ...], the same shape
with or without --full (which adds given and family)
updates notices about the work: [{\"kind\": \"retraction\", \"notice\":
\"doi:...\", \"date\": \"...\", \"source\": \"...\"}]; kinds: retraction,
withdrawal, expression_of_concern, correction, or another name
update_to for a notice itself, the works it updates: [{\"kind\", \"work\",
\"date\", \"source\"}]
`lookup --full` also asks Crossref for notices in both directions.
Examples:
aspero lookup 10.1038/nature14539 arXiv:1706.03762
cat dois.txt | aspero lookup - # one identifier per line
aspero lookup --format jsonl W2919115771")]
Lookup {
ids: Vec<String>,
},
#[command(after_help = "\
Searches are not interchangeable across providers (rankings differ), so one
provider answers: --source, or the first that can.
Filters a provider can't apply are reported in `ignored_filters`, never
silently dropped. OpenAlex searches cost $0.001 each.
Examples:
aspero search \"retrieval augmented generation\" --year 2020.. --limit 5
aspero search transformer --source crossref --type article-journal
aspero search \"protein folding\" --oa --cursor <next_cursor from last page>")]
Search {
query: String,
#[command(flatten)]
options: SearchOptions,
},
#[command(after_help = "\
The citation can be free text in any style, an identifier, structured
fields, or a mix. Candidates come from identifier lookups, Crossref's
citation matcher and OpenAlex title searches; records sharing an
identifier are merged. Each is scored on title, authors and year.
Use `verify` for a yes/no answer; `resolve` shows every candidate.
Examples:
aspero resolve \"LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015\"
aspero resolve --title \"Attention is all you need\" --author Vaswani --year 2017")]
Resolve {
#[command(flatten)]
claim: ClaimArgs,
},
#[command(after_help = "\
Verdicts:
verified a matching work exists (and so do its claimed identifiers)
plausible a work partly matches; check the details (exit 9)
mismatch the claimed DOI/arXiv ID doesn't exist or is another
work's; `reason` names the work the citation matches (exit 9)
not_found no matching work (exit 3)
unverifiable providers failed (exit 4)
A wrong year is treated as a strong mismatch: re-deposits of famous papers
exist with the same title and authors but a different year.
Stops early when the claim's identifiers, or two providers, confirm a match;
otherwise OpenAlex title searches run too ($0.001 each, at most two per claim).
Batch input on stdin, one claim per line, as text or JSON:
{\"title\": \"...\", \"authors\": [\"...\"], \"year\": 2017, \"doi\": \"...\"}
JSON keys: text, title, authors, year, doi, arxiv, id, ids
Examples:
aspero verify \"Vaswani et al. 2017. Attention is all you need. arXiv:1706.03762\"
aspero verify --title \"Deep learning\" --author LeCun --year 2015 --id 10.1038/nature14539
aspero verify - < citations.jsonl")]
Verify {
#[command(flatten)]
claim: ClaimArgs,
},
#[command(after_help = "\
Each entry's title, authors, year and identifiers (doi, arXiv eprint, DOI
or arXiv URLs) are verified, then reported with fixes:
error retracted or withdrawn; not found; identifier wrong or
nonexistent (with the right one); year off by more than one
warning expression of concern; only a plausible match; duplicate
entries; providers failed
info add a DOI the entry lacks; year off by one (often preprint vs
published, or online vs print); a published correction
Exits 9 if any entry reaches --fail-on (default: error), so it can gate a
pre-commit hook or CI job. JSON output has a summary and every finding.
Examples:
aspero check refs.bib
aspero check --fail-on warning --format json thesis.bib > report.json
cat refs.bib | aspero check -")]
Check {
path: String,
#[arg(long, value_enum, default_value_t = FailOn::Error)]
fail_on: FailOn,
#[arg(long, default_value_t = 5)]
limit: u32,
},
Cache {
#[command(subcommand)]
action: CacheAction,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum FailOn {
Error,
Warning,
Info,
Never,
}
#[derive(Debug, Subcommand)]
pub enum CacheAction {
Stats,
Clear,
Path,
}
#[derive(Debug, Args)]
pub struct ClaimArgs {
pub text: Option<String>,
#[arg(long)]
pub title: Option<String>,
#[arg(long = "author")]
pub authors: Vec<String>,
#[arg(long)]
pub year: Option<i16>,
#[arg(long = "id")]
pub ids: Vec<String>,
#[arg(long, default_value_t = 5)]
pub limit: u32,
}
#[derive(Debug, Args)]
pub struct SearchOptions {
#[arg(long)]
pub source: Option<String>,
#[arg(long)]
pub year: Option<String>,
#[arg(long = "type")]
pub work_type: Option<String>,
#[arg(long)]
pub oa: bool,
#[arg(long, default_value_t = 10)]
pub limit: u32,
#[arg(long)]
pub cursor: Option<String>,
}