1use jiff::civil::Date;
14use jiff::{Span, ToSpan};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18pub const MANIFEST: &str = "sioul-papers.toml";
19pub const FOLDER: &str = "papers";
20
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum Kind {
25 Identity,
26 Passport,
27 Residence,
28 Driving,
29 HealthCard,
30 HealthCover,
31 Insurance,
32 TaxNotice,
33 RentReceipt,
34 BankDetails,
35 Payslip,
36 Certificate,
37 Warranty,
38 #[default]
39 Other,
40}
41
42impl Kind {
43 pub const ALL: [Kind; 14] = [
44 Kind::Identity,
45 Kind::Passport,
46 Kind::Residence,
47 Kind::Driving,
48 Kind::HealthCard,
49 Kind::HealthCover,
50 Kind::Insurance,
51 Kind::TaxNotice,
52 Kind::RentReceipt,
53 Kind::BankDetails,
54 Kind::Payslip,
55 Kind::Certificate,
56 Kind::Warranty,
57 Kind::Other,
58 ];
59
60 pub fn id(self) -> &'static str {
62 match self {
63 Kind::Identity => "identity",
64 Kind::Passport => "passport",
65 Kind::Residence => "residence",
66 Kind::Driving => "driving",
67 Kind::HealthCard => "health-card",
68 Kind::HealthCover => "health-cover",
69 Kind::Insurance => "insurance",
70 Kind::TaxNotice => "tax-notice",
71 Kind::RentReceipt => "rent-receipt",
72 Kind::BankDetails => "bank-details",
73 Kind::Payslip => "payslip",
74 Kind::Certificate => "certificate",
75 Kind::Warranty => "warranty",
76 Kind::Other => "other",
77 }
78 }
79
80 pub fn of(id: &str) -> Kind {
81 Kind::ALL.into_iter().find(|k| k.id() == id).unwrap_or_default()
82 }
83
84 pub fn family(self) -> &'static str {
86 match self {
87 Kind::Identity | Kind::Passport | Kind::Residence | Kind::Driving => "identity",
88 Kind::HealthCard | Kind::HealthCover => "health",
89 Kind::Insurance | Kind::RentReceipt => "home",
90 Kind::TaxNotice | Kind::BankDetails | Kind::Payslip => "money",
91 Kind::Warranty => "warranty",
92 Kind::Certificate | Kind::Other => "other",
93 }
94 }
95
96 pub fn lead_days(self) -> Option<i64> {
101 match self {
102 Kind::Identity | Kind::Passport | Kind::HealthCover => Some(90),
103 Kind::Residence => Some(120),
104 Kind::Driving => Some(60),
105 Kind::Insurance | Kind::Warranty => Some(30),
106 _ => None,
107 }
108 }
109
110 pub fn fresh_days(self) -> Option<i64> {
113 match self {
114 Kind::RentReceipt | Kind::Payslip | Kind::Certificate => Some(92),
115 Kind::TaxNotice => Some(400),
116 _ => None,
117 }
118 }
119
120 pub fn years(self) -> Option<i16> {
124 match self {
125 Kind::Identity | Kind::Passport => Some(10),
126 Kind::Warranty => Some(2),
127 _ => None,
128 }
129 }
130
131 pub fn guess(words: &crate::words::Words, text: &str) -> Option<Kind> {
135 let plain = |text: &str| crate::text::fold(text).into_iter().map(|c| if c.is_alphanumeric() { c } else { ' ' }).collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ");
137 let padded = format!(" {} ", plain(text));
138 let has = |list: &[String]| list.iter().map(|w| plain(w)).any(|w| !w.is_empty() && padded.contains(&format!(" {w} ")));
139 let k = &words.papers.kinds;
140 let kind = if has(&k.passport) {
141 Kind::Passport
142 } else if has(&k.identity) {
143 Kind::Identity
144 } else if has(&k.residence) {
145 Kind::Residence
146 } else if has(&k.driving) {
147 Kind::Driving
148 } else if has(&k.health_cover) {
149 Kind::HealthCover
150 } else if has(&k.health_card) {
151 Kind::HealthCard
152 } else if has(&k.tax_notice) {
153 Kind::TaxNotice
154 } else if has(&k.rent_receipt) {
155 Kind::RentReceipt
156 } else if has(&k.bank_details) {
157 Kind::BankDetails
158 } else if has(&k.payslip) {
159 Kind::Payslip
160 } else if has(&k.warranty) {
161 Kind::Warranty
162 } else if has(&k.insurance) {
163 Kind::Insurance
164 } else if has(&k.certificate) {
165 Kind::Certificate
166 } else {
167 return None;
168 };
169 Some(kind)
170 }
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
175pub struct Paper {
176 pub id: String,
177 #[serde(default)]
178 pub kind: Kind,
179 pub title: String,
180 #[serde(default)]
182 pub file: String,
183 #[serde(default, deserialize_with = "crate::budget::dates::optional")]
184 pub issued: Option<Date>,
185 #[serde(default, deserialize_with = "crate::budget::dates::optional")]
187 pub until: Option<Date>,
188 #[serde(default)]
190 pub holder: String,
191 #[serde(default)]
192 pub notes: String,
193 #[serde(default)]
195 pub renewal: String,
196 #[serde(default, deserialize_with = "crate::budget::dates::optional")]
198 pub added: Option<Date>,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
203#[serde(rename_all = "lowercase", tag = "is", content = "date")]
204pub enum Standing {
205 Valid(Date),
207 Renew(Date),
209 Ended(Date),
211 Fresh(Date),
213 Old(Date),
215 Undated,
217}
218
219impl Paper {
220 pub fn standing(&self, today: Date) -> Standing {
221 if let Some(until) = self.until {
222 if until < today {
223 return Standing::Ended(until);
224 }
225 let starts = self.kind.lead_days().and_then(|d| until.checked_sub(d.days()).ok());
226 return if starts.is_some_and(|s| s <= today) { Standing::Renew(until) } else { Standing::Valid(until) };
227 }
228 match (self.issued, self.kind.fresh_days()) {
229 (Some(issued), Some(days)) if issued.checked_add(days.days()).is_ok_and(|limit| limit < today) => Standing::Old(issued),
230 (Some(issued), Some(_)) => Standing::Fresh(issued),
231 _ => Standing::Undated,
232 }
233 }
234
235 pub fn renew_from(&self) -> Option<Date> {
237 let until = self.until?;
238 until.checked_sub(Span::new().days(self.kind.lead_days()?)).ok()
239 }
240}
241
242fn portable_name(name: &str) -> String {
248 let last = name.rsplit(['/', '\\']).next().unwrap_or("");
249 let clean: String = last.chars().map(|c| if c.is_control() || matches!(c, ':' | '*' | '?' | '"' | '<' | '>' | '|') { '-' } else { c }).collect();
250 let clean = clean.trim().trim_end_matches(['.', ' ']).to_string();
251 if clean.is_empty() {
252 return "paper".into();
253 }
254 let stem = clean.split('.').next().unwrap_or("").trim().to_ascii_uppercase();
255 let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || (stem.len() == 4 && (stem.starts_with("COM") || stem.starts_with("LPT")) && stem.as_bytes()[3].is_ascii_digit());
256 if device { format!("_{clean}") } else { clean }
257}
258
259pub fn usual_end(kind: Kind, issued: Date) -> Option<Date> {
261 issued.checked_add(Span::new().years(kind.years()?)).ok()?.yesterday().ok()
262}
263
264#[derive(Debug, Clone, Default)]
266pub struct Wallet {
267 pub root: PathBuf,
268 pub papers: Vec<Paper>,
269}
270
271#[derive(Deserialize, Default)]
272struct File {
273 #[serde(default, rename = "paper")]
274 papers: Vec<Paper>,
275}
276
277impl Wallet {
278 pub fn change<R>(root: &Path, change: impl FnOnce(&mut Wallet) -> R) -> Result<R, String> {
282 crate::filelock::with_lock(&root.join(MANIFEST), || {
283 let mut wallet = Wallet::load(root)?;
284 let out = change(&mut wallet);
285 wallet.save()?;
286 Ok(out)
287 })
288 }
289
290 pub fn load(root: &Path) -> Result<Wallet, String> {
292 let path = root.join(MANIFEST);
293 let papers = match std::fs::read_to_string(&path) {
294 Ok(text) => toml::from_str::<File>(&text).map_err(|e| format!("{}: {e}", path.display()))?.papers,
295 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
296 Err(e) => return Err(format!("{}: {e}", path.display())),
297 };
298 Ok(Wallet { root: root.to_path_buf(), papers })
299 }
300
301 pub fn get(&self, id: &str) -> Option<&Paper> {
302 self.papers.iter().find(|p| p.id == id)
303 }
304
305 pub fn file_path(&self, paper: &Paper) -> Option<PathBuf> {
307 if paper.file.is_empty() {
308 return None;
309 }
310 let path = Path::new(&paper.file);
311 Some(if path.is_absolute() { path.to_path_buf() } else { self.root.join(path) })
312 }
313
314 pub fn put(&mut self, mut paper: Paper) -> String {
316 if paper.id.is_empty() {
317 let taken: Vec<String> = self.papers.iter().map(|p| p.id.clone()).collect();
318 paper.id = crate::projects::new_id(&paper.title, &taken);
319 }
320 let id = paper.id.clone();
321 match self.papers.iter_mut().find(|p| p.id == id) {
322 Some(place) => *place = paper,
323 None => self.papers.push(paper),
324 }
325 id
326 }
327
328 pub fn remove(&mut self, id: &str) -> bool {
330 let before = self.papers.len();
331 self.papers.retain(|p| p.id != id);
332 self.papers.len() != before
333 }
334
335 pub fn keep_file(&self, source: &Path, name: &str) -> Result<String, String> {
337 let folder = self.root.join(FOLDER);
338 std::fs::create_dir_all(&folder).map_err(|e| format!("{}: {e}", folder.display()))?;
339 if source.starts_with(&folder) {
340 return Ok(source.strip_prefix(&self.root).unwrap_or(source).to_string_lossy().replace('\\', "/"));
341 }
342 let name = portable_name(name);
343 let (stem, extension) = match name.rsplit_once('.') {
344 Some((stem, extension)) => (stem.to_string(), format!(".{extension}")),
345 None => (name.clone(), String::new()),
346 };
347 let target = std::iter::once(folder.join(&name)).chain((2..1000).map(|n| folder.join(format!("{stem}-{n}{extension}")))).find(|p| !p.exists()).ok_or("no free name")?;
348 std::fs::copy(source, &target).map_err(|e| format!("{}: {e}", target.display()))?;
349 Ok(format!("{FOLDER}/{}", target.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()))
350 }
351
352 pub fn save(&self) -> Result<(), String> {
354 crate::filelock::with_lock(&self.root.join(MANIFEST), || self.write())
355 }
356
357 fn write(&self) -> Result<(), String> {
358 let mut doc = toml_edit::DocumentMut::new();
359 doc.decor_mut().set_prefix("# Your papers: what each is, its file, how long it holds (docs/papers.md).\n\n");
360 let mut list = toml_edit::ArrayOfTables::new();
361 let date = |d: Date| toml_edit::value(toml_edit::Datetime { date: Some(toml_edit::Date { year: d.year() as u16, month: d.month() as u8, day: d.day() as u8 }), time: None, offset: None });
362 for paper in &self.papers {
363 let mut table = toml_edit::Table::new();
364 table["id"] = toml_edit::value(&paper.id);
365 table["kind"] = toml_edit::value(paper.kind.id());
366 table["title"] = toml_edit::value(&paper.title);
367 for (name, text) in [("file", &paper.file), ("holder", &paper.holder), ("notes", &paper.notes), ("renewal", &paper.renewal)] {
368 if !text.is_empty() {
369 table[name] = toml_edit::value(text);
370 }
371 }
372 if let Some(d) = paper.issued {
373 table["issued"] = date(d);
374 }
375 if let Some(d) = paper.until {
376 table["until"] = date(d);
377 }
378 if let Some(d) = paper.added {
379 table["added"] = date(d);
380 }
381 list.push(table);
382 }
383 doc.insert("paper", toml_edit::Item::ArrayOfTables(list));
384 let path = self.root.join(MANIFEST);
385 let temporary = self.root.join(format!("{MANIFEST}.new"));
386 std::fs::write(&temporary, doc.to_string()).and_then(|()| std::fs::rename(&temporary, &path)).map_err(|e| format!("{}: {e}", path.display()))
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn day(text: &str) -> Date {
395 text.parse().unwrap()
396 }
397
398 #[test]
399 fn where_each_stands() {
400 let today = day("2026-10-03");
401 let passport = Paper { kind: Kind::Passport, until: Some(day("2026-12-20")), ..Paper::default() };
402 assert_eq!(passport.standing(today), Standing::Renew(day("2026-12-20")), "within its three months");
403 assert_eq!(passport.renew_from(), Some(day("2026-09-21")));
404 let later = Paper { until: Some(day("2027-06-01")), ..passport.clone() };
405 assert_eq!(later.standing(today), Standing::Valid(day("2027-06-01")));
406 let ended = Paper { until: Some(day("2026-09-30")), ..passport };
407 assert_eq!(ended.standing(today), Standing::Ended(day("2026-09-30")));
408 let receipt = Paper { kind: Kind::RentReceipt, issued: Some(day("2026-06-01")), ..Paper::default() };
409 assert_eq!(receipt.standing(today), Standing::Old(day("2026-06-01")));
410 let fresh = Paper { issued: Some(day("2026-09-01")), ..receipt };
411 assert_eq!(fresh.standing(today), Standing::Fresh(day("2026-09-01")));
412 assert_eq!(Paper { kind: Kind::BankDetails, ..Paper::default() }.standing(today), Standing::Undated);
413 assert_eq!(usual_end(Kind::Passport, day("2017-03-12")), Some(day("2027-03-11")));
414 assert_eq!(usual_end(Kind::Warranty, day("2026-10-03")), Some(day("2028-10-02")));
415 }
416
417 #[test]
418 fn guessed_from_names() {
419 assert_eq!(Kind::guess(&crate::words::Words::builtin(), "avis_impot_2026.pdf"), Some(Kind::TaxNotice));
420 assert_eq!(Kind::guess(&crate::words::Words::builtin(), "Votre quittance de loyer — septembre"), Some(Kind::RentReceipt));
421 assert_eq!(Kind::guess(&crate::words::Words::builtin(), "RIB Ma Banque.pdf"), Some(Kind::BankDetails));
422 assert_eq!(Kind::guess(&crate::words::Words::builtin(), "Attestation de droits à l'Assurance Maladie"), Some(Kind::HealthCard));
423 assert_eq!(Kind::guess(&crate::words::Words::builtin(), "Passeport.jpg"), Some(Kind::Passport));
424 assert_eq!(Kind::guess(&crate::words::Words::builtin(), "holiday.jpg"), None);
425 assert_eq!(Kind::guess(&crate::words::Words::builtin(), "Carte d'identité recto.jpg"), Some(Kind::Identity));
426 assert_eq!(Kind::guess(&crate::words::Words::builtin(), "Votre avis d’impôt 2026 est disponible"), Some(Kind::TaxNotice));
427 assert_eq!(Kind::guess(&crate::words::Words::builtin(), "cassis.pdf"), None, "a word inside another is not it: css");
428 }
429
430 #[test]
431 fn kept_and_read_again() {
432 let root = std::env::temp_dir().join(format!("sioul-papers-{}", std::process::id()));
433 let _ = std::fs::remove_dir_all(&root);
434 std::fs::create_dir_all(&root).unwrap();
435 let scan = root.join("scan.pdf");
436 std::fs::write(&scan, b"%PDF-1.4").unwrap();
437 let mut wallet = Wallet::load(&root).unwrap();
438 assert!(wallet.papers.is_empty());
439 let file = wallet.keep_file(&scan, "Passeport.pdf").unwrap();
440 assert_eq!(file, "papers/Passeport.pdf");
441 assert_eq!(wallet.keep_file(&scan, "Passeport.pdf").unwrap(), "papers/Passeport-2.pdf", "a free name");
442 assert_eq!(wallet.keep_file(&scan, "..\\..\\avis:2026?.pdf").unwrap(), "papers/avis-2026-.pdf");
444 assert_eq!(wallet.keep_file(&scan, "CON.pdf").unwrap(), "papers/_CON.pdf");
445 assert_eq!(wallet.keep_file(&scan, "..").unwrap(), "papers/paper");
446 let id = wallet.put(Paper { kind: Kind::Passport, title: "Passeport".into(), file, until: Some(day("2027-03-11")), ..Paper::default() });
447 assert_eq!(id, "passeport");
448 wallet.save().unwrap();
449 let again = Wallet::load(&root).unwrap();
450 assert_eq!(again.papers, wallet.papers);
451 assert!(std::fs::read_to_string(root.join(MANIFEST)).unwrap().contains("until = 2027-03-11"));
452 assert!(again.file_path(&again.papers[0]).unwrap().is_file());
453 let mut again = again;
454 assert!(again.remove("passeport") && again.papers.is_empty());
455 let _ = std::fs::remove_dir_all(&root);
456 }
457}