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#[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 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 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 #[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 pub rating: Option<Rating>,
126 #[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
166pub 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 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}