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::print_err;
33use super::output::OutputMode;
34use super::resolve_store_root;
35
36/// Phase 1 default cap on the number of returned **local** rows. Picked to
37/// match the "small CLI table" feel — large enough to be useful for an
38/// ad-hoc `doiget search foo --local`, small enough that an unbounded scan
39/// over a pathological store still terminates promptly.
40const LOCAL_DEFAULT_LIMIT: usize = 50;
41
42/// Format string for [`chrono::DateTime`] columns. RFC3339-shaped, UTC, no
43/// fractional seconds — identical to the [`list_recent`](super::list_recent)
44/// table so downstream pipelines can treat both outputs uniformly.
45const FETCHED_AT_FMT: &str = "%Y-%m-%dT%H:%M:%SZ";
46
47/// Production OpenAlex API base. Overridable via `DOIGET_OPENALEX_BASE`
48/// (test wiremock origin), mirroring the `graph` subcommand.
49const OPENALEX_DEFAULT_BASE: &str = "https://api.openalex.org";
50
51/// `--sort` choices for external discovery. Maps 1:1 onto
52/// [`SearchSort`]; kept CLI-local so `doiget-core` carries no `clap` dep.
53#[derive(Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
54pub enum SortArg {
55    /// Best textual match first (OpenAlex `relevance_score:desc`).
56    ///
57    /// The only sort: `cited` / `recent` were removed (#290) — over
58    /// OpenAlex's loose free-text match they float off-topic papers to the
59    /// top. Use `--min-fwci` / `--min-percentile` / `--from-year` to
60    /// surface "important / recent" results as FILTERS instead.
61    #[default]
62    Relevance,
63}
64
65impl From<SortArg> for SearchSort {
66    fn from(s: SortArg) -> Self {
67        match s {
68            SortArg::Relevance => SearchSort::Relevance,
69        }
70    }
71}
72
73/// External-discovery flag bundle (everything except `query` / `mode`).
74/// Bundled so the `main.rs` dispatch arm and [`run`] stay readable.
75#[derive(Debug, Clone)]
76pub struct ExternalArgs {
77    /// Max results; validated to `1..=200` (OpenAlex `per-page` ceiling) by
78    /// `PaperSearchQuery::validate` — an out-of-range value is rejected,
79    /// not silently clamped.
80    pub limit: usize,
81    /// Inclusive lower publication-year bound.
82    pub from_year: Option<i32>,
83    /// Inclusive upper publication-year bound.
84    pub to_year: Option<i32>,
85    /// Restrict to open-access works.
86    pub oa_only: bool,
87    /// Only works cited strictly more than this many times.
88    pub min_citations: Option<u64>,
89    /// Minimum field-and-year-normalized impact (FWCI) floor (#290).
90    pub min_fwci: Option<f64>,
91    /// Minimum within-cohort citation percentile, 0–100 (#290).
92    pub min_percentile: Option<u8>,
93    /// Author name to filter by (resolved to an OpenAlex author ID).
94    pub author: Option<String>,
95    /// Venue / journal name to filter by (resolved to an OpenAlex source ID).
96    pub venue: Option<String>,
97    /// Publisher name to filter by (resolved to an OpenAlex publisher ID).
98    pub publisher: Option<String>,
99    /// Result ordering.
100    pub sort: SortArg,
101}
102
103/// Run the `search` subcommand.
104///
105/// `local` selects the store scan; otherwise external discovery runs
106/// (the default; `--external` is its explicit form and is already
107/// resolved away by clap's `conflicts_with`). `tag` is a local-only
108/// filter; `ext` carries the external-only flags and is ignored on the
109/// local path.
110///
111/// # Errors
112///
113/// Propagates store-open / scan failures (local) or surfaces a typed
114/// [`ErrorCode`] as a process exit code (external); an empty query is a
115/// usage error unless `--local --tag` is given.
116pub async fn run(
117    query: String,
118    local: bool,
119    tag: Option<String>,
120    ext: ExternalArgs,
121    mode: OutputMode,
122    quiet_was_explicit: bool,
123) -> Result<()> {
124    let tag_filter = tag.as_deref();
125    if query.trim().is_empty() && !(local && tag_filter.is_some()) {
126        anyhow::bail!("search query is empty");
127    }
128    if local {
129        run_local(&query, tag_filter, mode, quiet_was_explicit)
130    } else {
131        run_external(&query, ext, mode, quiet_was_explicit).await
132    }
133}
134
135/// Local-store substring scan. When `tag_filter` is `Some(t)` the scan is
136/// delegated to `FsStore::search_by_tag` which matches on `[doiget].tags`
137/// first; an empty `query` matches all tagged entries in that case.
138fn run_local(
139    query: &str,
140    tag_filter: Option<&str>,
141    mode: OutputMode,
142    quiet_was_explicit: bool,
143) -> Result<()> {
144    let store_root = resolve_store_root()?;
145    let store = FsStore::new(store_root)?;
146    let entries = if let Some(tag) = tag_filter {
147        store
148            .search_by_tag(tag, query, LOCAL_DEFAULT_LIMIT)
149            .with_context(|| format!("tag search failed for tag {tag:?}"))?
150    } else {
151        store
152            .search(query, LOCAL_DEFAULT_LIMIT)
153            .with_context(|| format!("search failed for query {query:?}"))?
154    };
155
156    // Artifact-class (ADR-0017 Amendment 2 / #301): suppress only on
157    // explicit Quiet; the non-TTY implicit fallback still emits.
158    if mode == OutputMode::Quiet && quiet_was_explicit {
159        return Ok(());
160    }
161
162    let stdout = std::io::stdout();
163    let mut out = stdout.lock();
164    if mode == OutputMode::Json {
165        write_json(&mut out, &local_envelope(query, &entries))?;
166        return Ok(());
167    }
168    // Same column as `list-recent` (#481): `search --local` lists store
169    // entries too, so it had the same gap.
170    writeln!(out, "safekey\tyear\ttitle\tfetched_at\tpdf")
171        .context("failed to write search header to stdout")?;
172    for e in &entries {
173        let year = dash_or(e.year);
174        let fetched = e
175            .fetched_at
176            .map(|t| t.format(FETCHED_AT_FMT).to_string())
177            .unwrap_or_else(|| "-".into());
178        writeln!(
179            out,
180            "{}\t{}\t{}\t{}\t{}",
181            e.safekey.as_str(),
182            year,
183            e.title,
184            fetched,
185            super::list_recent::pdf_cell(e)
186        )
187        .context("failed to write search row to stdout")?;
188    }
189    Ok(())
190}
191
192/// External OpenAlex discovery search (the default scope).
193async fn run_external(
194    query: &str,
195    ext: ExternalArgs,
196    mode: OutputMode,
197    quiet_was_explicit: bool,
198) -> Result<()> {
199    let q = PaperSearchQuery {
200        query: query.to_string(),
201        limit: ext.limit,
202        from_year: ext.from_year,
203        to_year: ext.to_year,
204        oa_only: ext.oa_only,
205        min_citations: ext.min_citations,
206        min_fwci: ext.min_fwci,
207        min_percentile: ext.min_percentile,
208        author: ext.author,
209        venue: ext.venue,
210        publisher: ext.publisher,
211        sort: ext.sort.into(),
212    };
213    // Boundary validation (limit range, inverted year range) lives in
214    // `PaperSearchQuery::validate` so the CLI and the MCP tool cannot drift.
215    q.validate().map_err(|m| anyhow::anyhow!("{m}"))?;
216
217    let base = resolve_openalex_base()?;
218    // Leave `mailto` unset when no contact email is configured: send a real
219    // address (polite pool) or none, never a non-routable placeholder. The
220    // empty string is skipped by `build_search_url` / `resolve_entity_id`.
221    // Resolved through the core ladder so config.toml's rung counts (#504).
222    let contact_email = doiget_core::orchestrator::configured_contact_email().unwrap_or_default();
223
224    let harness = FetchHarness::from_env().context("building fetch harness")?;
225    harness
226        .log_session_start(Some(query))
227        .context("logging session start")?;
228    let ctx = harness.fetch_context();
229
230    let outcome = paper_search(&base, &contact_email, &q, &ctx).await;
231    harness.log_session_end(
232        outcome.is_ok(),
233        Some(query),
234        outcome.as_ref().err().map(|e| ErrorCode::from(e).as_wire()),
235    );
236
237    let results = match outcome {
238        Ok(r) => r,
239        Err(e) => {
240            let code = ErrorCode::from(&e);
241            print_err(format_args!("error[{}]: {e}", code.as_wire()));
242            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
243        }
244    };
245
246    // Artifact-class (ADR-0017 Amendment 2 / #301): suppress only on
247    // explicit Quiet; the non-TTY implicit fallback still emits.
248    if mode == OutputMode::Quiet && quiet_was_explicit {
249        return Ok(());
250    }
251
252    let stdout = std::io::stdout();
253    let mut out = stdout.lock();
254    if mode == OutputMode::Json {
255        write_json(&mut out, &external_envelope(query, &results))?;
256        return Ok(());
257    }
258
259    // Human table: surface "interesting" signals (citations) first, then
260    // year / OA / DOI / title. Tab-separated, `cut(1)`-compatible.
261    writeln!(out, "cited_by\tyear\toa\tdoi\ttitle")
262        .context("failed to write search header to stdout")?;
263    // #534, human half: a header with no rows under it says "not indexed" just
264    // as flatly as the JSON envelope did. The note goes to stderr so the table
265    // on stdout stays `cut(1)`-clean (ADR-0001).
266    if results.results.is_empty() {
267        if let Some(hint) = doiget_core::discovery::zero_result_hint(query) {
268            print_err(format_args!("  = note: {hint}"));
269        }
270    }
271    for hit in &results.results {
272        let year = dash_or(hit.year);
273        let oa = hit.oa_status.as_deref().unwrap_or("-");
274        let doi = hit.doi.as_deref().unwrap_or("-");
275        writeln!(
276            out,
277            "{}\t{}\t{}\t{}\t{}",
278            hit.cited_by_count, year, oa, doi, hit.title
279        )
280        .context("failed to write search row to stdout")?;
281    }
282    Ok(())
283}
284
285/// Resolve the OpenAlex base URL: `DOIGET_OPENALEX_BASE` override (tests)
286/// or the production default.
287fn resolve_openalex_base() -> Result<url::Url> {
288    let raw =
289        std::env::var("DOIGET_OPENALEX_BASE").unwrap_or_else(|_| OPENALEX_DEFAULT_BASE.to_string());
290    url::Url::parse(&raw).with_context(|| format!("DOIGET_OPENALEX_BASE is not a URL: {raw}"))
291}
292
293/// Build the local-scan `--mode json` envelope (ADR-0031 D5, #212):
294/// `{ ok, scope: "local", query, count, results }`. The `results[]` element is
295/// the legacy `EntryInfo` shape, unchanged.
296fn local_envelope(query: &str, entries: &[EntryInfo]) -> serde_json::Value {
297    serde_json::json!({
298        "ok": true,
299        "scope": "local",
300        "query": query,
301        "count": entries.len(),
302        "results": entries,
303    })
304}
305
306/// Build the external-discovery `--mode json` envelope (ADR-0031 D5, #212):
307/// `{ ok, scope, query, total_results, count, results }`. Extracted as a pure
308/// function so the wire shape is unit-testable without capturing stdout.
309fn external_envelope(query: &str, results: &PaperSearchResults) -> serde_json::Value {
310    let mut envelope = serde_json::json!({
311        "ok": true,
312        "scope": "external",
313        "query": query,
314        "total_results": results.total_results,
315        "count": results.results.len(),
316        "results": results.results,
317    });
318    // #534. `{"ok": true, "total_results": 0}` is a success envelope, so
319    // nothing in the error machinery reaches it, and a script or agent reading
320    // it takes zero results as a fact about the literature and stops. The fix
321    // first landed on the MCP tool only -- this surface went on emitting the
322    // exact envelope the issue was filed about, byte for byte.
323    if results.results.is_empty() {
324        if let Some(hint) = doiget_core::discovery::zero_result_hint(query) {
325            envelope["hint"] = serde_json::json!(hint);
326        }
327    }
328    envelope
329}
330
331/// Pretty-serialize a JSON value and write it as one line to `out`. Shared
332/// by the local and external `--mode json` paths.
333fn write_json(out: &mut impl Write, value: &serde_json::Value) -> Result<()> {
334    let s = serde_json::to_string_pretty(value).context("failed to serialize search JSON")?;
335    writeln!(out, "{s}").context("failed to write search JSON to stdout")
336}
337
338/// Render an optional value for a human-table cell: its `Display`, or `-`.
339fn dash_or<T: std::fmt::Display>(v: Option<T>) -> String {
340    v.map(|x| x.to_string()).unwrap_or_else(|| "-".into())
341}
342
343#[cfg(test)]
344#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
345mod tests {
346    use super::*;
347    use doiget_core::discovery::{DiscoverySource, PaperHit};
348
349    fn hit() -> PaperHit {
350        PaperHit {
351            doi: Some("10.1234/x".to_string()),
352            openalex_id: "W1".to_string(),
353            arxiv: None,
354            title: "T".to_string(),
355            authors: vec!["A".to_string()],
356            year: Some(2024),
357            venue: Some("V".to_string()),
358            abstract_: Some("abs".to_string()),
359            cited_by_count: 3,
360            oa_status: Some("gold".to_string()),
361            fwci: Some(2.5),
362            cited_by_percentile_year_min: Some(85),
363            source: DiscoverySource::OpenAlex,
364        }
365    }
366
367    #[test]
368    fn external_envelope_has_scope_total_and_results() {
369        let results = PaperSearchResults {
370            results: vec![hit()],
371            total_results: Some(4012),
372        };
373        let v = external_envelope("spin glass", &results);
374        assert_eq!(v["ok"], true);
375        assert_eq!(v["scope"], "external");
376        assert_eq!(v["query"], "spin glass");
377        assert_eq!(v["total_results"], 4012);
378        assert_eq!(v["count"], 1);
379        assert_eq!(v["results"][0]["openalex_id"], "W1");
380        assert_eq!(v["results"][0]["abstract"], "abs");
381    }
382
383    /// #534 on THIS surface. The fix landed on the MCP tool first, so
384    /// `doiget search --mode json` went on emitting the exact envelope the
385    /// issue was filed about -- `ok: true`, `total_results: 0`, nothing to
386    /// tell a script that the query, not the literature, was the problem.
387    ///
388    /// Driven through the real `external_envelope`, the function `--mode json`
389    /// prints.
390    #[test]
391    fn a_long_query_that_matched_nothing_carries_the_hint() {
392        let results = PaperSearchResults {
393            results: vec![],
394            total_results: Some(0),
395        };
396        let q = "lithium refractoriness after discontinuation kindling sensitization course of illness Post";
397        let v = external_envelope(q, &results);
398        assert_eq!(v["ok"], true, "still a success envelope: {v}");
399        assert_eq!(v["count"], 0);
400        let hint = v["hint"].as_str().unwrap_or_default();
401        assert!(hint.contains("10 terms"), "names the count: {hint:?}");
402        assert!(hint.contains("3-5"), "says what to do instead: {hint:?}");
403    }
404
405    /// A short query matching nothing may genuinely mean nothing is indexed,
406    /// and a hint on every empty result would train readers to skip it.
407    #[test]
408    fn a_short_query_that_matched_nothing_is_left_alone() {
409        let results = PaperSearchResults {
410            results: vec![],
411            total_results: Some(0),
412        };
413        let v = external_envelope("depersonalization derealization", &results);
414        assert!(v.get("hint").is_none(), "no hint on a short query: {v}");
415    }
416
417    /// And a query that DID match carries no hint, however long it is.
418    #[test]
419    fn a_long_query_with_results_carries_no_hint() {
420        let results = PaperSearchResults {
421            results: vec![hit()],
422            total_results: Some(1),
423        };
424        let q = "a b c d e f g h i j k l";
425        let v = external_envelope(q, &results);
426        assert!(v.get("hint").is_none(), "results present: {v}");
427    }
428
429    #[test]
430    fn sort_arg_lowers_to_core() {
431        // Relevance is the only sort (#290); `cited` / `recent` were removed.
432        assert_eq!(SearchSort::from(SortArg::Relevance), SearchSort::Relevance);
433    }
434
435    #[test]
436    fn local_envelope_has_local_scope_and_count() {
437        let v = local_envelope("quantum", &[]);
438        assert_eq!(v["ok"], true);
439        assert_eq!(v["scope"], "local");
440        assert_eq!(v["query"], "quantum");
441        assert_eq!(v["count"], 0);
442        assert!(v["results"].as_array().expect("results array").is_empty());
443        // The local envelope must NOT carry the external-only field.
444        assert!(v.get("total_results").is_none());
445    }
446
447    /// `ExternalArgs` with defaults; override per test.
448    fn ext(limit: usize, from_year: Option<i32>, to_year: Option<i32>) -> ExternalArgs {
449        ExternalArgs {
450            limit,
451            from_year,
452            to_year,
453            oa_only: false,
454            min_citations: None,
455            min_fwci: None,
456            min_percentile: None,
457            author: None,
458            venue: None,
459            publisher: None,
460            sort: SortArg::Relevance,
461        }
462    }
463
464    // These validations fire BEFORE any network / harness construction, so
465    // the calls error without touching the filesystem or OpenAlex.
466
467    #[tokio::test]
468    async fn external_rejects_limit_below_1() {
469        let err = run(
470            "q".into(),
471            false,
472            None,
473            ext(0, None, None),
474            OutputMode::Quiet,
475            true,
476        )
477        .await
478        .expect_err("limit 0 must be rejected");
479        assert!(err.to_string().contains("limit"), "got: {err}");
480    }
481
482    #[tokio::test]
483    async fn external_rejects_limit_above_200() {
484        let err = run(
485            "q".into(),
486            false,
487            None,
488            ext(201, None, None),
489            OutputMode::Quiet,
490            true,
491        )
492        .await
493        .expect_err("limit 201 must be rejected");
494        assert!(err.to_string().contains("limit"), "got: {err}");
495    }
496
497    #[tokio::test]
498    async fn external_rejects_inverted_year_range() {
499        let err = run(
500            "q".into(),
501            false,
502            None,
503            ext(25, Some(2025), Some(2010)),
504            OutputMode::Quiet,
505            true,
506        )
507        .await
508        .expect_err("from_year > to_year must be rejected");
509        assert!(err.to_string().contains("is after"), "got: {err}");
510    }
511}