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
19fn 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
33fn locate_snippet(snippet: &str, body: &str, query_terms: &str) -> Option<usize> {
48 let core = snippet.strip_prefix('…').unwrap_or(snippet);
54 let core = core.strip_suffix('…').unwrap_or(core);
55 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 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 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 let lead = match_at
91 .map(|b| core[..b].chars().filter(|c| *c != '[' && *c != ']').count())
92 .unwrap_or(0);
93 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 let lower_stripped = stripped.to_lowercase();
105 let pos = lower_stripped.find(&plain.to_lowercase())?;
106 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 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 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 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 let needle = crate::adapters::bib_importer::normalize_doi(doi);
255 let Some(needle) = needle else {
256 return Ok(None);
257 };
258 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 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 let keywords: String = keywords.chars().take(512).collect();
370 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 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 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 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 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(>),
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(>),
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let store = test_store();
1558 let topic = ResearchTopic::new("T".into());
1559 store.insert_topic(&topic).unwrap();
1560
1561 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 let store = test_store();
1594 {
1595 let conn = store.conn.lock().unwrap();
1596 conn.execute_batch(
1597 "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 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 let store = test_store();
1658 {
1659 let conn = store.conn.lock().unwrap();
1660 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 #[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 #[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 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 #[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 #[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 #[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 #[test]
1805 fn body_evidence_scoped_to_paper_survives_a_crowded_library() {
1806 let store = test_store();
1807 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 let all = store.search_body_evidence("keyword", None, 20).unwrap();
1831 assert_eq!(all.len(), 11);
1832 }
1833
1834 #[test]
1839 fn body_evidence_returns_the_best_matches_under_a_limit() {
1840 let store = test_store();
1841 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 #[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 assert_eq!(hits[0].anchor.section.as_deref(), Some("Results"));
1887 assert_eq!(hits[0].anchor.page, Some(3));
1888 }
1889
1890 #[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}