Skip to main content

research_agent/domain/
paper.rs

1use crate::error::{ResearchError, Result};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5#[serde(rename_all = "snake_case")]
6pub enum PaperStatus {
7    Discovered,
8    AbstractRead,
9    Skimmed,
10    Read,
11    DeepRead,
12}
13
14impl PaperStatus {
15    pub fn as_str(&self) -> &'static str {
16        match self {
17            Self::Discovered => "discovered",
18            Self::AbstractRead => "abstract_read",
19            Self::Skimmed => "skimmed",
20            Self::Read => "read",
21            Self::DeepRead => "deep_read",
22        }
23    }
24
25    pub fn from_str_lossy(s: &str) -> Self {
26        match s {
27            "abstract_read" => Self::AbstractRead,
28            "skimmed" => Self::Skimmed,
29            "read" => Self::Read,
30            "deep_read" => Self::DeepRead,
31            _ => Self::Discovered,
32        }
33    }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37#[serde(rename_all = "snake_case")]
38pub enum ReadingStatus {
39    Unread,
40    Queued,
41    InProgress,
42    Completed,
43    Abandoned,
44}
45
46impl ReadingStatus {
47    pub fn as_str(&self) -> &'static str {
48        match self {
49            Self::Unread => "unread",
50            Self::Queued => "queued",
51            Self::InProgress => "in_progress",
52            Self::Completed => "completed",
53            Self::Abandoned => "abandoned",
54        }
55    }
56
57    pub fn from_str_lossy(s: &str) -> Self {
58        match s {
59            "queued" => Self::Queued,
60            "in_progress" => Self::InProgress,
61            "completed" => Self::Completed,
62            "abandoned" => Self::Abandoned,
63            _ => Self::Unread,
64        }
65    }
66}
67
68/// User rating on the 1–5 scale. The constructor and the serde path both
69/// enforce bounds, so a `Rating` value is always valid — callers and
70/// deserialized data cannot bypass validation.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(transparent)]
73pub struct Rating(u8);
74
75impl<'de> Deserialize<'de> for Rating {
76    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
77    where
78        D: serde::Deserializer<'de>,
79    {
80        let v = u8::deserialize(deserializer)?;
81        Rating::new(v).map_err(serde::de::Error::custom)
82    }
83}
84
85impl Rating {
86    /// Create a rating, rejecting anything outside 1..=5.
87    pub fn new(value: u8) -> Result<Self> {
88        if (1..=5).contains(&value) {
89            Ok(Self(value))
90        } else {
91            Err(ResearchError::Validation(format!(
92                "rating must be between 1 and 5, got {value}"
93            )))
94        }
95    }
96
97    /// The underlying 1–5 value.
98    pub fn get(self) -> u8 {
99        self.0
100    }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Paper {
105    pub id: String,
106    pub title: String,
107    pub authors: Vec<String>,
108    pub abstract_text: String,
109    pub year: Option<u32>,
110    pub venue: Option<String>,
111    pub doi: Option<String>,
112    pub arxiv_id: Option<String>,
113    pub s2_id: Option<String>,
114    /// OpenAlex work id (`W…`), set by the OpenAlex source.
115    #[serde(default)]
116    pub openalex_id: Option<String>,
117    pub url: Option<String>,
118    pub pdf_path: Option<String>,
119    pub status: PaperStatus,
120    pub notes: String,
121    pub tags: Vec<String>,
122    pub relevance_score: f32,
123    pub reading_status: ReadingStatus,
124    /// User rating 1–5, None if not yet rated.
125    pub rating: Option<Rating>,
126    /// Search-only keywords generated from the title and abstract (by the
127    /// configured LLM, or by a host agent through `research enrich`).
128    /// Indexed by FTS5 so a paraphrased query can reach a paper whose abstract
129    /// never uses the query's wording. Deliberately separate from `tags`,
130    /// which are the user's own and get pushed to Zotero.
131    #[serde(default)]
132    pub keywords: String,
133    pub created_at: String,
134    pub updated_at: String,
135}
136
137impl Paper {
138    pub fn new(title: String) -> Self {
139        let now = chrono::Utc::now().to_rfc3339();
140        Self {
141            id: uuid::Uuid::new_v4().to_string(),
142            title,
143            authors: Vec::new(),
144            abstract_text: String::new(),
145            year: None,
146            venue: None,
147            doi: None,
148            arxiv_id: None,
149            s2_id: None,
150            openalex_id: None,
151            url: None,
152            pdf_path: None,
153            status: PaperStatus::Discovered,
154            notes: String::new(),
155            tags: Vec::new(),
156            relevance_score: 0.5,
157            reading_status: ReadingStatus::Unread,
158            rating: None,
159            keywords: String::new(),
160            created_at: now.clone(),
161            updated_at: now,
162        }
163    }
164}
165
166/// Normalize a title into the form used for identity matching: punctuation
167/// dropped (letters and digits kept, so hyphenated words and CJK titles
168/// survive), lowercased, whitespace runs collapsed to one space. Dropping
169/// punctuation catches the same paper exported with a trailing period by one
170/// source and not the other. This is the fallback key for papers that carry
171/// neither a DOI nor a pdf_path, so the stored side must be compared through
172/// the same function (the store cannot express this collapse in SQL, which
173/// is why the lookup scans and compares here). Two genuinely different
174/// papers sharing one normalized title collapse to one row — accepted for a
175/// personal library.
176pub fn normalize_title(title: &str) -> String {
177    let stripped: String = title
178        .chars()
179        .filter(|c| c.is_alphanumeric() || c.is_whitespace())
180        .collect();
181    stripped
182        .split_whitespace()
183        .collect::<Vec<_>>()
184        .join(" ")
185        .to_lowercase()
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn paper_status_roundtrip() {
194        let statuses = [
195            PaperStatus::Discovered,
196            PaperStatus::AbstractRead,
197            PaperStatus::Skimmed,
198            PaperStatus::Read,
199            PaperStatus::DeepRead,
200        ];
201        for s in &statuses {
202            assert_eq!(PaperStatus::from_str_lossy(s.as_str()), *s);
203        }
204    }
205
206    #[test]
207    fn reading_status_roundtrip() {
208        let statuses = [
209            ReadingStatus::Unread,
210            ReadingStatus::Queued,
211            ReadingStatus::InProgress,
212            ReadingStatus::Completed,
213            ReadingStatus::Abandoned,
214        ];
215        for s in &statuses {
216            assert_eq!(ReadingStatus::from_str_lossy(s.as_str()), *s);
217        }
218    }
219
220    #[test]
221    fn rating_accepts_valid_range() {
222        for v in [1u8, 2, 3, 4, 5] {
223            assert_eq!(Rating::new(v).unwrap().get(), v);
224        }
225    }
226
227    #[test]
228    fn rating_rejects_out_of_range() {
229        assert!(Rating::new(0).is_err());
230        assert!(Rating::new(6).is_err());
231        assert!(Rating::new(255).is_err());
232    }
233
234    #[test]
235    fn rating_deserialize_enforces_bounds() {
236        // serde must not bypass Rating::new — out-of-range values error.
237        assert!(serde_json::from_str::<Rating>("0").is_err());
238        assert!(serde_json::from_str::<Rating>("6").is_err());
239        assert!(serde_json::from_str::<Rating>("99").is_err());
240        assert_eq!(serde_json::from_str::<Rating>("3").unwrap().get(), 3);
241    }
242
243    #[test]
244    fn paper_new_generates_id_and_timestamps() {
245        let p = Paper::new("Test Paper".into());
246        assert_eq!(p.title, "Test Paper");
247        assert!(!p.id.is_empty());
248        assert!(!p.created_at.is_empty());
249        assert_eq!(p.status, PaperStatus::Discovered);
250        assert_eq!(p.reading_status, ReadingStatus::Unread);
251    }
252
253    #[test]
254    fn paper_serialization_roundtrip() {
255        let p = Paper::new("Serialization Test".into());
256        let json = serde_json::to_string(&p).unwrap();
257        let back: Paper = serde_json::from_str(&json).unwrap();
258        assert_eq!(back.title, p.title);
259        assert_eq!(back.id, p.id);
260    }
261}