1use crate::money::Money;
22use crate::words::{LetterWords, Words};
23use jiff::ToSpan;
24use jiff::civil::Date;
25use serde::Serialize;
26use std::path::{Path, PathBuf};
27
28pub const FOLDER: &str = "letters";
29pub const MANIFEST: &str = "letters.toml";
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum Kind {
35 FormalNotice,
37 Decision,
39 TaxNotice,
40 Reminder,
41 Bill,
42 Appointment,
43 Acknowledgment,
44 Attestation,
45 Contract,
46 #[default]
47 Other,
48}
49
50impl Kind {
51 pub fn id(self) -> &'static str {
52 match self {
53 Kind::FormalNotice => "formal-notice",
54 Kind::Decision => "decision",
55 Kind::TaxNotice => "tax-notice",
56 Kind::Reminder => "reminder",
57 Kind::Bill => "bill",
58 Kind::Appointment => "appointment",
59 Kind::Acknowledgment => "acknowledgment",
60 Kind::Attestation => "attestation",
61 Kind::Contract => "contract",
62 Kind::Other => "other",
63 }
64 }
65
66 pub fn of(id: &str) -> Kind {
67 [Kind::FormalNotice, Kind::Decision, Kind::TaxNotice, Kind::Reminder, Kind::Bill, Kind::Appointment, Kind::Acknowledgment, Kind::Attestation, Kind::Contract].into_iter().find(|k| k.id() == id).unwrap_or_default()
68 }
69}
70
71fn plain(text: &str) -> String {
73 let words: String = crate::text::fold(text).into_iter().map(|c| if c.is_alphanumeric() { c } else { ' ' }).collect();
74 format!(" {} ", words.split_whitespace().collect::<Vec<_>>().join(" "))
75}
76
77fn has(text: &str, phrases: &[String]) -> bool {
79 phrases.iter().any(|p| {
80 let p = plain(p);
81 let p = p.trim();
82 !p.is_empty() && text.contains(&format!(" {p} "))
83 })
84}
85
86#[derive(Debug, Clone, Default, PartialEq, Eq)]
88pub struct Reading {
89 pub sender: String,
90 pub kind: Kind,
91 pub amount: Option<Money>,
92 pub dated: Option<Date>,
94 pub deadline: Option<Date>,
96 pub why: String,
97 pub appointment: Option<(Date, Option<(i8, i8)>)>,
99 pub registered: bool,
101 pub reference: String,
103}
104
105fn normal(list: &[String]) -> Vec<String> {
107 list.iter().map(|w| plain(w).trim().to_string()).filter(|w| !w.is_empty()).collect()
108}
109
110fn numbered(list: &crate::words::Named) -> Vec<(i64, Vec<String>)> {
112 list.iter().filter_map(|(n, names)| Some((n.trim().parse().ok()?, normal(names)))).collect()
113}
114
115fn dates(lw: &LetterWords, words: &[&str]) -> Vec<(usize, Date)> {
118 let mut out = Vec::new();
119 let months = numbered(&lw.months);
120 let ordinals = normal(&lw.ordinals);
121 let month = |w: &str| months.iter().find(|(_, names)| names.iter().any(|n| n == w)).and_then(|(m, _)| i8::try_from(*m).ok());
122 let year = |w: &str| w.parse::<i16>().ok().filter(|y| (1990..=2100).contains(y));
123 let day = |w: &str| ordinals.iter().fold(w, |w, suffix| w.trim_end_matches(suffix.as_str())).parse::<i8>().ok().filter(|d| (1..=31).contains(d));
124 let mut i = 0;
125 while i < words.len() {
126 if let (Some(d), Some(m), Some(y)) = (day(words[i]), words.get(i + 1).and_then(|w| month(w)), words.get(i + 2).and_then(|w| year(w)))
128 && let Ok(date) = Date::new(y, m, d)
129 {
130 out.push((i, date));
131 i += 3;
132 continue;
133 }
134 if let (Some(m), Some(d), Some(y)) = (month(words[i]), words.get(i + 1).and_then(|w| day(w)), words.get(i + 2).and_then(|w| year(w)))
136 && let Ok(date) = Date::new(y, m, d)
137 {
138 out.push((i, date));
139 i += 3;
140 continue;
141 }
142 if let (Some(d), Some(m), Some(y)) = (day(words[i]), words.get(i + 1).and_then(|w| w.parse::<i8>().ok()).filter(|m| (1..=12).contains(m)), words.get(i + 2))
144 && words[i].len() <= 2
145 && let Some(y) = year(y).or_else(|| y.parse::<i16>().ok().filter(|y| *y < 100 && y.to_string().len() <= 2).map(|y| 2000 + y))
146 && let Ok(date) = Date::new(y, m, d)
147 {
148 out.push((i, date));
149 i += 3;
150 continue;
151 }
152 i += 1;
153 }
154 out
155}
156
157fn number_word(numbers: &[(i64, Vec<String>)], w: &str) -> Option<i64> {
159 numbers.iter().find(|(_, names)| names.iter().any(|n| n == w)).map(|(n, _)| *n).or_else(|| w.parse().ok().filter(|n: &i64| *n > 0 && *n <= 366))
160}
161
162pub fn read(looked: &Words, text: &str, received: Date) -> Reading {
167 let lw = &looked.letters;
168 let mut reading = Reading::default();
169 let all = plain(text);
170 let words: Vec<&str> = all.split_whitespace().collect();
171 let head: String = text.lines().map(str::trim).filter(|l| !l.is_empty()).take(25).collect::<Vec<_>>().join("\n");
173 let head_plain = plain(&head);
174 reading.sender = lw.senders.iter().find(|(_, phrases)| has(&head_plain, phrases)).or_else(|| lw.senders.iter().find(|(_, phrases)| has(&all, phrases))).map(|(name, _)| name.clone()).unwrap_or_else(|| text.lines().map(str::trim).find(|l| l.chars().filter(|c| c.is_alphabetic()).count() >= 3).unwrap_or("").chars().take(60).collect());
175 let k = &lw.kinds;
177 reading.kind = if has(&all, &k.formal_notice) {
178 Kind::FormalNotice
179 } else if has(&all, &k.tax_notice) {
180 Kind::TaxNotice
181 } else if has(&all, &k.decision) && has(&all, &k.decision_appeal) {
182 Kind::Decision
183 } else if has(&all, &k.reminder) {
184 Kind::Reminder
185 } else if has(&all, &k.appointment) {
186 Kind::Appointment
187 } else if has(&all, &k.bill) {
188 Kind::Bill
189 } else if has(&all, &k.acknowledgment) {
190 Kind::Acknowledgment
191 } else if has(&all, &k.attestation) {
192 Kind::Attestation
193 } else if has(&all, &k.contract) {
194 Kind::Contract
195 } else {
196 Kind::Other
197 };
198 reading.registered = has(&all, &lw.registered) && has(&all, &lw.registered_receipt);
199 reading.amount = text
201 .lines()
202 .find(|line| has(&plain(line), &lw.asks))
203 .and_then(|line| crate::money::find_amount(&looked.money, line))
204 .or_else(|| crate::money::find_amount(&looked.money, text))
205 .map(|a| Money(a.money.cents().abs()));
206 let found = dates(lw, &words);
207 let head_words = head_plain.split_whitespace().count();
209 reading.dated = found.iter().find(|(at, _)| *at < head_words.min(80)).map(|(_, d)| *d);
210 let before: Vec<Vec<String>> = lw.deadline.iter().map(|p| plain(p).split_whitespace().map(str::to_string).collect::<Vec<_>>()).filter(|run| !run.is_empty()).collect();
212 'dates: for (at, date) in &found {
213 for words_before in &before {
214 let n = words_before.len();
215 let start = at.saturating_sub(6 + n);
217 for k in start..*at {
218 if k + n <= words.len() && words[k..k + n].iter().zip(words_before).all(|(a, b)| *a == b) && date >= &received.checked_sub(30.days()).unwrap_or(received) {
219 reading.deadline = Some(*date);
220 reading.why = words[k..(*at + 3).min(words.len())].join(" ");
221 break 'dates;
222 }
223 }
224 }
225 }
226 if reading.deadline.is_none() {
228 let numbers = numbered(&lw.numbers);
229 let (delay_words, delay_of) = (normal(&lw.delay_words), normal(&lw.delay_of));
230 let (months, days, weeks) = (normal(&lw.unit_months), normal(&lw.unit_days), normal(&lw.unit_weeks));
231 let fixed: Vec<(i64, Vec<String>)> = lw.fixed_delays.iter().filter_map(|(n, phrases)| Some((n.trim().parse().ok()?, phrases))).flat_map(|(n, phrases)| phrases.iter().map(move |p| (n, plain(p).split_whitespace().map(str::to_string).collect::<Vec<_>>()))).filter(|(_, run)| !run.is_empty()).collect();
232 let from_letter = normal(&lw.from_letter);
233 let is = |list: &[String], w: &str| list.iter().any(|x| x == w);
234 for (i, w) in words.iter().enumerate() {
235 let span = if is(&delay_words, w) && i + 3 < words.len() {
236 let (n, unit) = if is(&delay_of, words[i + 1]) { (number_word(&numbers, words[i + 2]), words.get(i + 3)) } else { (number_word(&numbers, words[i + 1]), words.get(i + 2)) };
237 match (n, unit.copied()) {
238 (Some(n), Some(unit)) if is(&months, unit) => Some(n.months()),
239 (Some(n), Some(unit)) if is(&days, unit) => Some(n.days()),
240 (Some(n), Some(unit)) if is(&weeks, unit) => Some((n * 7).days()),
241 _ => None,
242 }
243 } else {
244 fixed.iter().find(|(_, run)| words[i..].iter().zip(run).filter(|(a, b)| **a == b.as_str()).count() == run.len()).map(|(n, _)| n.days())
245 };
246 let Some(span) = span else { continue };
247 let rest = words[i..(i + 14).min(words.len())].join(" ");
249 let from_the_letter = from_letter.iter().any(|p| rest.contains(p.as_str()));
250 let base = if from_the_letter { reading.dated.unwrap_or(received) } else { received };
251 if let Ok(date) = base.checked_add(span) {
252 reading.deadline = Some(date);
253 reading.why = words[i.saturating_sub(2)..(i + 10).min(words.len())].join(" ");
254 break;
255 }
256 }
257 }
258 if reading.kind == Kind::Appointment
260 && let Some((at, date)) = found.iter().find(|(_, d)| *d >= received)
261 {
262 let at_words = normal(&lw.at);
263 let after: Vec<&str> = words[(*at + 3).min(words.len())..(*at + 9).min(words.len())].to_vec();
264 let time = after.iter().position(|w| at_words.iter().any(|a| a == w)).and_then(|k| {
265 let hour = after.get(k + 1)?.trim_end_matches('h').parse::<i8>().ok().filter(|h| (0..24).contains(h))?;
266 let minute = after.get(k + 2).filter(|w| **w == "h").and_then(|_| after.get(k + 3)).or_else(|| after.get(k + 2)).and_then(|m| m.parse::<i8>().ok()).filter(|m| (0..60).contains(m)).unwrap_or(0);
267 Some((hour, minute))
268 });
269 reading.appointment = Some((*date, time));
270 }
271 if !reading.why.is_empty() {
273 reading.why = as_written(text, &reading.why);
274 }
275 let labels = normal(&lw.references);
277 'lines: for line in text.lines() {
278 let folded = plain(line);
279 if !labels.iter().any(|l| folded.contains(&format!(" {l} "))) {
280 continue;
281 }
282 let after = line.split_once(':').map_or(line, |(_, rest)| rest);
283 for token in after.split_whitespace() {
284 let token = token.trim_matches(|c: char| !c.is_alphanumeric());
285 if token.chars().filter(char::is_ascii_digit).count() >= 4 {
286 reading.reference = token.to_string();
287 break 'lines;
288 }
289 }
290 }
291 reading
292}
293
294fn as_written(text: &str, phrase: &str) -> String {
296 let flat: Vec<char> = text.split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
297 let folded: Vec<char> = flat.iter().map(|&c| crate::text::fold_char(c)).map(|c| if c.is_alphanumeric() { c } else { ' ' }).collect();
299 let mut spans: Vec<(usize, usize)> = Vec::new();
300 let mut start = None;
301 for (i, c) in folded.iter().enumerate() {
302 match (c.is_alphanumeric(), start) {
303 (true, None) => start = Some(i),
304 (false, Some(s)) => {
305 spans.push((s, i));
306 start = None;
307 }
308 _ => {}
309 }
310 }
311 if let Some(s) = start {
312 spans.push((s, folded.len()));
313 }
314 let words: Vec<String> = spans.iter().map(|(a, b)| folded[*a..*b].iter().collect()).collect();
315 let wanted: Vec<&str> = phrase.split_whitespace().collect();
316 let n = wanted.len();
317 (0..words.len().saturating_sub(n.saturating_sub(1)))
318 .find(|&k| words[k..k + n].iter().map(String::as_str).eq(wanted.iter().copied()))
319 .map(|k| flat[spans[k].0..spans[k + n - 1].1].iter().collect())
320 .unwrap_or_else(|| phrase.to_string())
321}
322
323#[derive(Debug, Clone, Default, PartialEq, Eq)]
325pub struct Letter {
326 pub id: String,
327 pub file: String,
329 pub source: String,
331 pub received: Option<Date>,
332 pub reading: Reading,
333 pub project: String,
336 pub status: String,
338 pub task: String,
340 pub problem: String,
342}
343
344#[derive(Debug, Clone, Default)]
346pub struct Letters {
347 pub root: PathBuf,
348 pub list: Vec<Letter>,
349}
350
351impl Letters {
352 pub fn folder(root: &Path) -> PathBuf {
353 root.join(FOLDER)
354 }
355
356 pub fn change<R>(root: &Path, change: impl FnOnce(&mut Letters) -> R) -> Result<R, String> {
360 Letters::try_change(root, |letters| Ok(change(letters)))
361 }
362
363 pub fn try_change<R>(root: &Path, change: impl FnOnce(&mut Letters) -> Result<R, String>) -> Result<R, String> {
365 crate::filelock::with_lock(&Letters::folder(root).join(MANIFEST), || {
366 let mut letters = Letters::load(root)?;
367 let out = change(&mut letters)?;
368 letters.save()?;
369 Ok(out)
370 })
371 }
372
373 pub fn load(root: &Path) -> Result<Letters, String> {
374 let path = Letters::folder(root).join(MANIFEST);
375 let text = match std::fs::read_to_string(&path) {
376 Ok(text) => text,
377 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Letters { root: root.to_path_buf(), list: Vec::new() }),
378 Err(e) => return Err(format!("{}: {e}", path.display())),
379 };
380 let table: toml::Table = text.parse().map_err(|e: toml::de::Error| format!("{}: {e}", path.display()))?;
381 let text_of = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).unwrap_or("").to_string();
382 let date_of = |t: &toml::Table, k: &str| t.get(k).and_then(|v| v.as_datetime().map(|d| d.to_string()).or_else(|| v.as_str().map(str::to_string))).and_then(|s| s.get(..10).and_then(|s| s.parse().ok()));
383 let list = table
384 .get("letter")
385 .and_then(toml::Value::as_array)
386 .into_iter()
387 .flatten()
388 .filter_map(|v| {
389 let t = v.as_table()?;
390 let time = t.get("time").and_then(toml::Value::as_str).and_then(|s| s.split_once(':')).and_then(|(h, m)| Some((h.parse().ok()?, m.parse().ok()?)));
391 Some(Letter {
392 id: text_of(t, "id"),
393 file: text_of(t, "file"),
394 source: text_of(t, "source"),
395 received: date_of(t, "received"),
396 reading: Reading {
397 sender: text_of(t, "sender"),
398 kind: Kind::of(&text_of(t, "kind")),
399 amount: t.get("amount").and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))).map(Money::from_units),
400 dated: date_of(t, "dated"),
401 deadline: date_of(t, "deadline"),
402 why: text_of(t, "why"),
403 appointment: date_of(t, "appointment").map(|d| (d, time)),
404 registered: t.get("registered").and_then(toml::Value::as_bool).unwrap_or(false),
405 reference: text_of(t, "reference"),
406 },
407 project: Some(text_of(t, "project")).filter(|p| !p.is_empty()).unwrap_or_else(|| text_of(t, "case")),
408 status: text_of(t, "status"),
409 task: text_of(t, "task"),
410 problem: text_of(t, "problem"),
411 })
412 })
413 .collect();
414 Ok(Letters { root: root.to_path_buf(), list })
415 }
416
417 pub fn get(&self, id: &str) -> Option<&Letter> {
418 self.list.iter().find(|l| l.id == id)
419 }
420
421 pub fn put(&mut self, letter: Letter) {
422 match self.list.iter_mut().find(|l| l.id == letter.id) {
423 Some(place) => *place = letter,
424 None => self.list.push(letter),
425 }
426 }
427
428 pub fn file_path(&self, letter: &Letter) -> PathBuf {
430 let path = Path::new(&letter.file);
431 if path.is_absolute() { path.to_path_buf() } else { self.root.join(path) }
432 }
433
434 pub fn text(&self, letter: &Letter) -> String {
436 std::fs::read_to_string(Letters::folder(&self.root).join(format!("{}.txt", letter.id))).unwrap_or_default()
437 }
438
439 pub fn save_text(&self, id: &str, text: &str) -> Result<(), String> {
440 let folder = Letters::folder(&self.root);
441 std::fs::create_dir_all(&folder).and_then(|()| std::fs::write(folder.join(format!("{id}.txt")), text)).map_err(|e| e.to_string())
442 }
443
444 pub fn file_away(&self, letter: &mut Letter) -> Result<(), String> {
446 let from = self.file_path(letter);
447 let day = letter.reading.dated.or(letter.received).unwrap_or(Date::constant(2000, 1, 1));
448 let folder = Letters::folder(&self.root).join(day.year().to_string());
449 if from.starts_with(&folder) {
450 return Ok(());
451 }
452 std::fs::create_dir_all(&folder).map_err(|e| e.to_string())?;
453 let extension = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
454 let sender: String = crate::projects::new_id(&letter.reading.sender, &[]);
455 let base = format!("{day} {sender} {}", letter.reading.kind.id());
456 let target = std::iter::once(folder.join(format!("{base}{extension}"))).chain((2..1000).map(|n| folder.join(format!("{base} {n}{extension}")))).find(|p| !p.exists()).ok_or("no free name")?;
457 std::fs::rename(&from, &target).or_else(|_| std::fs::copy(&from, &target).and_then(|_| std::fs::remove_file(&from))).map_err(|e| e.to_string())?;
458 letter.file = target.strip_prefix(&self.root).map_or_else(|_| target.display().to_string(), |p| p.to_string_lossy().replace('\\', "/"));
459 Ok(())
460 }
461
462 pub fn save(&self) -> Result<(), String> {
464 crate::filelock::with_lock(&Letters::folder(&self.root).join(MANIFEST), || self.write())
465 }
466
467 fn write(&self) -> Result<(), String> {
468 let folder = Letters::folder(&self.root);
469 std::fs::create_dir_all(&folder).map_err(|e| e.to_string())?;
470 let mut doc = toml_edit::DocumentMut::new();
471 doc.decor_mut().set_prefix("# Paper letters, read from their scans (docs/porch.md). Their texts are beside: <id>.txt.\n\n");
472 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 });
473 let mut list = toml_edit::ArrayOfTables::new();
474 for l in &self.list {
475 let mut t = toml_edit::Table::new();
476 t["id"] = toml_edit::value(&l.id);
477 t["file"] = toml_edit::value(&l.file);
478 for (k, v) in [("source", &l.source), ("sender", &l.reading.sender), ("why", &l.reading.why), ("reference", &l.reading.reference), ("case", &l.project), ("status", &l.status), ("task", &l.task), ("problem", &l.problem)] {
481 if !v.is_empty() {
482 t[k] = toml_edit::value(v);
483 }
484 }
485 t["kind"] = toml_edit::value(l.reading.kind.id());
486 if let Some(amount) = l.reading.amount {
487 t["amount"] = toml_edit::value(amount.cents() as f64 / 100.0);
488 }
489 for (k, d) in [("received", l.received), ("dated", l.reading.dated), ("deadline", l.reading.deadline), ("appointment", l.reading.appointment.map(|(d, _)| d))] {
490 if let Some(d) = d {
491 t[k] = date(d);
492 }
493 }
494 if let Some((_, Some((h, m)))) = l.reading.appointment {
495 t["time"] = toml_edit::value(format!("{h:02}:{m:02}"));
496 }
497 if l.reading.registered {
498 t["registered"] = toml_edit::value(true);
499 }
500 list.push(t);
501 }
502 doc.insert("letter", toml_edit::Item::ArrayOfTables(list));
503 let path = folder.join(MANIFEST);
504 let temporary = folder.join(format!("{MANIFEST}.new"));
505 std::fs::write(&temporary, doc.to_string()).and_then(|()| std::fs::rename(&temporary, &path)).map_err(|e| format!("{}: {e}", path.display()))
506 }
507}
508
509pub fn project_of(text: &str, sender: &str, projects: &[crate::projects::Project]) -> Option<String> {
511 let all = plain(&format!("{sender} {text}"));
512 projects
513 .iter()
514 .filter(|c| c.status.as_deref() != Some("closed"))
515 .map(|c| {
516 let words: Vec<String> = plain(&c.title).split_whitespace().filter(|w| w.len() >= 4).map(str::to_string).collect();
517 (words.iter().filter(|w| all.contains(&format!(" {w} "))).count(), words.len(), c.id.clone())
518 })
519 .filter(|(hits, words, _)| *hits > 0 && *hits * 2 >= *words)
520 .max_by_key(|(hits, _, _)| *hits)
521 .map(|(_, _, id)| id)
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 fn read(text: &str, received: Date) -> Reading {
530 super::read(&Words::builtin(), text, received)
531 }
532
533 fn day(text: &str) -> Date {
534 text.parse().unwrap()
535 }
536
537 #[test]
538 fn a_decision_with_its_appeal_delay() {
539 let text = "CAISSE D'ALLOCATIONS FAMILIALES\nd'Exempleville\n\nExempleville, le 28 septembre 2026\n\nNuméro allocataire : 1234567 X\n\nObjet : notification de décision\n\nMadame, Monsieur,\nNous avons examiné votre demande d'aide au logement. Notre décision : votre droit est refusé.\n\nVoies et délais de recours : vous pouvez contester cette décision dans un délai de deux mois à compter de sa notification, auprès de la commission de recours amiable.\n";
540 let r = read(text, day("2026-10-02"));
541 assert_eq!(r.sender, "CAF");
542 assert_eq!(r.kind, Kind::Decision);
543 assert_eq!(r.dated, Some(day("2026-09-28")));
544 assert_eq!(r.deadline, Some(day("2026-12-02")), "two months from the day it came");
545 assert!(r.why.contains("délai de deux mois à compter"), "as written: {}", r.why);
546 assert_eq!(r.reference, "1234567");
547 }
548
549 #[test]
550 fn a_bill_with_its_date_and_amount() {
551 let text = "CENTRE HOSPITALIER D'EXEMPLEVILLE\nService des recettes\n\nAVIS DES SOMMES A PAYER\n\nDate d'émission : 15/09/2026\nRéférence : 2026-ASAP-01234\n\nMontant à payer : 1 234,56 €\nÀ régler avant le 15 novembre 2026, par virement ou en ligne sur payfip.gouv.fr.\n\nLettre recommandée avec accusé de réception\n";
552 let r = read(text, day("2026-10-02"));
553 assert_eq!(r.sender, "Hôpital");
554 assert_eq!(r.kind, Kind::Bill);
555 assert_eq!(r.amount, Some(Money(123456)));
556 assert_eq!(r.deadline, Some(day("2026-11-15")));
557 assert!(r.registered);
558 assert_eq!(r.reference, "2026-ASAP-01234");
559 }
560
561 #[test]
562 fn a_formal_notice_comes_first_and_eight_days() {
563 let text = "Cabinet Exemple\nCommissaire de justice\n\nMISE EN DEMEURE\n\nNous vous mettons en demeure de régler la somme de 2 345,00 € sous huitaine.\n";
564 let r = read(text, day("2026-10-02"));
565 assert_eq!(r.kind, Kind::FormalNotice);
566 assert_eq!(r.sender, "Commissaire de justice");
567 assert_eq!(r.amount, Some(Money(234500)));
568 assert_eq!(r.deadline, Some(day("2026-10-10")));
569 }
570
571 #[test]
572 fn an_appointment_with_its_hour() {
573 let text = "Assurance Maladie\nCPAM d'Exempleville\n\nConvocation\n\nVous êtes convoqué à un examen le jeudi 22 octobre 2026 à 9 h 15, au centre d'examens de santé.\n";
574 let r = read(text, day("2026-10-02"));
575 assert_eq!(r.kind, Kind::Appointment);
576 assert_eq!(r.sender, "Assurance Maladie");
577 assert_eq!(r.appointment, Some((day("2026-10-22"), Some((9, 15)))));
578 }
579
580 #[test]
581 fn english_and_unknown_senders() {
582 let text = "Example Water Ltd\n12 High Street\n\nInvoice\nAmount due: £84.20\nPlease pay no later than 30 October 2026.\n";
583 let r = read(text, day("2026-10-02"));
584 assert_eq!(r.sender, "Example Water Ltd");
585 assert_eq!(r.kind, Kind::Bill);
586 assert_eq!(r.deadline, Some(day("2026-10-30")));
587 assert_eq!(r.amount, Some(Money(8420)));
588 }
589
590 #[test]
591 fn kept_and_filed() {
592 let root = std::env::temp_dir().join(format!("sioul-letters-{}", std::process::id()));
593 let _ = std::fs::remove_dir_all(&root);
594 std::fs::create_dir_all(root.join("letters/inbox")).unwrap();
595 std::fs::write(root.join("letters/inbox/scan.pdf"), b"%PDF").unwrap();
596 let mut letters = Letters::load(&root).unwrap();
597 let mut letter = Letter { id: "a1".into(), file: "letters/inbox/scan.pdf".into(), received: Some(day("2026-10-02")), status: "new".into(), ..Letter::default() };
598 letter.reading = read("CAF\nExempleville, le 28 septembre 2026\nDécision. Voies et délais de recours : recours dans un délai de deux mois.", day("2026-10-02"));
599 letters.file_away(&mut letter).unwrap();
600 assert_eq!(letter.file, "letters/2026/2026-09-28 caf decision.pdf");
601 assert!(root.join(&letter.file).is_file() && !root.join("letters/inbox/scan.pdf").exists());
602 letters.put(letter.clone());
603 letters.save_text("a1", "the text").unwrap();
604 letters.save().unwrap();
605 let again = Letters::load(&root).unwrap();
606 assert_eq!(again.list, vec![letter]);
607 assert_eq!(again.text(&again.list[0]), "the text");
608 let _ = std::fs::remove_dir_all(&root);
609 }
610}