Skip to main content

doiget_cli/commands/
search.rs

1//! `doiget search <query>` subcommand.
2//!
3//! Two scopes, one command (ADR-0031 D5):
4//!
5//! - **external** (default) — discovery search over OpenAlex
6//!   `/works?search=` via [`doiget_core::discovery::paper_search`]. Turns
7//!   a topic into ranked candidate papers (title / abstract / year /
8//!   venue / citations / OA status / DOI) for triage *before* any PDF is
9//!   fetched. Tier-1 OA metadata, always-on, ships in the default
10//!   `oa-only` binary; no `DOIGET_ENABLE_OPENALEX` gate.
11//! - **local** (`--local`) — the legacy substring scan over
12//!   `<store-root>/.metadata/*.toml` via
13//!   [`FsStore::search`](doiget_core::store::FsStore). Re-finds papers
14//!   already in the store; offline.
15//!
16//! `--local` and `--external` are mutually exclusive; omitting both means
17//! external. Both scopes share one `--mode json` envelope —
18//! `{ "scope": "external" | "local", "query": "...", "count": N,
19//! "results": [...] }` — with a scope-dependent `results[]` element
20//! schema. The external scope additionally carries `"total_results"` (the
21//! upstream OpenAlex match count, which may exceed `count`).
22
23use std::io::Write;
24
25use anyhow::{Context, Result};
26
27use doiget_core::discovery::{paper_search, PaperSearchQuery, PaperSearchResults, SearchSort};
28use doiget_core::store::{EntryInfo, FsStore, Store};
29use doiget_core::ErrorCode;
30
31use super::fetch::{cli_exit_code, CliExit, FetchHarness};
32use super::output::OutputMode;
33use super::resolve_store_root;
34
35/// Phase 1 default cap on the number of returned **local** rows. Picked to
36/// match the "small CLI table" feel — large enough to be useful for an
37/// ad-hoc `doiget search foo --local`, small enough that an unbounded scan
38/// over a pathological store still terminates promptly.
39const LOCAL_DEFAULT_LIMIT: usize = 50;
40
41/// Format string for [`chrono::DateTime`] columns. RFC3339-shaped, UTC, no
42/// fractional seconds — identical to the [`list_recent`](super::list_recent)
43/// table so downstream pipelines can treat both outputs uniformly.
44const FETCHED_AT_FMT: &str = "%Y-%m-%dT%H:%M:%SZ";
45
46/// Production OpenAlex API base. Overridable via `DOIGET_OPENALEX_BASE`
47/// (test wiremock origin), mirroring the `graph` subcommand.
48const OPENALEX_DEFAULT_BASE: &str = "https://api.openalex.org";
49
50/// `--sort` choices for external discovery. Maps 1:1 onto
51/// [`SearchSort`]; kept CLI-local so `doiget-core` carries no `clap` dep.
52#[derive(Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
53pub enum SortArg {
54    /// Best textual match first (OpenAlex `relevance_score:desc`).
55    ///
56    /// The only sort: `cited` / `recent` were removed (#290) — over
57    /// OpenAlex's loose free-text match they float off-topic papers to the
58    /// top. Use `--min-fwci` / `--min-percentile` / `--from-year` to
59    /// surface "important / recent" results as FILTERS instead.
60    #[default]
61    Relevance,
62}
63
64impl From<SortArg> for SearchSort {
65    fn from(s: SortArg) -> Self {
66        match s {
67            SortArg::Relevance => SearchSort::Relevance,
68        }
69    }
70}
71
72/// External-discovery flag bundle (everything except `query` / `mode`).
73/// Bundled so the `main.rs` dispatch arm and [`run`] stay readable.
74#[derive(Debug, Clone)]
75pub struct ExternalArgs {
76    /// Max results; validated to `1..=200` (OpenAlex `per-page` ceiling) by
77    /// `PaperSearchQuery::validate` — an out-of-range value is rejected,
78    /// not silently clamped.
79    pub limit: usize,
80    /// Inclusive lower publication-year bound.
81    pub from_year: Option<i32>,
82    /// Inclusive upper publication-year bound.
83    pub to_year: Option<i32>,
84    /// Restrict to open-access works.
85    pub oa_only: bool,
86    /// Only works cited strictly more than this many times.
87    pub min_citations: Option<u64>,
88    /// Minimum field-and-year-normalized impact (FWCI) floor (#290).
89    pub min_fwci: Option<f64>,
90    /// Minimum within-cohort citation percentile, 0–100 (#290).
91    pub min_percentile: Option<u8>,
92    /// Author name to filter by (resolved to an OpenAlex author ID).
93    pub author: Option<String>,
94    /// Venue / journal name to filter by (resolved to an OpenAlex source ID).
95    pub venue: Option<String>,
96    /// Publisher name to filter by (resolved to an OpenAlex publisher ID).
97    pub publisher: Option<String>,
98    /// Result ordering.
99    pub sort: SortArg,
100}
101
102/// Stderr sink for `docs/ERRORS.md` §3 human-error lines (mirrors the
103/// `print_err` helper in `commands::fetch` / `commands::graph`).
104#[allow(clippy::print_stderr)]
105fn print_err(args: std::fmt::Arguments<'_>) {
106    eprintln!("{args}");
107}
108
109/// Run the `search` subcommand.
110///
111/// `local` selects the store scan; otherwise external discovery runs
112/// (the default; `--external` is its explicit form and is already
113/// resolved away by clap's `conflicts_with`). `tag` is a local-only
114/// filter; `ext` carries the external-only flags and is ignored on the
115/// local path.
116///
117/// # Errors
118///
119/// Propagates store-open / scan failures (local) or surfaces a typed
120/// [`ErrorCode`] as a process exit code (external); an empty query is a
121/// usage error unless `--local --tag` is given.
122pub async fn run(
123    query: String,
124    local: bool,
125    tag: Option<String>,
126    ext: ExternalArgs,
127    mode: OutputMode,
128    quiet_was_explicit: bool,
129) -> Result<()> {
130    let tag_filter = tag.as_deref();
131    if query.trim().is_empty() && !(local && tag_filter.is_some()) {
132        anyhow::bail!("search query is empty");
133    }
134    if local {
135        run_local(&query, tag_filter, mode, quiet_was_explicit)
136    } else {
137        run_external(&query, ext, mode, quiet_was_explicit).await
138    }
139}
140
141/// Local-store substring scan. When `tag_filter` is `Some(t)` the scan is
142/// delegated to `FsStore::search_by_tag` which matches on `[doiget].tags`
143/// first; an empty `query` matches all tagged entries in that case.
144fn run_local(
145    query: &str,
146    tag_filter: Option<&str>,
147    mode: OutputMode,
148    quiet_was_explicit: bool,
149) -> Result<()> {
150    let store_root = resolve_store_root()?;
151    let store = FsStore::new(store_root)?;
152    let entries = if let Some(tag) = tag_filter {
153        store
154            .search_by_tag(tag, query, LOCAL_DEFAULT_LIMIT)
155            .with_context(|| format!("tag search failed for tag {tag:?}"))?
156    } else {
157        store
158            .search(query, LOCAL_DEFAULT_LIMIT)
159            .with_context(|| format!("search failed for query {query:?}"))?
160    };
161
162    // Artifact-class (ADR-0017 Amendment 2 / #301): suppress only on
163    // explicit Quiet; the non-TTY implicit fallback still emits.
164    if mode == OutputMode::Quiet && quiet_was_explicit {
165        return Ok(());
166    }
167
168    let stdout = std::io::stdout();
169    let mut out = stdout.lock();
170    if mode == OutputMode::Json {
171        write_json(&mut out, &local_envelope(query, &entries))?;
172        return Ok(());
173    }
174    writeln!(out, "safekey\tyear\ttitle\tfetched_at")
175        .context("failed to write search header to stdout")?;
176    for e in entries {
177        let year = dash_or(e.year);
178        let fetched = e
179            .fetched_at
180            .map(|t| t.format(FETCHED_AT_FMT).to_string())
181            .unwrap_or_else(|| "-".into());
182        writeln!(
183            out,
184            "{}\t{}\t{}\t{}",
185            e.safekey.as_str(),
186            year,
187            e.title,
188            fetched
189        )
190        .context("failed to write search row to stdout")?;
191    }
192    Ok(())
193}
194
195/// External OpenAlex discovery search (the default scope).
196async fn run_external(
197    query: &str,
198    ext: ExternalArgs,
199    mode: OutputMode,
200    quiet_was_explicit: bool,
201) -> Result<()> {
202    let q = PaperSearchQuery {
203        query: query.to_string(),
204        limit: ext.limit,
205        from_year: ext.from_year,
206        to_year: ext.to_year,
207        oa_only: ext.oa_only,
208        min_citations: ext.min_citations,
209        min_fwci: ext.min_fwci,
210        min_percentile: ext.min_percentile,
211        author: ext.author,
212        venue: ext.venue,
213        publisher: ext.publisher,
214        sort: ext.sort.into(),
215    };
216    // Boundary validation (limit range, inverted year range) lives in
217    // `PaperSearchQuery::validate` so the CLI and the MCP tool cannot drift.
218    q.validate().map_err(|m| anyhow::anyhow!("{m}"))?;
219
220    let base = resolve_openalex_base()?;
221    // Leave `mailto` unset when no contact email is configured: send a real
222    // address (polite pool) or none, never a non-routable placeholder. The
223    // empty string is skipped by `build_search_url` / `resolve_entity_id`.
224    let contact_email = std::env::var("DOIGET_CONTACT_EMAIL").unwrap_or_default();
225
226    let harness = FetchHarness::from_env().context("building fetch harness")?;
227    harness
228        .log_session_start(Some(query))
229        .context("logging session start")?;
230    let ctx = harness.fetch_context();
231
232    let outcome = paper_search(&base, &contact_email, &q, &ctx).await;
233    harness.log_session_end(outcome.is_ok(), Some(query));
234
235    let results = match outcome {
236        Ok(r) => r,
237        Err(e) => {
238            let code = ErrorCode::from(&e);
239            print_err(format_args!("error[{}]: {e}", code.as_wire()));
240            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
241        }
242    };
243
244    // Artifact-class (ADR-0017 Amendment 2 / #301): suppress only on
245    // explicit Quiet; the non-TTY implicit fallback still emits.
246    if mode == OutputMode::Quiet && quiet_was_explicit {
247        return Ok(());
248    }
249
250    let stdout = std::io::stdout();
251    let mut out = stdout.lock();
252    if mode == OutputMode::Json {
253        write_json(&mut out, &external_envelope(query, &results))?;
254        return Ok(());
255    }
256
257    // Human table: surface "interesting" signals (citations) first, then
258    // year / OA / DOI / title. Tab-separated, `cut(1)`-compatible.
259    writeln!(out, "cited_by\tyear\toa\tdoi\ttitle")
260        .context("failed to write search header to stdout")?;
261    for hit in &results.results {
262        let year = dash_or(hit.year);
263        let oa = hit.oa_status.as_deref().unwrap_or("-");
264        let doi = hit.doi.as_deref().unwrap_or("-");
265        writeln!(
266            out,
267            "{}\t{}\t{}\t{}\t{}",
268            hit.cited_by_count, year, oa, doi, hit.title
269        )
270        .context("failed to write search row to stdout")?;
271    }
272    Ok(())
273}
274
275/// Resolve the OpenAlex base URL: `DOIGET_OPENALEX_BASE` override (tests)
276/// or the production default.
277fn resolve_openalex_base() -> Result<url::Url> {
278    let raw =
279        std::env::var("DOIGET_OPENALEX_BASE").unwrap_or_else(|_| OPENALEX_DEFAULT_BASE.to_string());
280    url::Url::parse(&raw).with_context(|| format!("DOIGET_OPENALEX_BASE is not a URL: {raw}"))
281}
282
283/// Build the local-scan `--mode json` envelope (ADR-0031 D5, #212):
284/// `{ ok, scope: "local", query, count, results }`. The `results[]` element is
285/// the legacy `EntryInfo` shape, unchanged.
286fn local_envelope(query: &str, entries: &[EntryInfo]) -> serde_json::Value {
287    serde_json::json!({
288        "ok": true,
289        "scope": "local",
290        "query": query,
291        "count": entries.len(),
292        "results": entries,
293    })
294}
295
296/// Build the external-discovery `--mode json` envelope (ADR-0031 D5, #212):
297/// `{ ok, scope, query, total_results, count, results }`. Extracted as a pure
298/// function so the wire shape is unit-testable without capturing stdout.
299fn external_envelope(query: &str, results: &PaperSearchResults) -> serde_json::Value {
300    serde_json::json!({
301        "ok": true,
302        "scope": "external",
303        "query": query,
304        "total_results": results.total_results,
305        "count": results.results.len(),
306        "results": results.results,
307    })
308}
309
310/// Pretty-serialize a JSON value and write it as one line to `out`. Shared
311/// by the local and external `--mode json` paths.
312fn write_json(out: &mut impl Write, value: &serde_json::Value) -> Result<()> {
313    let s = serde_json::to_string_pretty(value).context("failed to serialize search JSON")?;
314    writeln!(out, "{s}").context("failed to write search JSON to stdout")
315}
316
317/// Render an optional value for a human-table cell: its `Display`, or `-`.
318fn dash_or<T: std::fmt::Display>(v: Option<T>) -> String {
319    v.map(|x| x.to_string()).unwrap_or_else(|| "-".into())
320}
321
322#[cfg(test)]
323#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
324mod tests {
325    use super::*;
326    use doiget_core::discovery::{DiscoverySource, PaperHit};
327
328    fn hit() -> PaperHit {
329        PaperHit {
330            doi: Some("10.1234/x".to_string()),
331            openalex_id: "W1".to_string(),
332            arxiv: None,
333            title: "T".to_string(),
334            authors: vec!["A".to_string()],
335            year: Some(2024),
336            venue: Some("V".to_string()),
337            abstract_: Some("abs".to_string()),
338            cited_by_count: 3,
339            oa_status: Some("gold".to_string()),
340            fwci: Some(2.5),
341            cited_by_percentile_year_min: Some(85),
342            source: DiscoverySource::OpenAlex,
343        }
344    }
345
346    #[test]
347    fn external_envelope_has_scope_total_and_results() {
348        let results = PaperSearchResults {
349            results: vec![hit()],
350            total_results: Some(4012),
351        };
352        let v = external_envelope("spin glass", &results);
353        assert_eq!(v["ok"], true);
354        assert_eq!(v["scope"], "external");
355        assert_eq!(v["query"], "spin glass");
356        assert_eq!(v["total_results"], 4012);
357        assert_eq!(v["count"], 1);
358        assert_eq!(v["results"][0]["openalex_id"], "W1");
359        assert_eq!(v["results"][0]["abstract"], "abs");
360    }
361
362    #[test]
363    fn sort_arg_lowers_to_core() {
364        // Relevance is the only sort (#290); `cited` / `recent` were removed.
365        assert_eq!(SearchSort::from(SortArg::Relevance), SearchSort::Relevance);
366    }
367
368    #[test]
369    fn local_envelope_has_local_scope_and_count() {
370        let v = local_envelope("quantum", &[]);
371        assert_eq!(v["ok"], true);
372        assert_eq!(v["scope"], "local");
373        assert_eq!(v["query"], "quantum");
374        assert_eq!(v["count"], 0);
375        assert!(v["results"].as_array().expect("results array").is_empty());
376        // The local envelope must NOT carry the external-only field.
377        assert!(v.get("total_results").is_none());
378    }
379
380    /// `ExternalArgs` with defaults; override per test.
381    fn ext(limit: usize, from_year: Option<i32>, to_year: Option<i32>) -> ExternalArgs {
382        ExternalArgs {
383            limit,
384            from_year,
385            to_year,
386            oa_only: false,
387            min_citations: None,
388            min_fwci: None,
389            min_percentile: None,
390            author: None,
391            venue: None,
392            publisher: None,
393            sort: SortArg::Relevance,
394        }
395    }
396
397    // These validations fire BEFORE any network / harness construction, so
398    // the calls error without touching the filesystem or OpenAlex.
399
400    #[tokio::test]
401    async fn external_rejects_limit_below_1() {
402        let err = run(
403            "q".into(),
404            false,
405            None,
406            ext(0, None, None),
407            OutputMode::Quiet,
408            true,
409        )
410        .await
411        .expect_err("limit 0 must be rejected");
412        assert!(err.to_string().contains("limit"), "got: {err}");
413    }
414
415    #[tokio::test]
416    async fn external_rejects_limit_above_200() {
417        let err = run(
418            "q".into(),
419            false,
420            None,
421            ext(201, None, None),
422            OutputMode::Quiet,
423            true,
424        )
425        .await
426        .expect_err("limit 201 must be rejected");
427        assert!(err.to_string().contains("limit"), "got: {err}");
428    }
429
430    #[tokio::test]
431    async fn external_rejects_inverted_year_range() {
432        let err = run(
433            "q".into(),
434            false,
435            None,
436            ext(25, Some(2025), Some(2010)),
437            OutputMode::Quiet,
438            true,
439        )
440        .await
441        .expect_err("from_year > to_year must be rejected");
442        assert!(err.to_string().contains("is after"), "got: {err}");
443    }
444}