Skip to main content

research_agent/adapters/
sqlite_store.rs

1use std::sync::Mutex;
2
3use rusqlite::{Connection, params};
4
5use crate::domain::citation::Citation;
6use crate::domain::knowledge_gap::{GapType, KnowledgeGap};
7use crate::domain::paper::{Paper, PaperStatus, Rating, ReadingStatus};
8use crate::domain::research_report::ResearchReport;
9use crate::domain::research_state::ResearchState;
10use crate::domain::research_topic::ResearchTopic;
11use crate::error::{ResearchError, Result};
12use crate::ports::index_store::{BodyEvidence, IndexStore};
13use crate::store::schema::{FTS_V3_SQL, MIGRATION_SQL, SCHEMA_SQL, TARGET_SCHEMA_VERSION};
14
15pub struct SqliteStore {
16    conn: Mutex<Connection>,
17}
18
19/// Quote every whitespace-separated token as an FTS5 phrase so user input
20/// like `latch-free` or `worst-case` is matched literally instead of being
21/// parsed as FTS5 syntax (where `-` is the NOT operator and bare `case`
22/// becomes a column reference). Double quotes in the input are dropped;
23/// tokens are implicitly AND-ed.
24fn fts_phrase_query(raw: &str) -> String {
25    raw.split_whitespace()
26        .map(|t| t.replace('"', ""))
27        .filter(|t| !t.is_empty())
28        .map(|t| format!("\"{t}\""))
29        .collect::<Vec<_>>()
30        .join(" ")
31}
32
33/// Where in `body` an FTS5 `snippet()` result came from.
34///
35/// Anchoring on the matched term alone is wrong twice over: the term may occur
36/// many times (`find` would take the first, not the one FTS chose), and the
37/// trigram tokenizer matches inside words, so a hit on "ion" can land in the
38/// middle of the heading "Introduction" and report a truncated section.
39///
40/// Rebuilding the snippet's own text and locating *that* pins the real
41/// position: it is a contiguous run of the body, long enough to be unique in
42/// practice. Ellipses mark where snippet() clipped the window, so the
43/// unclipped middle is what gets matched.
44///
45/// `query_terms` is the FTS query that produced the snippet: it tells match
46/// brackets apart from literal ones the body carried all along.
47fn locate_snippet(snippet: &str, body: &str, query_terms: &str) -> Option<usize> {
48    // Strip exactly the pair of ellipses snippet() adds to mark a clipped
49    // window. `trim_matches` would also eat any the body text itself starts or
50    // ends with; that happens to come out even today because the needle and the
51    // lead shrink together, but the compensation is incidental and stating the
52    // intent directly costs nothing.
53    let core = snippet.strip_prefix('…').unwrap_or(snippet);
54    let core = core.strip_suffix('…').unwrap_or(core);
55    // Trim before measuring, not after. `lead` and the text located in the body
56    // must be counted against the *same* string: measuring the lead against an
57    // untrimmed window while searching for its trimmed text shifts the anchor
58    // right by every character the trim removed, and PDF bodies routinely keep
59    // indentation on wrapped lines.
60    let core = core.trim();
61    let plain: String = core.chars().filter(|c| *c != '[' && *c != ']').collect();
62    let plain = plain.as_str();
63    if plain.is_empty() {
64        return None;
65    }
66    // A `[` in the window is not necessarily snippet()'s match marker: bodies
67    // carry literal citations like "[12]" that ride along unbracketed by the
68    // matcher. A bracket span is the match only if its content appears in the
69    // query; any other bracket is body punctuation and stays put.
70    let match_at = core.match_indices('[').find_map(|(b, _)| {
71        let rest = &core[b + 1..];
72        let end = rest.find(']')?;
73        (!rest[..end].is_empty() && query_terms.contains(&rest[..end])).then_some(b)
74    });
75    // Strip the same brackets from the body so the window still matches
76    // through literal citations, and keep each kept character's offset:
77    // `plain` and the lead below are both counted in stripped coordinates.
78    let mut stripped = String::with_capacity(body.len());
79    let mut offsets = Vec::with_capacity(body.len());
80    for (i, c) in body.char_indices() {
81        if c != '[' && c != ']' {
82            stripped.push(c);
83            offsets.push(i);
84        }
85    }
86    // Offset the located window start by the characters it keeps before the
87    // match, so the anchor is the matched text itself rather than the
88    // snippet's leading edge (which can begin mid-heading and truncate the
89    // section name).
90    let lead = match_at
91        .map(|b| core[..b].chars().filter(|c| *c != '[' && *c != ']').count())
92        .unwrap_or(0);
93    // Map a byte position in the stripped body back to the original body.
94    let at = |pos: usize| -> Option<usize> {
95        let chars = stripped[..pos].chars().count();
96        offsets.get(chars + lead).copied()
97    };
98    if let Some(pos) = stripped.find(plain) {
99        return at(pos);
100    }
101    // snippet() reproduces the body's casing, so an exact hit is the norm.
102    // Fall back case-insensitively rather than silently anchoring to offset 0,
103    // which would report the document's first section for a match anywhere.
104    let lower_stripped = stripped.to_lowercase();
105    let pos = lower_stripped.find(&plain.to_lowercase())?;
106    // Byte offsets from the lowercased copy are only valid if lowercasing did
107    // not change the length; give up rather than report a wrong anchor.
108    if lower_stripped.len() != stripped.len() {
109        return None;
110    }
111    at(pos)
112}
113
114impl SqliteStore {
115    pub fn open(path: &std::path::Path) -> Result<Self> {
116        let conn = Connection::open(path)?;
117        conn.execute_batch("PRAGMA journal_mode=WAL;")?;
118        conn.execute_batch("PRAGMA foreign_keys=ON;")?;
119        // Wait up to 5s on a locked DB instead of failing immediately. This
120        // matters under the MCP server, where concurrent tool calls each open
121        // their own store handle and run `init_schema` (CREATE TABLE …) —
122        // without a busy timeout the parallel writers race on the SQLite write
123        // lock and surface "database is locked". Harmless for the single-handle
124        // CLI path.
125        conn.busy_timeout(std::time::Duration::from_secs(5))?;
126        let store = Self {
127            conn: Mutex::new(conn),
128        };
129        store.init_schema()?;
130        Ok(store)
131    }
132
133    pub fn open_in_memory() -> Result<Self> {
134        let conn = Connection::open_in_memory()?;
135        conn.execute_batch("PRAGMA foreign_keys=ON;")?;
136        let store = Self {
137            conn: Mutex::new(conn),
138        };
139        store.init_schema()?;
140        Ok(store)
141    }
142
143    fn paper_from_row(row: &rusqlite::Row<'_>) -> std::result::Result<Paper, rusqlite::Error> {
144        let authors_str: String = row.get("authors")?;
145        let tags_str: String = row.get("tags")?;
146        Ok(Paper {
147            id: row.get("id")?,
148            title: row.get("title")?,
149            authors: serde_json::from_str(&authors_str).unwrap_or_default(),
150            abstract_text: row.get("abstract_text")?,
151            year: row.get("year")?,
152            venue: row.get("venue")?,
153            doi: row.get("doi")?,
154            arxiv_id: row.get("arxiv_id")?,
155            s2_id: row.get("s2_id")?,
156            openalex_id: row.get("openalex_id")?,
157            url: row.get("url")?,
158            pdf_path: row.get("pdf_path")?,
159            status: {
160                let s: String = row.get("status")?;
161                PaperStatus::from_str_lossy(&s)
162            },
163            notes: row.get("notes")?,
164            tags: serde_json::from_str(&tags_str).unwrap_or_default(),
165            relevance_score: row.get("relevance_score")?,
166            reading_status: {
167                let s: String = row.get("reading_status")?;
168                ReadingStatus::from_str_lossy(&s)
169            },
170            rating: {
171                // Defensive read: a corrupt or out-of-range value (hand-edited
172                // DB, an older binary) maps to None (unrated) instead of
173                // failing the query. Rating::new enforces 1..=5 on every write
174                // path, so this only affects data that bypassed the constructor.
175                let raw: Option<i64> = row.get("rating")?;
176                raw.and_then(|n| u8::try_from(n).ok().and_then(|v| Rating::new(v).ok()))
177            },
178            // Defaulting here is load-bearing exactly once: `SELECT *` against a
179            // pre-v3 table has no `keywords` column, which is how a paper reads
180            // during the migration that adds it. After that the column is
181            // NOT NULL DEFAULT '', so a failure means a broken schema — but
182            // reporting it as "" would look like "needs enrichment" and loop
183            // forever, so it is worth not hiding.
184            keywords: match row.get("keywords") {
185                Ok(kw) => kw,
186                Err(rusqlite::Error::InvalidColumnName(_)) => String::new(),
187                Err(e) => return Err(e),
188            },
189            created_at: row.get("created_at")?,
190            updated_at: row.get("updated_at")?,
191        })
192    }
193}
194
195impl IndexStore for SqliteStore {
196    fn insert_paper(&self, paper: &Paper) -> Result<()> {
197        let conn = self.conn.lock().map_err(|e| {
198            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
199        })?;
200        conn.execute(
201            "INSERT OR REPLACE INTO papers
202             (id, title, authors, abstract_text, year, venue, doi, arxiv_id, s2_id,
203              openalex_id, url, pdf_path, status, reading_status, notes, tags,
204              relevance_score, rating, keywords, created_at, updated_at)
205             VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)",
206            params![
207                paper.id,
208                paper.title,
209                serde_json::to_string(&paper.authors)?,
210                paper.abstract_text,
211                paper.year,
212                paper.venue,
213                paper.doi,
214                paper.arxiv_id,
215                paper.s2_id,
216                paper.openalex_id,
217                paper.url,
218                paper.pdf_path,
219                paper.status.as_str(),
220                paper.reading_status.as_str(),
221                paper.notes,
222                serde_json::to_string(&paper.tags)?,
223                paper.relevance_score,
224                paper.rating.map(|r| r.get() as i64),
225                paper.keywords,
226                paper.created_at,
227                paper.updated_at,
228            ],
229        )?;
230        Ok(())
231    }
232
233    fn get_paper(&self, id: &str) -> Result<Option<Paper>> {
234        let conn = self.conn.lock().map_err(|e| {
235            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
236        })?;
237        let mut stmt = conn.prepare("SELECT * FROM papers WHERE id = ?1")?;
238        let mut rows = stmt.query(params![id])?;
239        match rows.next()? {
240            Some(row) => Ok(Some(Self::paper_from_row(row)?)),
241            None => Ok(None),
242        }
243    }
244
245    fn find_paper_by_doi(&self, doi: &str) -> Result<Option<Paper>> {
246        let conn = self.conn.lock().map_err(|e| {
247            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
248        })?;
249        // Compare on the normalized form so a DOI stored raw by an earlier
250        // ingest (Europe PMC and Semantic Scholar store what upstream sends)
251        // still matches a normalized one arriving now. Normalizing only the
252        // incoming side would let 10.1038/NATURE12373 and 10.1038/nature12373
253        // coexist as separate papers.
254        let needle = crate::adapters::bib_importer::normalize_doi(doi);
255        let Some(needle) = needle else {
256            return Ok(None);
257        };
258        // Normalize the stored side with the same function, not a parallel SQL
259        // `replace()` chain: the chain silently covered fewer prefixes than
260        // `normalize_doi`, so `doi:` and `http://doi.org/` rows never matched.
261        // `doi:` cannot be expressed as a `replace()` anyway without corrupting
262        // a DOI that contains the substring. Prefiltering on a suffix match
263        // keeps SQLite from handing back the whole table.
264        let mut stmt = conn.prepare(
265            "SELECT * FROM papers
266             WHERE doi IS NOT NULL AND lower(trim(doi)) LIKE '%' || ?1",
267        )?;
268        let mut rows = stmt.query(params![needle])?;
269        while let Some(row) = rows.next()? {
270            let stored: Option<String> = row.get("doi")?;
271            let matches = stored
272                .as_deref()
273                .and_then(crate::adapters::bib_importer::normalize_doi)
274                .is_some_and(|stored| stored == needle);
275            if matches {
276                return Ok(Some(Self::paper_from_row(row)?));
277            }
278        }
279        Ok(None)
280    }
281
282    fn find_paper_by_openalex_id(&self, openalex_id: &str) -> Result<Option<Paper>> {
283        let conn = self.conn.lock().map_err(|e| {
284            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
285        })?;
286        let mut stmt = conn.prepare("SELECT * FROM papers WHERE openalex_id = ?1 LIMIT 1")?;
287        let mut rows = stmt.query(params![openalex_id])?;
288        match rows.next()? {
289            Some(row) => Ok(Some(Self::paper_from_row(row)?)),
290            None => Ok(None),
291        }
292    }
293
294    fn find_paper_by_pdf_path(&self, path: &str) -> Result<Option<Paper>> {
295        let conn = self.conn.lock().map_err(|e| {
296            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
297        })?;
298        let mut stmt = conn.prepare("SELECT * FROM papers WHERE pdf_path = ?1 LIMIT 1")?;
299        let mut rows = stmt.query(params![path])?;
300        match rows.next()? {
301            Some(row) => Ok(Some(Self::paper_from_row(row)?)),
302            None => Ok(None),
303        }
304    }
305
306    fn find_paper_by_title(&self, title: &str) -> Result<Option<Paper>> {
307        let conn = self.conn.lock().map_err(|e| {
308            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
309        })?;
310        // The whitespace collapse in `normalize_title` has no SQL equivalent,
311        // so the stored side is compared in Rust. Libraries are personal-scale
312        // (hundreds of rows), which keeps the full scan affordable.
313        // ponytail: full scan per lookup; add a normalized-title column if a
314        // library grows past ~10k papers.
315        let mut stmt = conn.prepare("SELECT * FROM papers WHERE title IS NOT NULL")?;
316        let mut rows = stmt.query([])?;
317        while let Some(row) = rows.next()? {
318            let stored: String = row.get("title")?;
319            if crate::domain::paper::normalize_title(&stored) == title {
320                return Ok(Some(Self::paper_from_row(row)?));
321            }
322        }
323        Ok(None)
324    }
325
326    fn set_paper_body(&self, paper_id: &str, body: &str) -> Result<()> {
327        let conn = self.conn.lock().map_err(|e| {
328            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
329        })?;
330        conn.execute(
331            "INSERT OR REPLACE INTO paper_bodies (paper_id, body) VALUES (?1, ?2)",
332            params![paper_id, body],
333        )?;
334        Ok(())
335    }
336
337    fn get_paper_body(&self, paper_id: &str) -> Result<Option<String>> {
338        let conn = self.conn.lock().map_err(|e| {
339            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
340        })?;
341        let mut stmt = conn.prepare("SELECT body FROM paper_bodies WHERE paper_id = ?1")?;
342        let mut rows = stmt.query(params![paper_id])?;
343        match rows.next()? {
344            Some(row) => Ok(Some(row.get(0)?)),
345            None => Ok(None),
346        }
347    }
348
349    fn set_paper_pdf_path(&self, paper_id: &str, path: &str) -> Result<()> {
350        let conn = self.conn.lock().map_err(|e| {
351            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
352        })?;
353        let now = chrono::Utc::now().to_rfc3339();
354        conn.execute(
355            "UPDATE papers SET pdf_path = ?1, updated_at = ?2 WHERE id = ?3",
356            params![path, now, paper_id],
357        )?;
358        Ok(())
359    }
360
361    fn set_paper_keywords(&self, id: &str, keywords: &str) -> Result<()> {
362        let conn = self.conn.lock().map_err(|e| {
363            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
364        })?;
365        let now = chrono::Utc::now().to_rfc3339();
366        // Bounded at the store so every writer is covered — the MCP tool is
367        // agent-reachable, and unbounded keywords would bloat the trigram index
368        // on every row. Keywords are a short list; 512 chars is generous.
369        let keywords: String = keywords.chars().take(512).collect();
370        // The papers_au trigger reindexes the FTS row, so no explicit index
371        // maintenance is needed here.
372        let changed = conn.execute(
373            "UPDATE papers SET keywords = ?1, updated_at = ?2 WHERE id = ?3",
374            params![keywords, now, id],
375        )?;
376        if changed == 0 {
377            return Err(ResearchError::NotFound(format!("paper {id}")));
378        }
379        Ok(())
380    }
381
382    fn papers_missing_keywords(&self, limit: usize) -> Result<Vec<Paper>> {
383        let conn = self.conn.lock().map_err(|e| {
384            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
385        })?;
386        let mut stmt = conn.prepare(
387            "SELECT * FROM papers WHERE keywords = '' ORDER BY created_at DESC LIMIT ?1",
388        )?;
389        let rows = stmt.query_map(params![limit as i64], Self::paper_from_row)?;
390        let mut papers = Vec::new();
391        for paper in rows {
392            papers.push(paper?);
393        }
394        Ok(papers)
395    }
396
397    fn papers_missing_keywords_by_topic(&self, topic_id: &str, limit: usize) -> Result<Vec<Paper>> {
398        let conn = self.conn.lock().map_err(|e| {
399            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
400        })?;
401        let mut stmt = conn.prepare(
402            "SELECT p.* FROM papers p
403             JOIN topic_papers tp ON tp.paper_id = p.id
404             WHERE tp.topic_id = ?1 AND p.keywords = ''
405             ORDER BY tp.relevance DESC LIMIT ?2",
406        )?;
407        let rows = stmt.query_map(params![topic_id, limit as i64], Self::paper_from_row)?;
408        let mut papers = Vec::new();
409        for paper in rows {
410            papers.push(paper?);
411        }
412        Ok(papers)
413    }
414
415    fn papers_stalest(&self, limit: usize) -> Result<Vec<Paper>> {
416        let conn = self.conn.lock().map_err(|e| {
417            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
418        })?;
419        let mut stmt = conn.prepare("SELECT * FROM papers ORDER BY updated_at ASC LIMIT ?1")?;
420        let rows = stmt.query_map(params![limit as i64], Self::paper_from_row)?;
421        let mut papers = Vec::new();
422        for paper in rows {
423            papers.push(paper?);
424        }
425        Ok(papers)
426    }
427
428    fn papers_by_topic_stalest(&self, topic_id: &str, limit: usize) -> Result<Vec<Paper>> {
429        let conn = self.conn.lock().map_err(|e| {
430            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
431        })?;
432        let mut stmt = conn.prepare(
433            "SELECT p.* FROM papers p
434             JOIN topic_papers tp ON tp.paper_id = p.id
435             WHERE tp.topic_id = ?1
436             ORDER BY p.updated_at ASC LIMIT ?2",
437        )?;
438        let rows = stmt.query_map(params![topic_id, limit as i64], Self::paper_from_row)?;
439        let mut papers = Vec::new();
440        for paper in rows {
441            papers.push(paper?);
442        }
443        Ok(papers)
444    }
445
446    fn update_paper_status(&self, id: &str, status: PaperStatus) -> Result<()> {
447        let conn = self.conn.lock().map_err(|e| {
448            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
449        })?;
450        let now = chrono::Utc::now().to_rfc3339();
451        let changed = conn.execute(
452            "UPDATE papers SET status = ?1, updated_at = ?2 WHERE id = ?3",
453            params![status.as_str(), now, id],
454        )?;
455        if changed == 0 {
456            return Err(ResearchError::NotFound(format!("paper {id}")));
457        }
458        Ok(())
459    }
460
461    fn update_reading_status(&self, id: &str, status: ReadingStatus) -> Result<()> {
462        let conn = self.conn.lock().map_err(|e| {
463            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
464        })?;
465        let now = chrono::Utc::now().to_rfc3339();
466        let changed = conn.execute(
467            "UPDATE papers SET reading_status = ?1, updated_at = ?2 WHERE id = ?3",
468            params![status.as_str(), now, id],
469        )?;
470        if changed == 0 {
471            return Err(ResearchError::NotFound(format!("paper {id}")));
472        }
473        Ok(())
474    }
475
476    fn update_rating(&self, id: &str, rating: Rating) -> Result<()> {
477        let conn = self.conn.lock().map_err(|e| {
478            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
479        })?;
480        let now = chrono::Utc::now().to_rfc3339();
481        let changed = conn.execute(
482            "UPDATE papers SET rating = ?1, updated_at = ?2 WHERE id = ?3",
483            params![rating.get() as i64, now, id],
484        )?;
485        if changed == 0 {
486            return Err(ResearchError::NotFound(format!("paper {id}")));
487        }
488        Ok(())
489    }
490
491    fn clear_rating(&self, id: &str) -> Result<()> {
492        let conn = self.conn.lock().map_err(|e| {
493            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
494        })?;
495        let now = chrono::Utc::now().to_rfc3339();
496        let changed = conn.execute(
497            "UPDATE papers SET rating = NULL, updated_at = ?1 WHERE id = ?2",
498            params![now, id],
499        )?;
500        if changed == 0 {
501            return Err(ResearchError::NotFound(format!("paper {id}")));
502        }
503        Ok(())
504    }
505
506    fn search_papers(&self, query: &str, limit: usize) -> Result<Vec<Paper>> {
507        let conn = self.conn.lock().map_err(|e| {
508            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
509        })?;
510        let fts_query = fts_phrase_query(query);
511        if fts_query.is_empty() {
512            return Ok(Vec::new());
513        }
514        let limit_i64 = limit as i64;
515
516        let mut stmt = conn.prepare(
517            "SELECT p.* FROM papers p
518             JOIN papers_fts fts ON fts.rowid = p.rowid
519             WHERE papers_fts MATCH ?1
520             ORDER BY rank
521             LIMIT ?2",
522        )?;
523        let rows = stmt.query_map(params![fts_query, limit_i64], Self::paper_from_row)?;
524        let mut papers = Vec::new();
525        for p in rows {
526            papers.push(p?);
527        }
528
529        // Body hits: papers whose stored body text matches but whose title /
530        // abstract / notes did not. Ranks across two FTS tables are not
531        // comparable, so body-only hits simply follow the metadata hits.
532        // ponytail: append-after ordering; a cross-table rank fusion only pays
533        // off once libraries grow past a few thousand papers.
534        let mut stmt = conn.prepare(
535            "SELECT p.* FROM papers p
536             JOIN paper_bodies pb ON pb.paper_id = p.id
537             JOIN bodies_fts fts ON fts.rowid = pb.rowid
538             WHERE bodies_fts MATCH ?1
539             LIMIT ?2",
540        )?;
541        let rows = stmt.query_map(params![fts_query, limit_i64], Self::paper_from_row)?;
542        for p in rows {
543            let p = p?;
544            if !papers.iter().any(|existing| existing.id == p.id) {
545                papers.push(p);
546            }
547        }
548        papers.truncate(limit);
549        Ok(papers)
550    }
551
552    fn search_body_evidence(
553        &self,
554        query: &str,
555        paper_id: Option<&str>,
556        limit: usize,
557    ) -> Result<Vec<BodyEvidence>> {
558        let conn = self.conn.lock().map_err(|e| {
559            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
560        })?;
561        let fts_query = fts_phrase_query(query);
562        if fts_query.is_empty() {
563            return Ok(Vec::new());
564        }
565        // snippet() gives the matching window with terms bracketed; the body
566        // itself comes back so the match can be located for anchoring.
567        // Scoping happens in SQL, not after the fact: filtering a whole-library
568        // result set in the caller lets other papers' hits crowd out the
569        // requested paper's before it is ever reached.
570        let mut stmt = conn.prepare(
571            "SELECT p.id, p.title, pb.body,
572                    snippet(bodies_fts, 0, '[', ']', '…', 32) AS snip
573             FROM papers p
574             JOIN paper_bodies pb ON pb.paper_id = p.id
575             JOIN bodies_fts fts ON fts.rowid = pb.rowid
576             WHERE bodies_fts MATCH ?1
577               AND (?2 IS NULL OR p.id = ?2)
578             ORDER BY rank
579             LIMIT ?3",
580        )?;
581        let rows = stmt.query_map(params![fts_query, paper_id, limit as i64], |row| {
582            Ok((
583                row.get::<_, String>(0)?,
584                row.get::<_, String>(1)?,
585                row.get::<_, String>(2)?,
586                row.get::<_, String>(3)?,
587            ))
588        })?;
589        let mut out = Vec::new();
590        for row in rows {
591            let (paper_id, title, body, snippet) = row?;
592            // Anchor on the matched term itself, which snippet() brackets.
593            // Anchoring on surrounding context instead would land on the
594            // leading edge of the window and can sit *before* the very heading
595            // the match falls under.
596            let anchor = locate_snippet(&snippet, &body, &fts_query)
597                .map(|off| crate::domain::anchor::resolve(&body, off))
598                .unwrap_or_default();
599            out.push(BodyEvidence {
600                paper_id,
601                title,
602                snippet,
603                anchor,
604            });
605        }
606        Ok(out)
607    }
608
609    fn list_papers(&self, limit: Option<usize>) -> Result<Vec<Paper>> {
610        let conn = self.conn.lock().map_err(|e| {
611            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
612        })?;
613        let sql = match limit {
614            Some(n) => format!("SELECT * FROM papers ORDER BY created_at DESC LIMIT {n}"),
615            None => "SELECT * FROM papers ORDER BY created_at DESC".into(),
616        };
617        let mut stmt = conn.prepare(&sql)?;
618        let rows = stmt.query_map([], Self::paper_from_row)?;
619        let mut papers = Vec::new();
620        for p in rows {
621            papers.push(p?);
622        }
623        Ok(papers)
624    }
625
626    fn list_papers_by_topic(&self, topic_id: &str, limit: Option<usize>) -> Result<Vec<Paper>> {
627        let conn = self.conn.lock().map_err(|e| {
628            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
629        })?;
630        // Interpolating `limit` as a usize is safe (no injection surface) and
631        // matches the existing `list_papers` style; the user-supplied value is
632        // the parameterized `topic_id`.
633        let sql = match limit {
634            Some(n) => format!(
635                "SELECT p.* FROM papers p
636                 JOIN topic_papers tp ON tp.paper_id = p.id
637                 WHERE tp.topic_id = ?1
638                 ORDER BY tp.relevance DESC, p.created_at DESC
639                 LIMIT {n}"
640            ),
641            None => "SELECT p.* FROM papers p
642                 JOIN topic_papers tp ON tp.paper_id = p.id
643                 WHERE tp.topic_id = ?1
644                 ORDER BY tp.relevance DESC, p.created_at DESC"
645                .into(),
646        };
647        let mut stmt = conn.prepare(&sql)?;
648        let rows = stmt.query_map(params![topic_id], Self::paper_from_row)?;
649        let mut papers = Vec::new();
650        for p in rows {
651            papers.push(p?);
652        }
653        Ok(papers)
654    }
655
656    fn insert_topic(&self, topic: &ResearchTopic) -> Result<()> {
657        let conn = self.conn.lock().map_err(|e| {
658            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
659        })?;
660        conn.execute(
661            "INSERT OR REPLACE INTO research_topics
662             (id, name, description, parent_topic_id, depth, priority, created_at)
663             VALUES (?1,?2,?3,?4,?5,?6,?7)",
664            params![
665                topic.id,
666                topic.name,
667                topic.description,
668                topic.parent_topic_id,
669                topic.depth,
670                topic.priority,
671                topic.created_at,
672            ],
673        )?;
674        Ok(())
675    }
676
677    fn get_topic(&self, id: &str) -> Result<Option<ResearchTopic>> {
678        let conn = self.conn.lock().map_err(|e| {
679            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
680        })?;
681        let mut stmt = conn.prepare("SELECT * FROM research_topics WHERE id = ?1")?;
682        let mut rows = stmt.query(params![id])?;
683        match rows.next()? {
684            Some(row) => Ok(Some(ResearchTopic {
685                id: row.get("id")?,
686                name: row.get("name")?,
687                description: row.get("description")?,
688                parent_topic_id: row.get("parent_topic_id")?,
689                depth: row.get("depth")?,
690                priority: row.get("priority")?,
691                created_at: row.get("created_at")?,
692            })),
693            None => Ok(None),
694        }
695    }
696
697    fn list_topics(&self) -> Result<Vec<ResearchTopic>> {
698        let conn = self.conn.lock().map_err(|e| {
699            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
700        })?;
701        let mut stmt =
702            conn.prepare("SELECT * FROM research_topics ORDER BY depth ASC, name ASC")?;
703        let rows = stmt.query_map([], |row| {
704            Ok(ResearchTopic {
705                id: row.get("id")?,
706                name: row.get("name")?,
707                description: row.get("description")?,
708                parent_topic_id: row.get("parent_topic_id")?,
709                depth: row.get("depth")?,
710                priority: row.get("priority")?,
711                created_at: row.get("created_at")?,
712            })
713        })?;
714        let mut topics = Vec::new();
715        for t in rows {
716            topics.push(t?);
717        }
718        Ok(topics)
719    }
720
721    fn link_paper_to_topic(&self, paper_id: &str, topic_id: &str, relevance: f32) -> Result<()> {
722        let conn = self.conn.lock().map_err(|e| {
723            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
724        })?;
725        conn.execute(
726            "INSERT OR REPLACE INTO topic_papers (topic_id, paper_id, relevance) VALUES (?1, ?2, ?3)",
727            params![topic_id, paper_id, relevance],
728        )?;
729        Ok(())
730    }
731
732    fn insert_gap(&self, gap: &KnowledgeGap) -> Result<()> {
733        let conn = self.conn.lock().map_err(|e| {
734            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
735        })?;
736        conn.execute(
737            "INSERT OR REPLACE INTO knowledge_gaps
738             (id, description, topic_id, gap_type, priority, discovered_at)
739             VALUES (?1,?2,?3,?4,?5,?6)",
740            params![
741                gap.id,
742                gap.description,
743                gap.topic_id,
744                gap.gap_type.as_str(),
745                gap.priority,
746                gap.discovered_at,
747            ],
748        )?;
749        Ok(())
750    }
751
752    fn list_gaps(&self, topic_id: Option<&str>) -> Result<Vec<KnowledgeGap>> {
753        let conn = self.conn.lock().map_err(|e| {
754            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
755        })?;
756        let mut gaps = Vec::new();
757        match topic_id {
758            Some(tid) => {
759                let mut stmt = conn.prepare(
760                    "SELECT * FROM knowledge_gaps WHERE topic_id = ?1 ORDER BY priority DESC",
761                )?;
762                let rows = stmt.query_map(params![tid], |row| {
763                    let gt: String = row.get("gap_type")?;
764                    Ok(KnowledgeGap {
765                        id: row.get("id")?,
766                        description: row.get("description")?,
767                        topic_id: row.get("topic_id")?,
768                        gap_type: GapType::from_str_lossy(&gt),
769                        priority: row.get("priority")?,
770                        discovered_at: row.get("discovered_at")?,
771                    })
772                })?;
773                for g in rows {
774                    gaps.push(g?);
775                }
776            }
777            None => {
778                let mut stmt =
779                    conn.prepare("SELECT * FROM knowledge_gaps ORDER BY priority DESC")?;
780                let rows = stmt.query_map([], |row| {
781                    let gt: String = row.get("gap_type")?;
782                    Ok(KnowledgeGap {
783                        id: row.get("id")?,
784                        description: row.get("description")?,
785                        topic_id: row.get("topic_id")?,
786                        gap_type: GapType::from_str_lossy(&gt),
787                        priority: row.get("priority")?,
788                        discovered_at: row.get("discovered_at")?,
789                    })
790                })?;
791                for g in rows {
792                    gaps.push(g?);
793                }
794            }
795        }
796        Ok(gaps)
797    }
798
799    fn get_research_state(&self, topic_id: &str) -> Result<Option<ResearchState>> {
800        let conn = self.conn.lock().map_err(|e| {
801            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
802        })?;
803        let mut stmt = conn.prepare("SELECT * FROM research_state WHERE topic_id = ?1")?;
804        let mut rows = stmt.query(params![topic_id])?;
805        match rows.next()? {
806            Some(row) => Ok(Some(ResearchState {
807                topic_id: row.get("topic_id")?,
808                papers_read: row.get("papers_read")?,
809                papers_queued: row.get("papers_queued")?,
810                gaps_identified: row.get("gaps_identified")?,
811                coverage_score: row.get("coverage_score")?,
812                last_updated: row.get("last_updated")?,
813            })),
814            None => Ok(None),
815        }
816    }
817
818    fn update_research_state(&self, state: &ResearchState) -> Result<()> {
819        let conn = self.conn.lock().map_err(|e| {
820            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
821        })?;
822        conn.execute(
823            "INSERT OR REPLACE INTO research_state
824             (topic_id, papers_read, papers_queued, gaps_identified, coverage_score, last_updated)
825             VALUES (?1,?2,?3,?4,?5,?6)",
826            params![
827                state.topic_id,
828                state.papers_read,
829                state.papers_queued,
830                state.gaps_identified,
831                state.coverage_score,
832                state.last_updated,
833            ],
834        )?;
835        Ok(())
836    }
837
838    fn insert_report(&self, report: &ResearchReport) -> Result<()> {
839        let conn = self.conn.lock().map_err(|e| {
840            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
841        })?;
842        conn.execute(
843            "INSERT OR REPLACE INTO research_reports
844             (id, title, topic_ids, content, format, output_path, generated_at)
845             VALUES (?1,?2,?3,?4,?5,?6,?7)",
846            params![
847                report.id,
848                report.title,
849                serde_json::to_string(&report.topic_ids)?,
850                report.to_markdown(),
851                report.format,
852                report.output_path,
853                report.generated_at,
854            ],
855        )?;
856        Ok(())
857    }
858
859    fn list_reports(&self, limit: Option<usize>) -> Result<Vec<ResearchReport>> {
860        let conn = self.conn.lock().map_err(|e| {
861            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
862        })?;
863        let sql = match limit {
864            Some(n) => {
865                format!("SELECT * FROM research_reports ORDER BY generated_at DESC LIMIT {n}")
866            }
867            None => "SELECT * FROM research_reports ORDER BY generated_at DESC".into(),
868        };
869        let mut stmt = conn.prepare(&sql)?;
870        let rows = stmt.query_map([], |row| {
871            let ids_str: String = row.get("topic_ids")?;
872            let content: String = row.get("content").unwrap_or_default();
873            let sections = ResearchReport::parse_sections(&content);
874            Ok(ResearchReport {
875                id: row.get("id")?,
876                title: row.get("title")?,
877                topic_ids: serde_json::from_str(&ids_str).unwrap_or_default(),
878                sections,
879                format: row.get("format")?,
880                output_path: row.get("output_path")?,
881                generated_at: row.get("generated_at")?,
882            })
883        })?;
884        let mut reports = Vec::new();
885        for r in rows {
886            reports.push(r?);
887        }
888        Ok(reports)
889    }
890
891    fn insert_citations(&self, citations: &[Citation]) -> Result<usize> {
892        let mut conn = self.conn.lock().map_err(|e| {
893            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
894        })?;
895        let tx = conn.transaction()?;
896        let mut inserted = 0usize;
897        for c in citations {
898            // INSERT OR IGNORE: the pair is the PK, so re-running a reference
899            // fetch is a no-op for edges already stored.
900            let n = tx.execute(
901                "INSERT OR IGNORE INTO citations (citing_paper_id, cited_paper_id, context)
902                 VALUES (?1, ?2, ?3)",
903                params![c.citing_paper_id, c.cited_paper_id, c.context],
904            )?;
905            inserted += n;
906        }
907        tx.commit()?;
908        Ok(inserted)
909    }
910
911    fn citations_for_paper(&self, paper_id: &str) -> Result<Vec<Citation>> {
912        let conn = self.conn.lock().map_err(|e| {
913            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
914        })?;
915        let mut stmt = conn.prepare(
916            "SELECT citing_paper_id, cited_paper_id, context
917             FROM citations WHERE citing_paper_id = ?1 ORDER BY rowid",
918        )?;
919        let rows = stmt.query_map(params![paper_id], |row| {
920            Ok(Citation {
921                citing_paper_id: row.get(0)?,
922                cited_paper_id: row.get(1)?,
923                context: row.get(2)?,
924            })
925        })?;
926        let mut citations = Vec::new();
927        for c in rows {
928            citations.push(c?);
929        }
930        Ok(citations)
931    }
932
933    fn citations_citing_paper(&self, paper_id: &str) -> Result<Vec<Citation>> {
934        let conn = self.conn.lock().map_err(|e| {
935            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
936        })?;
937        let mut stmt = conn.prepare(
938            "SELECT citing_paper_id, cited_paper_id, context
939             FROM citations WHERE cited_paper_id = ?1 ORDER BY rowid",
940        )?;
941        let rows = stmt.query_map(params![paper_id], |row| {
942            Ok(Citation {
943                citing_paper_id: row.get(0)?,
944                cited_paper_id: row.get(1)?,
945                context: row.get(2)?,
946            })
947        })?;
948        let mut citations = Vec::new();
949        for c in rows {
950            citations.push(c?);
951        }
952        Ok(citations)
953    }
954
955    fn set_citation_contexts(&self, citations: &[Citation]) -> Result<usize> {
956        let mut conn = self.conn.lock().map_err(|e| {
957            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
958        })?;
959        let tx = conn.transaction()?;
960        let mut updated = 0usize;
961        for c in citations {
962            // Scoped to existing edges: labeling never invents an edge the
963            // graph sync did not establish.
964            updated += tx.execute(
965                "UPDATE citations SET context = ?3
966                 WHERE citing_paper_id = ?1 AND cited_paper_id = ?2",
967                params![c.citing_paper_id, c.cited_paper_id, c.context],
968            )?;
969        }
970        tx.commit()?;
971        Ok(updated)
972    }
973
974    fn rebuild_index(&self) -> Result<()> {
975        let conn = self.conn.lock().map_err(|e| {
976            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
977        })?;
978        conn.execute("INSERT INTO papers_fts(papers_fts) VALUES('rebuild')", [])?;
979        conn.execute("INSERT INTO bodies_fts(bodies_fts) VALUES('rebuild')", [])?;
980        Ok(())
981    }
982
983    fn init_schema(&self) -> Result<()> {
984        let mut conn = self.conn.lock().map_err(|e| {
985            ResearchError::Database(rusqlite::Error::InvalidParameterName(e.to_string()))
986        })?;
987        conn.execute_batch(SCHEMA_SQL)?;
988        // Apply additive migrations only when the stored version lags. Each
989        // migration is guarded by a column-existence check, so it is idempotent
990        // even if a prior build already added the column without bumping the
991        // version (which would otherwise make `ALTER` fail on "duplicate
992        // column"). The work and the version bump share one transaction.
993        let current_version = Self::schema_version(&conn);
994        if current_version < TARGET_SCHEMA_VERSION {
995            let tx = conn.transaction()?;
996            for (sql, column) in MIGRATION_SQL {
997                if !Self::column_exists(&tx, "papers", column)? {
998                    tx.execute_batch(sql)?;
999                }
1000            }
1001            // Recreate papers_fts with the `keywords` column. Ordered after the
1002            // column migrations above because the recreated triggers reference
1003            // `new.keywords`, and gated on the version (not a column check) —
1004            // the virtual table's shape is invisible to PRAGMA table_info-style
1005            // guards, and the trailing rebuild costs O(corpus).
1006            if current_version < 3 {
1007                tx.execute_batch(FTS_V3_SQL)?;
1008            }
1009            tx.execute(
1010                "UPDATE _meta SET value = ?1 WHERE key = 'schema_version'",
1011                params![TARGET_SCHEMA_VERSION.to_string()],
1012            )?;
1013            tx.commit()?;
1014        }
1015        Ok(())
1016    }
1017}
1018
1019impl SqliteStore {
1020    /// Read the stored schema version, defaulting to 0 if the `_meta` row is
1021    /// missing or non-numeric (which triggers migration — the safe direction).
1022    fn schema_version(conn: &Connection) -> i64 {
1023        conn.query_row(
1024            "SELECT value FROM _meta WHERE key = 'schema_version'",
1025            [],
1026            |row| Ok(row.get::<_, String>(0)?.parse::<i64>().unwrap_or(0)),
1027        )
1028        .unwrap_or(0)
1029    }
1030
1031    /// True if `column` exists on `table` (via PRAGMA table_info). Errors
1032    /// propagate (do NOT swallow as "column missing") — a PRAGMA failure under
1033    /// lock contention or I/O error must surface, not be misread as "run the
1034    /// ALTER" and crash on "duplicate column".
1035    fn column_exists(conn: &Connection, table: &str, column: &str) -> rusqlite::Result<bool> {
1036        let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
1037        let mut rows = stmt.query([])?;
1038        // PRAGMA table_info columns: cid, name, type, notnull, dflt_value, pk.
1039        while let Some(row) = rows.next()? {
1040            if row
1041                .get::<_, String>(1)
1042                .map(|name| name == column)
1043                .unwrap_or(false)
1044            {
1045                return Ok(true);
1046            }
1047        }
1048        Ok(false)
1049    }
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055    use crate::domain::knowledge_gap::GapType;
1056
1057    fn test_store() -> SqliteStore {
1058        SqliteStore::open_in_memory().unwrap()
1059    }
1060
1061    fn schema_version(store: &SqliteStore) -> i64 {
1062        let conn = store.conn.lock().unwrap();
1063        SqliteStore::schema_version(&conn)
1064    }
1065
1066    #[test]
1067    fn set_paper_pdf_path_roundtrips() {
1068        let store = test_store();
1069        let paper = Paper::new("Downloaded Paper".into());
1070        store.insert_paper(&paper).unwrap();
1071
1072        store
1073            .set_paper_pdf_path(&paper.id, "/cache/pdf/2301.00234.pdf")
1074            .unwrap();
1075        assert_eq!(
1076            store.get_paper(&paper.id).unwrap().unwrap().pdf_path,
1077            Some("/cache/pdf/2301.00234.pdf".into())
1078        );
1079    }
1080
1081    #[test]
1082    fn init_schema_idempotent() {
1083        let store = test_store();
1084        store.init_schema().unwrap();
1085        store.init_schema().unwrap();
1086    }
1087
1088    #[test]
1089    fn insert_and_get_paper() {
1090        let store = test_store();
1091        let paper = Paper::new("Attention Is All You Need".into());
1092        store.insert_paper(&paper).unwrap();
1093
1094        let got = store.get_paper(&paper.id).unwrap().unwrap();
1095        assert_eq!(got.title, "Attention Is All You Need");
1096        assert_eq!(got.status, PaperStatus::Discovered);
1097    }
1098
1099    #[test]
1100    fn get_missing_paper_returns_none() {
1101        let store = test_store();
1102        assert!(store.get_paper("nonexistent").unwrap().is_none());
1103    }
1104
1105    #[test]
1106    fn citations_roundtrip_and_dedupe() {
1107        let store = test_store();
1108        let citing = Paper::new("citing".into());
1109        let cited = Paper::new("cited".into());
1110        store.insert_paper(&citing).unwrap();
1111        store.insert_paper(&cited).unwrap();
1112
1113        let edge = Citation::new(citing.id.clone(), cited.id.clone());
1114        // Inserting the same edge twice must be a no-op the second time.
1115        let first = [edge.clone()];
1116        assert_eq!(store.insert_citations(&first).unwrap(), 1);
1117        assert_eq!(store.insert_citations(&first).unwrap(), 0);
1118
1119        let edges = store.citations_for_paper(&citing.id).unwrap();
1120        assert_eq!(edges.len(), 1);
1121        assert_eq!(edges[0].cited_paper_id, cited.id);
1122        assert!(store.citations_for_paper("unknown").unwrap().is_empty());
1123    }
1124
1125    #[test]
1126    fn find_paper_by_openalex_id() {
1127        let store = test_store();
1128        let mut paper = Paper::new("oa paper".into());
1129        paper.openalex_id = Some("W2741809807".into());
1130        store.insert_paper(&paper).unwrap();
1131
1132        let got = store.find_paper_by_openalex_id("W2741809807").unwrap();
1133        assert_eq!(got.unwrap().id, paper.id);
1134        assert!(store.find_paper_by_openalex_id("W1").unwrap().is_none());
1135    }
1136
1137    #[test]
1138    fn update_paper_status() {
1139        let store = test_store();
1140        let paper = Paper::new("Test".into());
1141        store.insert_paper(&paper).unwrap();
1142
1143        store
1144            .update_paper_status(&paper.id, PaperStatus::Read)
1145            .unwrap();
1146        let got = store.get_paper(&paper.id).unwrap().unwrap();
1147        assert_eq!(got.status, PaperStatus::Read);
1148    }
1149
1150    #[test]
1151    fn update_reading_status() {
1152        let store = test_store();
1153        let paper = Paper::new("Test".into());
1154        store.insert_paper(&paper).unwrap();
1155
1156        store
1157            .update_reading_status(&paper.id, ReadingStatus::Completed)
1158            .unwrap();
1159        let got = store.get_paper(&paper.id).unwrap().unwrap();
1160        assert_eq!(got.reading_status, ReadingStatus::Completed);
1161    }
1162
1163    #[test]
1164    fn update_status_missing_paper_errors() {
1165        let store = test_store();
1166        let result = store.update_paper_status("missing", PaperStatus::Read);
1167        assert!(result.is_err());
1168    }
1169
1170    #[test]
1171    fn search_papers_fts() {
1172        let store = test_store();
1173        let mut paper = Paper::new("Deep Learning for NLP".into());
1174        paper.abstract_text = "A survey of deep learning methods".into();
1175        store.insert_paper(&paper).unwrap();
1176
1177        let results = store.search_papers("deep learning", 10).unwrap();
1178        assert_eq!(results.len(), 1);
1179        assert_eq!(results[0].title, "Deep Learning for NLP");
1180    }
1181
1182    #[test]
1183    fn search_papers_fts_hyphen_and_keyword_tokens() {
1184        let store = test_store();
1185        let mut paper = Paper::new("GTX: A Write-Optimized Latch-free Graph Data System".into());
1186        paper.abstract_text = "worst-case optimal join".into();
1187        store.insert_paper(&paper).unwrap();
1188
1189        // `-` is the FTS5 NOT operator and `case` is otherwise parsed as a column.
1190        for q in [
1191            "GTX latch-free",
1192            "worst-case optimal",
1193            "latch-free \"graph\"",
1194        ] {
1195            let results = store.search_papers(q, 10).unwrap();
1196            assert_eq!(results.len(), 1, "query {q:?} must match literally");
1197        }
1198        assert!(store.search_papers("   ", 10).unwrap().is_empty());
1199    }
1200
1201    #[test]
1202    fn list_papers_with_limit() {
1203        let store = test_store();
1204        for i in 0..5 {
1205            let p = Paper::new(format!("Paper {i}"));
1206            store.insert_paper(&p).unwrap();
1207        }
1208        let all = store.list_papers(None).unwrap();
1209        assert_eq!(all.len(), 5);
1210        let limited = store.list_papers(Some(3)).unwrap();
1211        assert_eq!(limited.len(), 3);
1212    }
1213
1214    #[test]
1215    fn topic_crud() {
1216        let store = test_store();
1217        let topic = ResearchTopic::new("Transformers".into());
1218        store.insert_topic(&topic).unwrap();
1219
1220        let got = store.get_topic(&topic.id).unwrap().unwrap();
1221        assert_eq!(got.name, "Transformers");
1222
1223        let topics = store.list_topics().unwrap();
1224        assert_eq!(topics.len(), 1);
1225    }
1226
1227    #[test]
1228    fn link_paper_to_topic() {
1229        let store = test_store();
1230        let paper = Paper::new("Test Paper".into());
1231        let topic = ResearchTopic::new("Topic".into());
1232        store.insert_paper(&paper).unwrap();
1233        store.insert_topic(&topic).unwrap();
1234
1235        store
1236            .link_paper_to_topic(&paper.id, &topic.id, 0.9)
1237            .unwrap();
1238    }
1239
1240    #[test]
1241    fn list_papers_by_topic_returns_only_linked_papers() {
1242        let store = test_store();
1243        let topic_a = ResearchTopic::new("Topic A".into());
1244        let topic_b = ResearchTopic::new("Topic B".into());
1245        store.insert_topic(&topic_a).unwrap();
1246        store.insert_topic(&topic_b).unwrap();
1247
1248        let paper_a = Paper::new("Paper A".into());
1249        let paper_b = Paper::new("Paper B".into());
1250        let paper_unlinked = Paper::new("Paper Unlinked".into());
1251        store.insert_paper(&paper_a).unwrap();
1252        store.insert_paper(&paper_b).unwrap();
1253        store.insert_paper(&paper_unlinked).unwrap();
1254
1255        store
1256            .link_paper_to_topic(&paper_a.id, &topic_a.id, 0.9)
1257            .unwrap();
1258        store
1259            .link_paper_to_topic(&paper_b.id, &topic_b.id, 0.9)
1260            .unwrap();
1261
1262        let a_papers = store.list_papers_by_topic(&topic_a.id, None).unwrap();
1263        assert_eq!(a_papers.len(), 1);
1264        assert_eq!(a_papers[0].id, paper_a.id);
1265
1266        let b_papers = store.list_papers_by_topic(&topic_b.id, None).unwrap();
1267        assert_eq!(b_papers.len(), 1);
1268        assert_eq!(b_papers[0].id, paper_b.id);
1269    }
1270
1271    #[test]
1272    fn list_papers_by_topic_empty_for_topic_with_no_papers() {
1273        // Regression: a topic with no linked papers must return an empty list,
1274        // not arbitrary papers. The pre-fix gaps/report code path used
1275        // `list_papers(Some(N))` which would have leaked unrelated papers here.
1276        let store = test_store();
1277        let topic_with = ResearchTopic::new("With Papers".into());
1278        let topic_without = ResearchTopic::new("Empty Topic".into());
1279        store.insert_topic(&topic_with).unwrap();
1280        store.insert_topic(&topic_without).unwrap();
1281
1282        let paper = Paper::new("Some Paper".into());
1283        store.insert_paper(&paper).unwrap();
1284        store
1285            .link_paper_to_topic(&paper.id, &topic_with.id, 0.5)
1286            .unwrap();
1287
1288        let empty = store.list_papers_by_topic(&topic_without.id, None).unwrap();
1289        assert!(
1290            empty.is_empty(),
1291            "topic with no linked papers must return empty, not arbitrary papers"
1292        );
1293    }
1294
1295    #[test]
1296    fn list_papers_by_topic_orders_by_relevance_then_respects_limit() {
1297        let store = test_store();
1298        let topic = ResearchTopic::new("Topic".into());
1299        store.insert_topic(&topic).unwrap();
1300
1301        let hi = Paper::new("High Relevance".into());
1302        let lo = Paper::new("Low Relevance".into());
1303        store.insert_paper(&hi).unwrap();
1304        store.insert_paper(&lo).unwrap();
1305        store.link_paper_to_topic(&hi.id, &topic.id, 0.9).unwrap();
1306        store.link_paper_to_topic(&lo.id, &topic.id, 0.1).unwrap();
1307
1308        let ordered = store.list_papers_by_topic(&topic.id, None).unwrap();
1309        assert_eq!(ordered.len(), 2);
1310        assert_eq!(ordered[0].id, hi.id, "most relevant first");
1311
1312        let limited = store.list_papers_by_topic(&topic.id, Some(1)).unwrap();
1313        assert_eq!(limited.len(), 1);
1314        assert_eq!(limited[0].id, hi.id);
1315    }
1316
1317    #[test]
1318    fn gap_crud() {
1319        let store = test_store();
1320        let topic = ResearchTopic::new("Topic".into());
1321        store.insert_topic(&topic).unwrap();
1322
1323        let gap = KnowledgeGap::new(
1324            "Missing survey".into(),
1325            topic.id.clone(),
1326            GapType::MissingLiterature,
1327        );
1328        store.insert_gap(&gap).unwrap();
1329
1330        let gaps = store.list_gaps(Some(&topic.id)).unwrap();
1331        assert_eq!(gaps.len(), 1);
1332        assert_eq!(gaps[0].description, "Missing survey");
1333
1334        let all_gaps = store.list_gaps(None).unwrap();
1335        assert_eq!(all_gaps.len(), 1);
1336    }
1337
1338    #[test]
1339    fn research_state_upsert() {
1340        let store = test_store();
1341        let topic = ResearchTopic::new("Topic".into());
1342        store.insert_topic(&topic).unwrap();
1343
1344        let state = ResearchState {
1345            topic_id: topic.id.clone(),
1346            papers_read: 5,
1347            papers_queued: 3,
1348            gaps_identified: 2,
1349            coverage_score: 0.6,
1350            last_updated: chrono::Utc::now().to_rfc3339(),
1351        };
1352        store.update_research_state(&state).unwrap();
1353
1354        let got = store.get_research_state(&topic.id).unwrap().unwrap();
1355        assert_eq!(got.papers_read, 5);
1356        assert_eq!(got.coverage_score, 0.6);
1357    }
1358
1359    #[test]
1360    fn report_crud() {
1361        let store = test_store();
1362        let mut report = ResearchReport::new("Report".into(), vec!["t1".into()]);
1363        report
1364            .sections
1365            .push(crate::domain::research_report::ReportSection {
1366                heading: "Intro".into(),
1367                content: "Hello world".into(),
1368            });
1369        store.insert_report(&report).unwrap();
1370
1371        let reports = store.list_reports(None).unwrap();
1372        assert_eq!(reports.len(), 1);
1373        assert_eq!(reports[0].title, "Report");
1374        assert_eq!(reports[0].sections.len(), 1);
1375        assert_eq!(reports[0].sections[0].heading, "Intro");
1376        assert_eq!(reports[0].sections[0].content, "Hello world");
1377    }
1378
1379    #[test]
1380    fn rebuild_index() {
1381        let store = test_store();
1382        let mut p = Paper::new("Rebuild Test".into());
1383        p.abstract_text = "Testing rebuild".into();
1384        store.insert_paper(&p).unwrap();
1385
1386        store.rebuild_index().unwrap();
1387        let results = store.search_papers("rebuild", 10).unwrap();
1388        assert_eq!(results.len(), 1);
1389    }
1390
1391    #[test]
1392    fn body_text_is_stored_searched_and_readable() {
1393        let store = test_store();
1394        let paper = Paper::new("Invisible Title".into());
1395        store.insert_paper(&paper).unwrap();
1396        assert!(store.search_papers("quantum", 10).unwrap().is_empty());
1397
1398        store
1399            .set_paper_body(
1400                &paper.id,
1401                "## Introduction\nThe quantum Lich equation dominates.",
1402            )
1403            .unwrap();
1404
1405        // Body-only term finds the paper even though title/abstract don't match.
1406        let hits = store.search_papers("quantum", 10).unwrap();
1407        assert_eq!(hits.len(), 1);
1408        assert_eq!(hits[0].id, paper.id);
1409        assert_eq!(hits[0].title, "Invisible Title");
1410
1411        // Roundtrip: replacing the body works, reading it back works.
1412        store
1413            .set_paper_body(&paper.id, "## Results\nCompletely different body.")
1414            .unwrap();
1415        assert!(store.search_papers("quantum", 10).unwrap().is_empty());
1416        let body = store.get_paper_body(&paper.id).unwrap().unwrap();
1417        assert!(body.contains("## Results"));
1418        assert!(store.get_paper_body("missing-id").unwrap().is_none());
1419    }
1420
1421    #[test]
1422    fn find_paper_by_doi() {
1423        let store = test_store();
1424        let mut paper = Paper::new("Doi Paper".into());
1425        paper.doi = Some("10.1/findme".into());
1426        store.insert_paper(&paper).unwrap();
1427
1428        assert_eq!(
1429            store.find_paper_by_doi("10.1/findme").unwrap().unwrap().id,
1430            paper.id
1431        );
1432        assert!(store.find_paper_by_doi("10.1/missing").unwrap().is_none());
1433    }
1434
1435    #[test]
1436    fn update_rating_roundtrip() {
1437        let store = test_store();
1438        let paper = Paper::new("Rated Paper".into());
1439        store.insert_paper(&paper).unwrap();
1440        store
1441            .update_rating(&paper.id, Rating::new(4).unwrap())
1442            .unwrap();
1443        let got = store.get_paper(&paper.id).unwrap().unwrap();
1444        assert_eq!(got.rating.map(Rating::get), Some(4));
1445    }
1446
1447    #[test]
1448    fn update_rating_missing_paper_errors() {
1449        let store = test_store();
1450        assert!(
1451            store
1452                .update_rating("missing", Rating::new(3).unwrap())
1453                .is_err()
1454        );
1455    }
1456
1457    #[test]
1458    fn topic_hierarchy_depth() {
1459        let store = test_store();
1460        let parent = ResearchTopic::new("ML".into());
1461        store.insert_topic(&parent).unwrap();
1462
1463        let child = ResearchTopic::new_subtopic("Deep Learning".into(), &parent);
1464        store.insert_topic(&child).unwrap();
1465
1466        let got = store.get_topic(&child.id).unwrap().unwrap();
1467        assert_eq!(got.parent_topic_id.as_deref(), Some(parent.id.as_str()));
1468        assert_eq!(got.depth, 1);
1469    }
1470
1471    #[test]
1472    fn list_topics_orders_parents_before_children() {
1473        let store = test_store();
1474        // Parent sorts after the child by name, so a pure name sort would list
1475        // the child first — depth-first ordering must put the parent above.
1476        let parent = ResearchTopic::new("Zoo".into());
1477        store.insert_topic(&parent).unwrap();
1478        let child = ResearchTopic::new_subtopic("Ant".into(), &parent);
1479        store.insert_topic(&child).unwrap();
1480
1481        let topics = store.list_topics().unwrap();
1482        let parent_pos = topics.iter().position(|t| t.id == parent.id).unwrap();
1483        let child_pos = topics.iter().position(|t| t.id == child.id).unwrap();
1484        assert!(parent_pos < child_pos);
1485    }
1486
1487    #[test]
1488    fn init_schema_marks_target_version_and_is_idempotent() {
1489        let store = test_store();
1490        assert_eq!(schema_version(&store), TARGET_SCHEMA_VERSION);
1491        // Re-running is a no-op (no "duplicate column" error path).
1492        store.init_schema().unwrap();
1493        store.init_schema().unwrap();
1494        assert_eq!(schema_version(&store), TARGET_SCHEMA_VERSION);
1495    }
1496
1497    #[test]
1498    fn init_schema_migrates_legacy_v0_database() {
1499        // Simulate a pre-rating database: drop the rating column and reset the
1500        // stored version to 0, then confirm init_schema re-applies the migration.
1501        let store = test_store();
1502        {
1503            let conn = store.conn.lock().unwrap();
1504            conn.execute_batch("ALTER TABLE papers DROP COLUMN rating")
1505                .unwrap();
1506            conn.execute_batch("UPDATE _meta SET value = '0' WHERE key = 'schema_version'")
1507                .unwrap();
1508        }
1509        assert_eq!(schema_version(&store), 0);
1510
1511        store.init_schema().unwrap();
1512
1513        assert_eq!(schema_version(&store), TARGET_SCHEMA_VERSION);
1514        let paper = Paper::new("Legacy".into());
1515        store.insert_paper(&paper).unwrap();
1516        store
1517            .update_rating(&paper.id, Rating::new(5).unwrap())
1518            .unwrap();
1519        let got = store.get_paper(&paper.id).unwrap().unwrap();
1520        assert_eq!(got.rating.map(Rating::get), Some(5));
1521    }
1522
1523    #[test]
1524    fn force_queue_advances_instead_of_repeating_its_head() {
1525        // Regression: ordering the re-enrichment queue by topic relevance made
1526        // every `--force` run return the same head, so repeated runs could
1527        // never reach the rest of the library. Oldest-update-first means each
1528        // pass moves the papers it touched to the back.
1529        let store = test_store();
1530        let mut ids = Vec::new();
1531        for i in 0..5 {
1532            let paper = Paper::new(format!("Paper {i}"));
1533            store.insert_paper(&paper).unwrap();
1534            ids.push(paper.id);
1535        }
1536
1537        let first = store.papers_stalest(2).unwrap();
1538        assert_eq!(first.len(), 2);
1539        // Enriching bumps updated_at, which must push these to the back.
1540        for paper in &first {
1541            store.set_paper_keywords(&paper.id, "kw").unwrap();
1542        }
1543
1544        let second = store.papers_stalest(2).unwrap();
1545        for paper in &second {
1546            assert!(
1547                !first.iter().any(|p| p.id == paper.id),
1548                "second batch repeated a paper from the first"
1549            );
1550        }
1551    }
1552
1553    #[test]
1554    fn topic_missing_keywords_filters_in_sql_not_in_a_window() {
1555        // Regression: fetching a fixed window and filtering afterwards reported
1556        // "nothing to enrich" whenever the window was full of enriched papers.
1557        let store = test_store();
1558        let topic = ResearchTopic::new("T".into());
1559        store.insert_topic(&topic).unwrap();
1560
1561        // 10 enriched papers at high relevance, 1 unenriched at the tail.
1562        for i in 0..10 {
1563            let paper = Paper::new(format!("Enriched {i}"));
1564            store.insert_paper(&paper).unwrap();
1565            store
1566                .link_paper_to_topic(&paper.id, &topic.id, 0.9)
1567                .unwrap();
1568            store.set_paper_keywords(&paper.id, "already").unwrap();
1569        }
1570        let needy = Paper::new("Needs keywords".into());
1571        store.insert_paper(&needy).unwrap();
1572        store
1573            .link_paper_to_topic(&needy.id, &topic.id, 0.1)
1574            .unwrap();
1575
1576        let got = store
1577            .papers_missing_keywords_by_topic(&topic.id, 2)
1578            .unwrap();
1579        assert_eq!(
1580            got.len(),
1581            1,
1582            "the low-relevance unenriched paper must surface"
1583        );
1584        assert_eq!(got[0].id, needy.id);
1585    }
1586
1587    #[test]
1588    fn init_schema_migrates_legacy_v2_to_keywords_fts() {
1589        // Simulate a pre-keywords database: drop the column, restore the old
1590        // 4-column FTS table and its triggers, reset the version. init_schema
1591        // must add the column, rebuild the FTS table with `keywords`, and make
1592        // keyword-only matches findable.
1593        let store = test_store();
1594        {
1595            let conn = store.conn.lock().unwrap();
1596            conn.execute_batch(
1597                // Triggers first: they reference new.keywords, so SQLite
1598                // refuses to drop the column while they exist.
1599                "DROP TRIGGER IF EXISTS papers_ai;
1600                 DROP TRIGGER IF EXISTS papers_ad;
1601                 DROP TRIGGER IF EXISTS papers_au;
1602                 DROP TABLE IF EXISTS papers_fts;
1603                 ALTER TABLE papers DROP COLUMN keywords;
1604                 CREATE VIRTUAL TABLE papers_fts USING fts5(
1605                     title, abstract_text, notes, tags,
1606                     content=papers, content_rowid=rowid, tokenize='trigram');
1607                 UPDATE _meta SET value = '2' WHERE key = 'schema_version';",
1608            )
1609            .unwrap();
1610        }
1611        assert_eq!(schema_version(&store), 2);
1612
1613        store.init_schema().unwrap();
1614
1615        assert_eq!(schema_version(&store), TARGET_SCHEMA_VERSION);
1616        let paper = Paper::new("Linearizable registers".into());
1617        store.insert_paper(&paper).unwrap();
1618        store
1619            .set_paper_keywords(&paper.id, "consistency model; strong consistency")
1620            .unwrap();
1621        // The keyword text is not in the title — only the rebuilt FTS column
1622        // carries it, so a hit proves the migration wired the column in.
1623        let hits = store.search_papers("consistency model", 10).unwrap();
1624        assert!(hits.iter().any(|p| p.id == paper.id));
1625    }
1626
1627    #[test]
1628    fn set_paper_keywords_is_searchable_and_listed_as_missing_before() {
1629        let store = test_store();
1630        let paper = Paper::new("Attention mechanisms".into());
1631        store.insert_paper(&paper).unwrap();
1632
1633        let missing = store.papers_missing_keywords(10).unwrap();
1634        assert!(missing.iter().any(|p| p.id == paper.id));
1635
1636        store
1637            .set_paper_keywords(&paper.id, "transformer; self-attention")
1638            .unwrap();
1639
1640        let got = store.get_paper(&paper.id).unwrap().unwrap();
1641        assert_eq!(got.keywords, "transformer; self-attention");
1642
1643        let hits = store.search_papers("self-attention", 10).unwrap();
1644        assert!(hits.iter().any(|p| p.id == paper.id));
1645
1646        let missing_after = store.papers_missing_keywords(10).unwrap();
1647        assert!(!missing_after.iter().any(|p| p.id == paper.id));
1648    }
1649
1650    #[test]
1651    fn init_schema_tolerates_rating_present_but_version_zero() {
1652        // Regression: PR #5 added the rating column via ALTER but never bumped
1653        // schema_version, leaving real DBs in the state {rating present,
1654        // version='0'}. A naive re-run of the ALTER crashes on "duplicate
1655        // column". init_schema must tolerate this, bump the version, and keep
1656        // the existing column — not crash every command.
1657        let store = test_store();
1658        {
1659            let conn = store.conn.lock().unwrap();
1660            // rating already exists from the fresh schema; just reset the version.
1661            conn.execute_batch("UPDATE _meta SET value = '0' WHERE key = 'schema_version'")
1662                .unwrap();
1663        }
1664        assert_eq!(schema_version(&store), 0);
1665
1666        store.init_schema().unwrap();
1667
1668        assert_eq!(schema_version(&store), TARGET_SCHEMA_VERSION);
1669        let paper = Paper::new("Regession".into());
1670        store.insert_paper(&paper).unwrap();
1671        store
1672            .update_rating(&paper.id, Rating::new(4).unwrap())
1673            .unwrap();
1674    }
1675
1676    /// A body hit must say where in the paper it matched, not just which
1677    /// paper — that is the whole point of storing bodies.
1678    #[test]
1679    fn body_evidence_carries_snippet_and_anchor() {
1680        let store = test_store();
1681        let paper = Paper::new("thermometry paper".into());
1682        store.insert_paper(&paper).unwrap();
1683        let body = "<!-- page 1 -->\nintro\n## Methods\nwe used nanodiamond probes\n<!-- page 2 -->\n## Results\nthe readout was stable\n";
1684        store.set_paper_body(&paper.id, body).unwrap();
1685
1686        let hits = store.search_body_evidence("nanodiamond", None, 10).unwrap();
1687        assert_eq!(hits.len(), 1);
1688        assert_eq!(hits[0].paper_id, paper.id);
1689        assert!(hits[0].snippet.contains("nanodiamond"));
1690        assert_eq!(hits[0].anchor.section.as_deref(), Some("Methods"));
1691        assert_eq!(hits[0].anchor.page, Some(1));
1692
1693        let hits = store.search_body_evidence("readout", None, 10).unwrap();
1694        assert_eq!(hits[0].anchor.section.as_deref(), Some("Results"));
1695        assert_eq!(hits[0].anchor.page, Some(2));
1696    }
1697
1698    #[test]
1699    fn body_evidence_empty_for_nonmatching_or_blank_query() {
1700        let store = test_store();
1701        let paper = Paper::new("p".into());
1702        store.insert_paper(&paper).unwrap();
1703        store
1704            .set_paper_body(&paper.id, "## Intro\nsome text")
1705            .unwrap();
1706
1707        assert!(
1708            store
1709                .search_body_evidence("absent", None, 10)
1710                .unwrap()
1711                .is_empty()
1712        );
1713        assert!(
1714            store
1715                .search_body_evidence("   ", None, 10)
1716                .unwrap()
1717                .is_empty()
1718        );
1719    }
1720
1721    /// The returned offset is the matched text, not the snippet's leading
1722    /// edge: a snippet that opens mid-heading would otherwise anchor inside
1723    /// the heading and report a truncated section name.
1724    #[test]
1725    fn locate_snippet_points_at_the_match_not_the_window() {
1726        let body = "## Intro\nalpha text\n## Results\nbeta text here\n";
1727        let at = locate_snippet("…## Results\nbeta [text] here…", body, "\"text\"").unwrap();
1728        assert_eq!(&body[at..at + 4], "text");
1729        // That offset sits after the heading, so the section resolves whole.
1730        assert_eq!(
1731            crate::domain::anchor::resolve(body, at).section.as_deref(),
1732            Some("Results")
1733        );
1734
1735        assert_eq!(locate_snippet("…", body, "\"text\""), None);
1736        assert_eq!(
1737            locate_snippet("text absent from body", body, "\"text\""),
1738            None
1739        );
1740    }
1741
1742    /// A snippet window that opens with indented body text must still anchor on
1743    /// the matched term. PDF bodies keep indentation on wrapped lines, so a
1744    /// leading-whitespace window is routine rather than exotic.
1745    #[test]
1746    fn locate_snippet_anchors_through_leading_whitespace() {
1747        let body = "<!-- page 1 -->\n## Methods\n    we used nanodiamond probes here\n";
1748        let at = locate_snippet(
1749            "…    we used [nanodiamond] probes here…",
1750            body,
1751            "\"nanodiamond\"",
1752        )
1753        .unwrap();
1754        assert_eq!(&body[at..at + "nanodiamond".len()], "nanodiamond");
1755    }
1756
1757    /// A literal bracket in the body (a citation like "[12]") is not the match
1758    /// marker. Two failures at once otherwise: the lead counts up to the
1759    /// citation instead of the match, and stripping brackets only from the
1760    /// needle makes it unfindable in a body that keeps its own brackets, so
1761    /// the anchor silently falls back to the document's first section.
1762    #[test]
1763    fn locate_snippet_ignores_literal_citation_brackets() {
1764        let body = "## Intro\nsee [12] and [34] there\n## Results\nthe [mechanism] holds\n";
1765        let snippet = "…see [12] and [34] there\n## Results\nthe [mechanism] holds…";
1766        let at = locate_snippet(snippet, body, "\"mechanism\"").unwrap();
1767        assert_eq!(&body[at..at + "mechanism".len()], "mechanism");
1768        assert_eq!(
1769            crate::domain::anchor::resolve(body, at).section.as_deref(),
1770            Some("Results")
1771        );
1772    }
1773
1774    /// Every prefix `normalize_doi` strips has to match on the stored side too.
1775    /// Normalizing only the incoming DOI lets the un-stripped forms sit in the
1776    /// table as permanent duplicates.
1777    #[test]
1778    fn find_paper_by_doi_matches_every_normalized_prefix() {
1779        for stored in [
1780            "https://doi.org/10.1038/nature12373",
1781            "http://doi.org/10.1038/nature12373",
1782            "http://dx.doi.org/10.1038/nature12373",
1783            "https://dx.doi.org/10.1038/nature12373",
1784            "doi:10.1038/nature12373",
1785            "10.1038/NATURE12373",
1786        ] {
1787            let store = test_store();
1788            let mut paper = Paper::new("stored form".to_string());
1789            paper.doi = Some(stored.to_string());
1790            store.insert_paper(&paper).unwrap();
1791            assert!(
1792                store
1793                    .find_paper_by_doi("10.1038/nature12373")
1794                    .unwrap()
1795                    .is_some(),
1796                "stored form {stored} did not match a normalized probe"
1797            );
1798        }
1799    }
1800
1801    /// Scoping to one paper must happen in the query. Filtering a whole-library
1802    /// result set afterwards loses the target paper's matches whenever other
1803    /// papers fill the limit first.
1804    #[test]
1805    fn body_evidence_scoped_to_paper_survives_a_crowded_library() {
1806        let store = test_store();
1807        // Many papers match the same term; the one we want is inserted last so
1808        // a whole-library search with a small limit would not reach it.
1809        for i in 0..10 {
1810            let noise = Paper::new(format!("noise {i}"));
1811            store.insert_paper(&noise).unwrap();
1812            store
1813                .set_paper_body(&noise.id, "## Intro\nshared keyword here")
1814                .unwrap();
1815        }
1816        let target = Paper::new("target".into());
1817        store.insert_paper(&target).unwrap();
1818        store
1819            .set_paper_body(&target.id, "## Methods\nshared keyword here too")
1820            .unwrap();
1821
1822        let scoped = store
1823            .search_body_evidence("keyword", Some(&target.id), 3)
1824            .unwrap();
1825        assert_eq!(scoped.len(), 1);
1826        assert_eq!(scoped[0].paper_id, target.id);
1827        assert_eq!(scoped[0].anchor.section.as_deref(), Some("Methods"));
1828
1829        // Unscoped still searches everything.
1830        let all = store.search_body_evidence("keyword", None, 20).unwrap();
1831        assert_eq!(all.len(), 11);
1832    }
1833
1834    /// A limit smaller than the match count must keep the *best* matches, not
1835    /// whichever rows SQLite happened to emit first. Without `ORDER BY rank`
1836    /// the survivors are unspecified row order and the strongest evidence can
1837    /// be dropped silently.
1838    #[test]
1839    fn body_evidence_returns_the_best_matches_under_a_limit() {
1840        let store = test_store();
1841        // Weak matches are inserted first so unordered row order would favour
1842        // them; the dense match is inserted last.
1843        for i in 0..8 {
1844            let weak = Paper::new(format!("weak {i}"));
1845            store.insert_paper(&weak).unwrap();
1846            store
1847                .set_paper_body(&weak.id, "## Intro\nphotonic mentioned once here")
1848                .unwrap();
1849        }
1850        let strong = Paper::new("strong".into());
1851        store.insert_paper(&strong).unwrap();
1852        store
1853            .set_paper_body(
1854                &strong.id,
1855                "## Methods\nphotonic photonic photonic photonic photonic lattice",
1856            )
1857            .unwrap();
1858
1859        let top = store.search_body_evidence("photonic", None, 1).unwrap();
1860        assert_eq!(top.len(), 1);
1861        assert_eq!(
1862            top[0].paper_id, strong.id,
1863            "limit kept an arbitrary row instead of the best-ranked match"
1864        );
1865    }
1866
1867    /// The exact scenario an audit reproduced: a term that also occurs inside
1868    /// an earlier heading. Anchoring on the term's first occurrence reported
1869    /// the wrong page and a section name truncated mid-word ("Introduct").
1870    #[test]
1871    fn body_evidence_anchors_the_matched_occurrence_not_the_first() {
1872        let store = test_store();
1873        let paper = Paper::new("ion beam".into());
1874        store.insert_paper(&paper).unwrap();
1875        store
1876            .set_paper_body(
1877                &paper.id,
1878                "<!-- page 1 -->\n## Introduction\nbackground material\n<!-- page 3 -->\n## Results\nthe ion beam produced clean output\n",
1879            )
1880            .unwrap();
1881
1882        let hits = store.search_body_evidence("ion beam", None, 10).unwrap();
1883        assert_eq!(hits.len(), 1);
1884        // "ion" also lives inside "Introduction" on page 1; the trigram
1885        // tokenizer matches inside words, so this is a real collision.
1886        assert_eq!(hits[0].anchor.section.as_deref(), Some("Results"));
1887        assert_eq!(hits[0].anchor.page, Some(3));
1888    }
1889
1890    /// DOI matching is normalization-insensitive on both sides: papers stored
1891    /// by an earlier ingest keep whatever form upstream sent, and must still
1892    /// dedupe against a normalized DOI arriving from an import.
1893    #[test]
1894    fn find_paper_by_doi_matches_across_stored_forms() {
1895        let store = test_store();
1896        let mut raw = Paper::new("stored raw".into());
1897        raw.doi = Some("https://doi.org/10.1038/NATURE12373".into());
1898        store.insert_paper(&raw).unwrap();
1899
1900        for probe in [
1901            "10.1038/nature12373",
1902            "10.1038/NATURE12373",
1903            "https://doi.org/10.1038/nature12373",
1904            "  doi:10.1038/Nature12373 ",
1905        ] {
1906            assert_eq!(
1907                store.find_paper_by_doi(probe).unwrap().map(|p| p.id),
1908                Some(raw.id.clone()),
1909                "probe {probe:?} should match the stored paper"
1910            );
1911        }
1912        assert!(store.find_paper_by_doi("10.1/other").unwrap().is_none());
1913    }
1914}