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    let contact_email = std::env::var("DOIGET_CONTACT_EMAIL").unwrap_or_default();
222
223    let harness = FetchHarness::from_env().context("building fetch harness")?;
224    harness
225        .log_session_start(Some(query))
226        .context("logging session start")?;
227    let ctx = harness.fetch_context();
228
229    let outcome = paper_search(&base, &contact_email, &q, &ctx).await;
230    harness.log_session_end(outcome.is_ok(), Some(query));
231
232    let results = match outcome {
233        Ok(r) => r,
234        Err(e) => {
235            let code = ErrorCode::from(&e);
236            print_err(format_args!("error[{}]: {e}", code.as_wire()));
237            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
238        }
239    };
240
241    // Artifact-class (ADR-0017 Amendment 2 / #301): suppress only on
242    // explicit Quiet; the non-TTY implicit fallback still emits.
243    if mode == OutputMode::Quiet && quiet_was_explicit {
244        return Ok(());
245    }
246
247    let stdout = std::io::stdout();
248    let mut out = stdout.lock();
249    if mode == OutputMode::Json {
250        write_json(&mut out, &external_envelope(query, &results))?;
251        return Ok(());
252    }
253
254    // Human table: surface "interesting" signals (citations) first, then
255    // year / OA / DOI / title. Tab-separated, `cut(1)`-compatible.
256    writeln!(out, "cited_by\tyear\toa\tdoi\ttitle")
257        .context("failed to write search header to stdout")?;
258    for hit in &results.results {
259        let year = dash_or(hit.year);
260        let oa = hit.oa_status.as_deref().unwrap_or("-");
261        let doi = hit.doi.as_deref().unwrap_or("-");
262        writeln!(
263            out,
264            "{}\t{}\t{}\t{}\t{}",
265            hit.cited_by_count, year, oa, doi, hit.title
266        )
267        .context("failed to write search row to stdout")?;
268    }
269    Ok(())
270}
271
272/// Resolve the OpenAlex base URL: `DOIGET_OPENALEX_BASE` override (tests)
273/// or the production default.
274fn resolve_openalex_base() -> Result<url::Url> {
275    let raw =
276        std::env::var("DOIGET_OPENALEX_BASE").unwrap_or_else(|_| OPENALEX_DEFAULT_BASE.to_string());
277    url::Url::parse(&raw).with_context(|| format!("DOIGET_OPENALEX_BASE is not a URL: {raw}"))
278}
279
280/// Build the local-scan `--mode json` envelope (ADR-0031 D5, #212):
281/// `{ ok, scope: "local", query, count, results }`. The `results[]` element is
282/// the legacy `EntryInfo` shape, unchanged.
283fn local_envelope(query: &str, entries: &[EntryInfo]) -> serde_json::Value {
284    serde_json::json!({
285        "ok": true,
286        "scope": "local",
287        "query": query,
288        "count": entries.len(),
289        "results": entries,
290    })
291}
292
293/// Build the external-discovery `--mode json` envelope (ADR-0031 D5, #212):
294/// `{ ok, scope, query, total_results, count, results }`. Extracted as a pure
295/// function so the wire shape is unit-testable without capturing stdout.
296fn external_envelope(query: &str, results: &PaperSearchResults) -> serde_json::Value {
297    serde_json::json!({
298        "ok": true,
299        "scope": "external",
300        "query": query,
301        "total_results": results.total_results,
302        "count": results.results.len(),
303        "results": results.results,
304    })
305}
306
307/// Pretty-serialize a JSON value and write it as one line to `out`. Shared
308/// by the local and external `--mode json` paths.
309fn write_json(out: &mut impl Write, value: &serde_json::Value) -> Result<()> {
310    let s = serde_json::to_string_pretty(value).context("failed to serialize search JSON")?;
311    writeln!(out, "{s}").context("failed to write search JSON to stdout")
312}
313
314/// Render an optional value for a human-table cell: its `Display`, or `-`.
315fn dash_or<T: std::fmt::Display>(v: Option<T>) -> String {
316    v.map(|x| x.to_string()).unwrap_or_else(|| "-".into())
317}
318
319#[cfg(test)]
320#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
321mod tests {
322    use super::*;
323    use doiget_core::discovery::{DiscoverySource, PaperHit};
324
325    fn hit() -> PaperHit {
326        PaperHit {
327            doi: Some("10.1234/x".to_string()),
328            openalex_id: "W1".to_string(),
329            arxiv: None,
330            title: "T".to_string(),
331            authors: vec!["A".to_string()],
332            year: Some(2024),
333            venue: Some("V".to_string()),
334            abstract_: Some("abs".to_string()),
335            cited_by_count: 3,
336            oa_status: Some("gold".to_string()),
337            fwci: Some(2.5),
338            cited_by_percentile_year_min: Some(85),
339            source: DiscoverySource::OpenAlex,
340        }
341    }
342
343    #[test]
344    fn external_envelope_has_scope_total_and_results() {
345        let results = PaperSearchResults {
346            results: vec![hit()],
347            total_results: Some(4012),
348        };
349        let v = external_envelope("spin glass", &results);
350        assert_eq!(v["ok"], true);
351        assert_eq!(v["scope"], "external");
352        assert_eq!(v["query"], "spin glass");
353        assert_eq!(v["total_results"], 4012);
354        assert_eq!(v["count"], 1);
355        assert_eq!(v["results"][0]["openalex_id"], "W1");
356        assert_eq!(v["results"][0]["abstract"], "abs");
357    }
358
359    #[test]
360    fn sort_arg_lowers_to_core() {
361        // Relevance is the only sort (#290); `cited` / `recent` were removed.
362        assert_eq!(SearchSort::from(SortArg::Relevance), SearchSort::Relevance);
363    }
364
365    #[test]
366    fn local_envelope_has_local_scope_and_count() {
367        let v = local_envelope("quantum", &[]);
368        assert_eq!(v["ok"], true);
369        assert_eq!(v["scope"], "local");
370        assert_eq!(v["query"], "quantum");
371        assert_eq!(v["count"], 0);
372        assert!(v["results"].as_array().expect("results array").is_empty());
373        // The local envelope must NOT carry the external-only field.
374        assert!(v.get("total_results").is_none());
375    }
376
377    /// `ExternalArgs` with defaults; override per test.
378    fn ext(limit: usize, from_year: Option<i32>, to_year: Option<i32>) -> ExternalArgs {
379        ExternalArgs {
380            limit,
381            from_year,
382            to_year,
383            oa_only: false,
384            min_citations: None,
385            min_fwci: None,
386            min_percentile: None,
387            author: None,
388            venue: None,
389            publisher: None,
390            sort: SortArg::Relevance,
391        }
392    }
393
394    // These validations fire BEFORE any network / harness construction, so
395    // the calls error without touching the filesystem or OpenAlex.
396
397    #[tokio::test]
398    async fn external_rejects_limit_below_1() {
399        let err = run(
400            "q".into(),
401            false,
402            None,
403            ext(0, None, None),
404            OutputMode::Quiet,
405            true,
406        )
407        .await
408        .expect_err("limit 0 must be rejected");
409        assert!(err.to_string().contains("limit"), "got: {err}");
410    }
411
412    #[tokio::test]
413    async fn external_rejects_limit_above_200() {
414        let err = run(
415            "q".into(),
416            false,
417            None,
418            ext(201, None, None),
419            OutputMode::Quiet,
420            true,
421        )
422        .await
423        .expect_err("limit 201 must be rejected");
424        assert!(err.to_string().contains("limit"), "got: {err}");
425    }
426
427    #[tokio::test]
428    async fn external_rejects_inverted_year_range() {
429        let err = run(
430            "q".into(),
431            false,
432            None,
433            ext(25, Some(2025), Some(2010)),
434            OutputMode::Quiet,
435            true,
436        )
437        .await
438        .expect_err("from_year > to_year must be rejected");
439        assert!(err.to_string().contains("is after"), "got: {err}");
440    }
441}