Skip to main content

sioul_core/
papers.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The papers wallet (docs/papers.md): the papers asked again and again (an
5//! identity card, the last tax notice, bank details, rent receipts, an
6//! attestation), each with its file and how long it holds; a reminder before
7//! one ends, early enough to renew it (a passport takes weeks, the
8//! complémentaire santé solidaire does not renew by itself).
9//!
10//! `sioul-papers.toml` at the root of the notes folder, the files in its
11//! `papers/` folder: they travel with your projects and notes.
12
13use 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/// What a paper is.
22#[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    /// "identity", "health-cover": its name in the file and in the strings (`paper-kind-<id>`).
61    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    /// Where it is shown: identity, health, home, money, warranties, others (`paper-family-<id>`).
85    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    /// How many days before it ends renewing starts: weeks of waiting for an
97    /// appointment and the making of a passport or a card; a residence permit
98    /// asked from four months before; the complémentaire santé solidaire two to
99    /// four months before, as it does not renew by itself. None: a newer one comes on its own.
100    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    /// Asked "less than three months old" when sent (rent receipts, payslips,
111    /// attestations); a tax notice is the last one, about a year.
112    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    /// How long it usually holds from its issue, in years, to propose an end:
121    /// a French passport or identity card for an adult, ten years (cards made
122    /// from 2021); the legal guarantee of conformity, two years from delivery.
123    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    /// The kind a file's name or a mail's subject suggests: "avis_impot_2026.pdf"
132    /// → a tax notice, by the words of each kind (`words::PaperKinds`), the
133    /// kinds tried in this order.
134    pub fn guess(words: &crate::words::Words, text: &str) -> Option<Kind> {
135        // Folded, punctuation as spaces: "Avis_d'impôt-2026.pdf" → "avis d impot 2026 pdf".
136        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/// One paper.
174#[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    /// Its file: in the notes folder (`papers/…`), or anywhere.
181    #[serde(default)]
182    pub file: String,
183    #[serde(default, deserialize_with = "crate::budget::dates::optional")]
184    pub issued: Option<Date>,
185    /// The last day it holds.
186    #[serde(default, deserialize_with = "crate::budget::dates::optional")]
187    pub until: Option<Date>,
188    /// Whose, in a household.
189    #[serde(default)]
190    pub holder: String,
191    #[serde(default)]
192    pub notes: String,
193    /// The task made to renew it, by UID.
194    #[serde(default)]
195    pub renewal: String,
196    /// When it entered the wallet: one added after its renewal began is not reminded, you just saw it.
197    #[serde(default, deserialize_with = "crate::budget::dates::optional")]
198    pub added: Option<Date>,
199}
200
201/// Where a paper stands, today.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
203#[serde(rename_all = "lowercase", tag = "is", content = "date")]
204pub enum Standing {
205    /// Holds until then.
206    Valid(Date),
207    /// Holds until then, and renewing starts now.
208    Renew(Date),
209    /// Ended then.
210    Ended(Date),
211    /// Recent enough to send: issued then.
212    Fresh(Date),
213    /// Older than what is usually asked: issued then.
214    Old(Date),
215    /// Without a date that matters.
216    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    /// When renewing starts, if it is renewed.
236    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
242/// A file name from elsewhere (an attachment's), as every system takes it:
243/// the folder travels to Windows and macOS too. Its last part only, back
244/// slashes included; what Windows refuses (":" would even write into a
245/// hidden stream) as "-"; no final dot or space; a device name ("CON",
246/// "NUL"…) with a "_" before it; "paper" when nothing is left.
247fn 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
259/// An end proposed from the issue, when the kind's usual length is known.
260pub fn usual_end(kind: Kind, issued: Date) -> Option<Date> {
261    issued.checked_add(Span::new().years(kind.years()?)).ok()?.yesterday().ok()
262}
263
264/// The papers of a notes folder.
265#[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    /// Read, changed by `change` and written back, all under the file's lock,
279    /// which the sharing takes too: a change is set over the file as it is
280    /// then, never over a copy read earlier. Returns what `change` returns.
281    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    /// The wallet at the root of a notes folder; empty when there is none yet.
291    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    /// A paper's file, where it is on this computer.
306    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    /// A paper added or changed (by id; a new one gets an id from its title); returns its id.
315    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    /// A paper taken out of the wallet; its file stays where it is.
329    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    /// A file copied into the wallet's folder, under a free name; its path from the notes folder.
336    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    /// Written whole, next to its place then moved: never half a file. Under the file's lock.
353    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        // An attachment's name from elsewhere: its last part, as every system takes it.
443        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}