1use crate::config::Config;
49use serde::{Deserialize, Serialize};
50use std::path::{Path, PathBuf};
51use std::sync::{Arc, LazyLock, Mutex};
52
53pub type Named = Vec<(String, Vec<String>)>;
56
57const SHIPPED: &[(&str, &str)] = &[
59 ("en.toml", include_str!("../data/words/en.toml")),
60 ("fr.toml", include_str!("../data/words/fr.toml")),
61 ("de.toml", include_str!("../data/words/de.toml")),
64 ("es.toml", include_str!("../data/words/es.toml")),
65 ("it.toml", include_str!("../data/words/it.toml")),
66 ("countries/FR.toml", include_str!("../data/words/countries/FR.toml")),
67 ("international.toml", include_str!("../data/words/international.toml")),
68];
69
70pub const MAPS: &[&str] = &["brands.brands", "letters.senders", "letters.months", "letters.numbers", "letters.fixed_delays", "capture.months"];
72
73pub const FIRST_LANGUAGES: [&str; 2] = ["fr", "en"];
75pub const FIRST_COUNTRIES: [&str; 1] = ["FR"];
77
78#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82#[serde(default)]
83pub struct Codes {
84 pub code: Vec<String>,
86 pub pointing: Vec<String>,
88 pub sign_in: Vec<String>,
90 pub not_yours: Vec<String>,
92 pub promo: Vec<String>,
94 pub password: Vec<String>,
95 pub reset: Vec<String>,
96 pub link: Vec<String>,
97 pub intents: Vec<String>,
99 pub link_words: Vec<String>,
101 pub validity: Vec<String>,
102 pub request: Vec<String>,
104 pub confirm: Vec<String>,
105 pub giving: Vec<String>,
107 pub owners: Vec<String>,
109 pub owned_after: Vec<String>,
111 pub linking: Vec<String>,
113 pub once: Vec<String>,
114 pub expiry_context: Vec<String>,
116 pub noun: Vec<String>,
118 pub unit_minutes: Vec<String>,
120 pub unit_hours: Vec<String>,
121 pub unit_days: Vec<String>,
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
126#[serde(default)]
127pub struct Approvals {
128 pub phrases: Vec<String>,
129 pub asks: Vec<String>,
131 pub asked_about: Vec<String>,
132 pub channels: Vec<String>,
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
138#[serde(default)]
139pub struct MoneyWords {
140 pub keywords: Vec<String>,
142 pub stated: Vec<String>,
144 pub totals: Vec<String>,
146 pub subtotal: Vec<String>,
148 pub legal: Vec<String>,
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
154#[serde(default)]
155pub struct Payments {
156 pub not_payments: Vec<String>,
157 pub refund: Vec<String>,
158 pub paid: Vec<String>,
159 pub received: Vec<String>,
160 pub order: Vec<String>,
161 pub bill: Vec<String>,
162 pub to: Vec<String>,
164 pub from: Vec<String>,
166 pub leads: Vec<String>,
168 pub order_words: Vec<String>,
170 pub confirmed_words: Vec<String>,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
175#[serde(default)]
176pub struct BankColumns {
177 pub date: Vec<String>,
178 pub label: Vec<String>,
179 pub name: Vec<String>,
180 pub net: Vec<String>,
181 pub amount: Vec<String>,
182 pub debit: Vec<String>,
183 pub credit: Vec<String>,
184 pub kind: Vec<String>,
185 pub status: Vec<String>,
186 pub impact: Vec<String>,
187 pub balance: Vec<String>,
188 pub time: Vec<String>,
189 pub currency: Vec<String>,
190 pub id: Vec<String>,
191}
192
193#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
195#[serde(default)]
196pub struct BankAbove {
197 pub balance: Vec<String>,
198 pub date: Vec<String>,
199 pub account: Vec<String>,
200}
201
202#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
204#[serde(default)]
205pub struct BankWords {
206 pub filler: Vec<String>,
208 pub columns: BankColumns,
209 pub above: BankAbove,
210 pub skipped: Vec<String>,
212 pub memo: Vec<String>,
214}
215
216#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
218#[serde(default)]
219pub struct AccountWords {
220 pub between: Vec<String>,
221}
222
223#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
225#[serde(default)]
226pub struct LetterKinds {
227 pub formal_notice: Vec<String>,
228 pub tax_notice: Vec<String>,
229 pub decision: Vec<String>,
230 pub decision_appeal: Vec<String>,
232 pub reminder: Vec<String>,
233 pub appointment: Vec<String>,
234 pub bill: Vec<String>,
235 pub acknowledgment: Vec<String>,
236 pub attestation: Vec<String>,
237 pub contract: Vec<String>,
238}
239
240#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
242#[serde(default)]
243pub struct LetterWords {
244 pub senders: Named,
246 pub kinds: LetterKinds,
247 pub registered: Vec<String>,
248 pub registered_receipt: Vec<String>,
249 pub asks: Vec<String>,
251 pub months: Named,
253 pub ordinals: Vec<String>,
254 pub numbers: Named,
256 pub deadline: Vec<String>,
258 pub delay_words: Vec<String>,
259 pub delay_of: Vec<String>,
260 pub unit_months: Vec<String>,
261 pub unit_days: Vec<String>,
262 pub unit_weeks: Vec<String>,
263 pub fixed_delays: Named,
265 pub from_letter: Vec<String>,
267 pub at: Vec<String>,
269 pub references: Vec<String>,
271}
272
273#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
275#[serde(default)]
276pub struct PaperKinds {
277 pub passport: Vec<String>,
278 pub identity: Vec<String>,
279 pub residence: Vec<String>,
280 pub driving: Vec<String>,
281 pub health_cover: Vec<String>,
282 pub health_card: Vec<String>,
283 pub tax_notice: Vec<String>,
284 pub rent_receipt: Vec<String>,
285 pub bank_details: Vec<String>,
286 pub payslip: Vec<String>,
287 pub warranty: Vec<String>,
288 pub insurance: Vec<String>,
289 pub certificate: Vec<String>,
290}
291
292#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
293#[serde(default)]
294pub struct PaperWords {
295 pub kinds: PaperKinds,
296}
297
298#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
300#[serde(default)]
301pub struct ContractKinds {
302 pub rent: Vec<String>,
303 pub energy: Vec<String>,
304 pub health: Vec<String>,
305 pub insurance: Vec<String>,
306 pub telecom: Vec<String>,
307 pub hosting: Vec<String>,
308 pub subscription: Vec<String>,
309 pub bank: Vec<String>,
310}
311
312#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
313#[serde(default)]
314pub struct ContractWords {
315 pub kinds: ContractKinds,
316}
317
318#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
320#[serde(default)]
321pub struct SenderWords {
322 pub automatic: Vec<String>,
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
328#[serde(default)]
329pub struct Replies {
330 pub forward: Vec<String>,
331 pub reply: Vec<String>,
332}
333
334#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
336#[serde(default)]
337pub struct Quotes {
338 pub openings: Vec<String>,
340 pub header_names: Vec<String>,
341 pub from_names: Vec<String>,
343 pub enough_names: Vec<String>,
345 pub wrote: Vec<String>,
347 pub attribution_starts: Vec<String>,
349}
350
351#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
353#[serde(default)]
354pub struct FolderWords {
355 pub sent: Vec<String>,
356 pub drafts: Vec<String>,
357 pub junk: Vec<String>,
358 pub trash: Vec<String>,
359 pub archive: Vec<String>,
360}
361
362#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
364#[serde(default)]
365pub struct BrandWords {
366 pub brands: Named,
368 pub shared: Vec<String>,
370 pub service_words: Vec<String>,
374 pub everyday: Vec<String>,
378}
379
380#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
382#[serde(default)]
383pub struct CallWords {
384 pub missed: Vec<String>,
385 pub ringing: Vec<String>,
386}
387
388#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
390#[serde(default)]
391pub struct VoicemailWords {
392 pub operators: Vec<String>,
394 pub words: Vec<String>,
395 pub hidden: Vec<String>,
396 pub caller_leads: Vec<String>,
398 pub own_line: Vec<String>,
399 pub duration: Vec<String>,
400}
401
402#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
404#[serde(default)]
405pub struct ShieldTopics {
406 pub work: Vec<String>,
407 pub support: Vec<String>,
408 pub press: Vec<String>,
409 pub thanks: Vec<String>,
410 pub donation: Vec<String>,
411}
412
413#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
415#[serde(default)]
416pub struct ShieldWords {
417 pub threats: Vec<String>,
418 pub insults: Vec<String>,
419 pub rude: Vec<String>,
420 pub you: Vec<String>,
421 pub topics: ShieldTopics,
422}
423
424#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
426#[serde(default)]
427pub struct TaskWords {
428 pub optional: Vec<String>,
430 pub joy: Vec<String>,
432 pub movement: Vec<String>,
434 pub personal: Vec<String>,
436 pub work: Vec<String>,
438 pub leisure: Vec<String>,
440 pub usual_personal: Vec<String>,
442 pub sounds_folders: Vec<String>,
444}
445
446#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
448#[serde(default)]
449pub struct Weekdays {
450 pub monday: Vec<String>,
451 pub tuesday: Vec<String>,
452 pub wednesday: Vec<String>,
453 pub thursday: Vec<String>,
454 pub friday: Vec<String>,
455 pub saturday: Vec<String>,
456 pub sunday: Vec<String>,
457}
458
459#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
461#[serde(default)]
462pub struct TaskKinds {
463 pub call: Vec<String>,
464 pub write: Vec<String>,
465 pub online: Vec<String>,
466 pub out: Vec<String>,
467 pub read: Vec<String>,
468 pub think: Vec<String>,
469 pub make: Vec<String>,
470}
471
472#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
474#[serde(default)]
475pub struct CaptureWords {
476 pub weekdays: Weekdays,
477 pub months: Named,
479 pub kind_words: TaskKinds,
481 pub kind_verbs: TaskKinds,
483 pub today: Vec<String>,
484 pub tomorrow: Vec<String>,
485 pub next_week: Vec<String>,
486 #[serde(rename = "in")]
488 pub in_: Vec<String>,
489 pub unit_days: Vec<String>,
490 pub unit_weeks: Vec<String>,
491 pub ordinals: Vec<String>,
492}
493
494#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
497#[serde(default)]
498pub struct SpamWords {
499 pub provider_tags: Vec<String>,
501 pub months: Vec<String>,
502 pub keys: Vec<String>,
503 pub elisions: Vec<String>,
504 pub number_words: Vec<String>,
505 pub stop_words: Vec<String>,
506}
507
508#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
510#[serde(default)]
511pub struct OcrWords {
512 pub tesseract: Vec<String>,
514}
515
516#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
518#[serde(default)]
519pub struct Words {
520 pub codes: Codes,
521 pub approvals: Approvals,
522 pub money: MoneyWords,
523 pub payments: Payments,
524 pub bank: BankWords,
525 pub accounts: AccountWords,
526 pub letters: LetterWords,
527 pub papers: PaperWords,
528 pub contracts: ContractWords,
529 pub senders: SenderWords,
530 pub replies: Replies,
531 pub quotes: Quotes,
532 pub folders: FolderWords,
533 pub brands: BrandWords,
534 pub calls: CallWords,
535 pub voicemail: VoicemailWords,
536 pub shield: ShieldWords,
537 pub tasks: TaskWords,
538 pub capture: CaptureWords,
539 pub spam: SpamWords,
540 pub ocr: OcrWords,
541 #[serde(skip)]
543 pub languages: Vec<String>,
544 #[serde(skip)]
546 pub countries: Vec<String>,
547}
548
549#[derive(Debug, Clone, PartialEq)]
553pub enum Node {
554 List(Vec<String>),
555 Table(Vec<(String, Node)>),
556}
557
558impl Node {
559 fn table() -> Node {
560 Node::Table(Vec::new())
561 }
562
563 pub fn get(&self, path: &str) -> Option<&Node> {
565 path.split('.').filter(|p| !p.is_empty()).try_fold(self, |node, part| match node {
566 Node::Table(entries) => entries.iter().find(|(k, _)| k == part).map(|(_, n)| n),
567 Node::List(_) => None,
568 })
569 }
570
571 pub fn list(&self, path: &str) -> &[String] {
573 match self.get(path) {
574 Some(Node::List(words)) => words,
575 _ => &[],
576 }
577 }
578
579 fn entry_mut(&mut self, key: &str) -> Option<&mut Node> {
580 match self {
581 Node::Table(entries) => entries.iter_mut().find(|(k, _)| k == key).map(|(_, n)| n),
582 Node::List(_) => None,
583 }
584 }
585
586 pub fn flatten(&self) -> Vec<(String, Vec<String>)> {
588 let mut out = Vec::new();
589 fn walk(node: &Node, path: &str, out: &mut Vec<(String, Vec<String>)>) {
590 match node {
591 Node::List(words) => out.push((path.to_string(), words.clone())),
592 Node::Table(entries) => {
593 for (key, child) in entries {
594 let path = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
595 walk(child, &path, out);
596 }
597 }
598 }
599 }
600 walk(self, "", &mut out);
601 out
602 }
603}
604
605fn node_of_value(value: &toml_edit::Value) -> Option<Node> {
606 match value {
607 toml_edit::Value::Array(array) => Some(Node::List(array.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())),
608 toml_edit::Value::InlineTable(table) => Some(Node::Table(table.iter().filter_map(|(k, v)| node_of_value(v).map(|n| (k.to_string(), n))).collect())),
609 _ => None,
611 }
612}
613
614fn node_of_item(item: &toml_edit::Item) -> Option<Node> {
615 match item {
616 toml_edit::Item::Table(table) => Some(Node::Table(table.iter().filter_map(|(k, v)| node_of_item(v).map(|n| (k.to_string(), n))).collect())),
617 toml_edit::Item::Value(value) => node_of_value(value),
618 _ => None,
619 }
620}
621
622#[derive(Debug, Clone)]
624pub struct Pack {
625 pub file: String,
627 pub checked: String,
629 pub lists: Node,
630}
631
632impl Pack {
633 pub fn parse(file: &str, text: &str) -> Option<Pack> {
635 let doc: toml_edit::DocumentMut = text.parse().ok()?;
636 let checked = doc.get("checked").and_then(toml_edit::Item::as_str).unwrap_or("").to_string();
637 let lists = node_of_item(doc.as_item()).unwrap_or_else(Node::table);
638 Some(Pack { file: file.to_string(), checked, lists })
639 }
640
641 pub fn language(&self) -> Option<&str> {
643 (!self.file.contains('/') && self.file != "international.toml").then(|| self.file.trim_end_matches(".toml"))
644 }
645
646 pub fn country(&self) -> Option<&str> {
648 self.file.strip_prefix("countries/").map(|f| f.trim_end_matches(".toml"))
649 }
650}
651
652fn shipped() -> &'static [Pack] {
654 static SHIPPED_PACKS: LazyLock<Vec<Pack>> = LazyLock::new(|| SHIPPED.iter().filter_map(|(file, text)| Pack::parse(file, text)).collect());
655 &SHIPPED_PACKS
656}
657
658fn places() -> Vec<PathBuf> {
660 let mut places = vec![crate::config::data_dir().join("words")];
661 let system = std::env::var_os("XDG_DATA_DIRS").unwrap_or_else(|| if cfg!(windows) { Default::default() } else { "/usr/local/share:/usr/share".into() });
662 places.extend(std::env::split_paths(&system).filter(|d| !d.as_os_str().is_empty()).map(|d| d.join("sioul").join("words")));
663 places
664}
665
666fn newest() -> &'static [Pack] {
668 static NEWEST: LazyLock<Vec<Pack>> = LazyLock::new(|| {
669 let places = places();
670 shipped()
671 .iter()
672 .map(|pack| {
673 let mut best = pack.clone();
674 for place in &places {
675 if let Some(found) = std::fs::read_to_string(place.join(&pack.file)).ok().and_then(|t| Pack::parse(&pack.file, &t))
676 && found.checked > best.checked
677 {
678 best = found;
679 }
680 }
681 best
682 })
683 .collect()
684 });
685 &NEWEST
686}
687
688pub fn available_languages() -> Vec<&'static str> {
690 shipped().iter().filter_map(Pack::language).collect()
691}
692
693pub fn available_countries() -> Vec<&'static str> {
695 shipped().iter().filter_map(Pack::country).collect()
696}
697
698fn strings(value: Option<&toml::Value>) -> Option<Vec<String>> {
701 value?.as_array().map(|list| list.iter().filter_map(|v| v.as_str().map(|s| s.trim().to_string())).filter(|s| !s.is_empty()).collect())
702}
703
704fn deduplicated(list: Vec<String>) -> Vec<String> {
705 let mut out: Vec<String> = Vec::with_capacity(list.len());
706 for word in list {
707 if !out.contains(&word) {
708 out.push(word);
709 }
710 }
711 out
712}
713
714pub fn languages(config: &Config) -> Vec<String> {
716 let said = strings(config.words.get("languages")).map(|list| list.iter().map(|l| base_language(l)).filter(|l| !l.is_empty()).collect::<Vec<_>>());
717 deduplicated(said.unwrap_or_else(|| {
718 let interface = config.language.clone().unwrap_or_else(crate::i18n::system_language);
719 vec![base_language(&interface), "en".to_string()]
720 }))
721}
722
723fn base_language(code: &str) -> String {
724 code.split(['_', '-', '.', '@']).next().unwrap_or("").trim().to_ascii_lowercase()
725}
726
727pub fn countries(config: &Config) -> Vec<String> {
729 let said = strings(config.words.get("countries")).map(|list| list.iter().map(|c| c.trim().to_ascii_uppercase()).filter(|c| !c.is_empty()).collect::<Vec<_>>());
730 deduplicated(said.unwrap_or_else(|| {
731 let region = config.contacts.region.as_deref().map(str::trim).filter(|r| !r.is_empty()).map(str::to_ascii_uppercase);
732 let locale = || {
733 ["LC_ALL", "LC_TELEPHONE", "LANG"]
734 .iter()
735 .filter_map(|v| std::env::var(v).ok())
736 .find_map(|v| v.split(['.', '@']).next().and_then(|b| b.split(['_', '-']).nth(1)).map(str::to_ascii_uppercase).filter(|c| c.len() == 2))
737 };
738 region.or_else(locale).into_iter().collect()
739 }))
740}
741
742fn in_use<'a>(packs: &'a [Pack], languages: &[String], countries: &[String]) -> Vec<&'a Pack> {
744 let mut out: Vec<&Pack> = Vec::new();
745 for language in languages {
746 out.extend(packs.iter().filter(|p| p.language() == Some(language.as_str())));
747 }
748 for country in countries {
749 out.extend(packs.iter().filter(|p| p.country() == Some(country.as_str())));
750 }
751 out.extend(packs.iter().filter(|p| p.file == "international.toml"));
752 out
753}
754
755fn merge(into: &mut Node, from: &Node) {
757 let (Node::Table(target), Node::Table(source)) = (into, from) else { return };
758 for (key, node) in source {
759 match target.iter_mut().find(|(k, _)| k == key) {
760 Some((_, existing)) => match (existing, node) {
761 (Node::List(words), Node::List(more)) => words.extend(more.iter().cloned()),
762 (existing @ Node::Table(_), Node::Table(_)) => merge(existing, node),
763 _ => {}
765 },
766 None => target.push((key.clone(), node.clone())),
767 }
768 }
769}
770
771pub fn folded(text: &str) -> String {
773 crate::text::fold(text.trim()).into_iter().collect()
774}
775
776pub fn holds_word(list: &[String], text: &str) -> bool {
783 let alnum = |c: Option<char>| c.is_some_and(char::is_alphanumeric);
784 list.iter().map(|w| folded(w)).filter(|w| !w.is_empty()).any(|word| {
785 let (starts, ends) = (alnum(word.chars().next()), alnum(word.chars().next_back()));
786 text.match_indices(word.as_str()).any(|(at, _)| (!starts || !alnum(text[..at].chars().next_back())) && (!ends || !alnum(text[at + word.len()..].chars().next())))
787 })
788}
789
790pub fn is_named(word: &str, list: &[String]) -> bool {
792 let word = folded(word);
793 !word.is_empty() && list.iter().any(|w| folded(w) == word)
794}
795
796pub fn named_in(words: &[String], list: &[String]) -> bool {
798 let list: Vec<String> = list.iter().map(|w| folded(w)).filter(|w| !w.is_empty()).collect();
799 words.iter().any(|w| list.contains(&folded(w)))
800}
801
802fn apply(into: &mut Node, changes: &toml::Table) {
807 for (key, value) in changes {
808 let toml::Value::Table(change) = value else { continue };
810 let is_change = change.contains_key("add") || change.contains_key("remove");
811 if is_change {
812 apply_one(into, key, change);
813 }
814 let deeper: toml::Table = change.iter().filter(|(k, _)| !matches!(k.as_str(), "add" | "remove")).map(|(k, v)| (k.clone(), v.clone())).collect();
815 if deeper.is_empty() {
816 continue;
817 }
818 if into.entry_mut(key).is_none()
819 && let Node::Table(entries) = into
820 {
821 entries.push((key.clone(), Node::table()));
822 }
823 if let Some(child @ Node::Table(_)) = into.entry_mut(key) {
824 apply(child, &deeper);
825 }
826 }
827}
828
829fn apply_one(into: &mut Node, key: &str, change: &toml::Table) {
830 let removed: Vec<String> = strings(change.get("remove")).unwrap_or_default().iter().map(|w| folded(w)).collect();
831 let add = change.get("add");
832 if into.entry_mut(key).is_none() {
833 let Node::Table(entries) = into else { return };
834 let node = match add {
835 Some(toml::Value::Table(_)) => Node::table(),
836 Some(toml::Value::Array(_)) => Node::List(Vec::new()),
837 _ => return,
838 };
839 entries.push((key.to_string(), node));
840 }
841 match (into.entry_mut(key), add) {
842 (Some(Node::List(words)), add) => {
843 words.retain(|w| !removed.contains(&folded(w)));
844 if let Some(add) = strings(add) {
845 words.extend(add);
846 }
847 }
848 (Some(Node::Table(entries)), add) => {
849 entries.retain(|(name, _)| !removed.contains(&folded(name)));
850 if let Some(toml::Value::Table(add)) = add {
851 for (name, words) in add {
852 let words = strings(Some(words)).unwrap_or_default();
853 match entries.iter_mut().find(|(n, _)| folded(n) == folded(name)) {
854 Some((_, Node::List(existing))) => existing.extend(words),
855 Some(_) => {}
856 None => entries.push((name.clone(), Node::List(words))),
857 }
858 }
859 }
860 }
861 _ => {}
862 }
863}
864
865fn tidy(node: &mut Node) {
867 match node {
868 Node::List(words) => {
869 let mut seen: Vec<String> = Vec::with_capacity(words.len());
870 words.retain(|w| {
871 let key = folded(w);
872 let keep = !key.is_empty() && !seen.contains(&key);
873 seen.push(key);
874 keep
875 });
876 }
877 Node::Table(entries) => entries.iter_mut().for_each(|(_, n)| tidy(n)),
878 }
879}
880
881fn value_of(node: &Node, path: &str) -> toml::Value {
883 match node {
884 Node::List(words) => toml::Value::Array(words.iter().cloned().map(toml::Value::String).collect()),
885 Node::Table(entries) if MAPS.contains(&path) => toml::Value::Array(
886 entries
887 .iter()
888 .filter_map(|(name, n)| match n {
889 Node::List(words) => Some(toml::Value::Array(vec![toml::Value::String(name.clone()), toml::Value::Array(words.iter().cloned().map(toml::Value::String).collect())])),
890 Node::Table(_) => None,
891 })
892 .collect(),
893 ),
894 Node::Table(entries) => toml::Value::Table(
895 entries
896 .iter()
897 .map(|(key, n)| {
898 let child = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
899 (key.clone(), value_of(n, &child))
900 })
901 .collect(),
902 ),
903 }
904}
905
906static BUILTIN: LazyLock<Arc<Words>> = LazyLock::new(|| {
907 let (languages, countries) = (FIRST_LANGUAGES.map(String::from).to_vec(), FIRST_COUNTRIES.map(String::from).to_vec());
908 Arc::new(typed(&lists(&in_use(shipped(), &languages, &countries), None), languages, countries))
909});
910
911fn lists(packs: &[&Pack], changes: Option<&toml::Table>) -> Node {
913 let mut tree = Node::table();
914 for pack in packs {
915 merge(&mut tree, &pack.lists);
916 }
917 if let Some(changes) = changes {
918 apply(&mut tree, changes);
919 }
920 tidy(&mut tree);
921 tree
922}
923
924fn typed(tree: &Node, languages: Vec<String>, countries: Vec<String>) -> Words {
925 let mut words: Words = value_of(tree, "").try_into().unwrap_or_else(|e| {
926 eprintln!("sioul: the words: {e}");
927 Words::default()
928 });
929 words.languages = languages;
930 words.countries = countries;
931 words
932}
933
934impl Words {
935 pub fn builtin() -> Arc<Words> {
939 BUILTIN.clone()
940 }
941
942 pub fn builtin_ref() -> &'static Words {
944 &BUILTIN
945 }
946
947 pub fn of(config: &Config) -> Arc<Words> {
950 type Cache = Mutex<Vec<(String, Arc<Words>)>>;
951 static CACHE: LazyLock<Cache> = LazyLock::new(|| Mutex::new(Vec::new()));
952 let (languages, countries) = (languages(config), countries(config));
953 let key = format!("{languages:?}|{countries:?}|{}|{:?}|{:?}|{:?}", toml::to_string(&config.words).unwrap_or_default(), config.filed_words, config.quiet.personal, config.quiet.work);
956 if let Ok(cache) = CACHE.lock()
957 && let Some((_, words)) = cache.iter().find(|(k, _)| *k == key)
958 {
959 return words.clone();
960 }
961 let tree = lists(&in_use(newest(), &languages, &countries), Some(&config.words));
962 let mut words = typed(&tree, languages, countries);
963 older_settings(config, &mut words);
964 let words = Arc::new(words);
965 if let Ok(mut cache) = CACHE.lock() {
966 cache.retain(|(k, _)| *k != key);
967 cache.insert(0, (key, words.clone()));
968 cache.truncate(4);
969 }
970 words
971 }
972}
973
974fn older_settings(config: &Config, words: &mut Words) {
978 let changed = |recogniser: &str, list: &str| config.words.get(recogniser).and_then(|r| r.get(list)).is_some();
979 if let Some(filed) = config.filed_words.as_ref().filter(|f| !f.is_empty())
980 && !changed("senders", "automatic")
981 {
982 words.senders.automatic = filed.clone();
983 }
984 if let Some(personal) = &config.quiet.personal
985 && !changed("tasks", "personal")
986 {
987 words.tasks.personal = personal.clone();
988 }
989 if let Some(work) = &config.quiet.work
990 && !changed("tasks", "work")
991 {
992 words.tasks.work = work.clone();
993 }
994}
995
996static CURRENT: std::sync::RwLock<Option<Arc<Words>>> = std::sync::RwLock::new(None);
998
999pub fn current() -> Arc<Words> {
1005 CURRENT.read().ok().and_then(|c| c.clone()).unwrap_or_else(Words::builtin)
1006}
1007
1008pub fn set_current(words: Arc<Words>) {
1010 if let Ok(mut current) = CURRENT.write() {
1011 *current = Some(words);
1012 }
1013}
1014
1015#[cfg(test)]
1019pub(crate) fn with_processor() -> Arc<Words> {
1020 let config: Config = toml::from_str(
1021 "[words.payments.paid]\nadd = [\"recu pour votre paiement\", \"recu de votre paiement\", \"vous avez autorise un paiement\", \"vous avez envoye un paiement\"]\n\
1022 [words.payments.not_payments]\nadd = [\"rapport d'activite\"]\n[words.payments.to]\nadd = [\"paiement a\"]\n[words.payments.from]\nadd = [\"paiement de\"]\n\
1023 [words.payments.leads]\nadd = [\"en faveur de\"]\n[words.money.stated]\nadd = [\"vous avez autorise un paiement de\"]\n",
1024 )
1025 .unwrap_or_default();
1026 let (languages, countries) = (FIRST_LANGUAGES.map(String::from).to_vec(), FIRST_COUNTRIES.map(String::from).to_vec());
1027 Arc::new(typed(&lists(&in_use(shipped(), &languages, &countries), Some(&config.words)), languages, countries))
1028}
1029
1030pub struct Compared {
1033 pub shipped: Node,
1034 pub yours: Node,
1035}
1036
1037pub fn compared(config: &Config) -> Compared {
1039 let packs = in_use(newest(), &languages(config), &countries(config));
1040 Compared { shipped: lists(&packs, None), yours: lists(&packs, Some(&config.words)) }
1041}
1042
1043fn changes_table<'a>(doc: &'a mut toml_edit::DocumentMut, path: &str) -> Option<&'a mut toml_edit::Table> {
1048 let mut table = doc.as_table_mut();
1049 if !table.contains_key("words") {
1050 let mut words = toml_edit::Table::new();
1051 words.set_implicit(true);
1052 table.insert("words", toml_edit::Item::Table(words));
1053 }
1054 table = table.get_mut("words")?.as_table_mut()?;
1055 for part in path.split('.').filter(|p| !p.is_empty()) {
1056 if !table.contains_key(part) {
1057 let mut next = toml_edit::Table::new();
1058 next.set_implicit(true);
1059 table.insert(part, toml_edit::Item::Table(next));
1060 }
1061 table = table.get_mut(part)?.as_table_mut()?;
1062 }
1063 Some(table)
1064}
1065
1066fn drop_changes(doc: &mut toml_edit::DocumentMut, path: &str) {
1068 fn prune(table: &mut toml_edit::Table, parts: &[&str]) {
1069 let Some((first, rest)) = parts.split_first() else { return };
1070 let Some(child) = table.get_mut(first).and_then(toml_edit::Item::as_table_mut) else { return };
1071 if rest.is_empty() {
1072 child.remove("add");
1073 child.remove("remove");
1074 } else {
1075 prune(child, rest);
1076 }
1077 if child.is_empty() {
1078 table.remove(first);
1079 }
1080 }
1081 let parts: Vec<&str> = std::iter::once("words").chain(path.split('.').filter(|p| !p.is_empty())).collect();
1082 prune(doc.as_table_mut(), &parts);
1083}
1084
1085fn array(words: &[String]) -> toml_edit::Item {
1086 toml_edit::value(words.iter().map(String::as_str).collect::<toml_edit::Array>())
1087}
1088
1089pub fn write_list(config_path: &Path, path: &str, wanted: &[String], shipped: &[String]) -> Result<(), String> {
1094 crate::filelock::with_lock(config_path, || {
1096 let shipped_folded: Vec<String> = shipped.iter().map(|w| folded(w)).collect();
1097 let wanted_folded: Vec<String> = wanted.iter().map(|w| folded(w)).collect();
1098 let add: Vec<String> = deduplicated(wanted.iter().filter(|w| !folded(w).is_empty() && !shipped_folded.contains(&folded(w))).map(|w| w.trim().to_string()).collect());
1099 let remove: Vec<String> = shipped.iter().filter(|w| !wanted_folded.contains(&folded(w))).cloned().collect();
1100 let mut doc = crate::config::read_document(config_path)?;
1101 drop_changes(&mut doc, path);
1102 if !add.is_empty() || !remove.is_empty() {
1103 let table = changes_table(&mut doc, path).ok_or_else(|| format!("{}: words.{path} is not a table", config_path.display()))?;
1104 table.set_implicit(false);
1105 if !add.is_empty() {
1106 table.insert("add", array(&add));
1107 }
1108 if !remove.is_empty() {
1109 table.insert("remove", array(&remove));
1110 }
1111 }
1112 crate::config::write_document(config_path, &doc)
1113 })
1114}
1115
1116pub fn reset(config_path: &Path, recogniser: &str) -> Result<(), String> {
1118 crate::filelock::with_lock(config_path, || {
1120 let mut doc = crate::config::read_document(config_path)?;
1121 if let Some(words) = doc.get_mut("words").and_then(toml_edit::Item::as_table_mut) {
1122 words.remove(recogniser);
1123 if words.is_empty() {
1124 doc.remove("words");
1125 }
1126 }
1127 crate::config::write_document(config_path, &doc)
1128 })
1129}
1130
1131pub fn reset_lists(config_path: &Path, paths: &[&str]) -> Result<(), String> {
1134 crate::filelock::with_lock(config_path, || {
1136 let mut doc = crate::config::read_document(config_path)?;
1137 for path in paths {
1138 drop_changes(&mut doc, path);
1139 }
1140 crate::config::write_document(config_path, &doc)
1141 })
1142}
1143
1144pub fn write_names(config_path: &Path, path: &str, wanted: &[(String, Vec<String>)], shipped: &[(String, Vec<String>)]) -> Result<(), String> {
1152 crate::filelock::with_lock(config_path, || {
1154 let folded_all = |words: &[String]| words.iter().map(|w| folded(w)).filter(|w| !w.is_empty()).collect::<Vec<_>>();
1155 let trimmed = |words: &[String]| deduplicated(words.iter().map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect());
1156 let mut add: Vec<(String, Vec<String>)> = Vec::new();
1157 let mut remove: Vec<String> = Vec::new();
1158 for (name, words) in shipped {
1159 let Some((_, wanted_words)) = wanted.iter().find(|(n, _)| folded(n) == folded(name)) else {
1160 remove.push(name.clone());
1161 continue;
1162 };
1163 let (have, want) = (folded_all(words), folded_all(wanted_words));
1164 if have.iter().all(|w| want.contains(w)) {
1165 let more: Vec<String> = trimmed(wanted_words).into_iter().filter(|w| !have.contains(&folded(w))).collect();
1166 if !more.is_empty() {
1167 add.push((name.clone(), more));
1168 }
1169 } else {
1170 remove.push(name.clone());
1171 add.push((name.clone(), trimmed(wanted_words)));
1172 }
1173 }
1174 for (name, words) in wanted {
1175 if !folded(name).is_empty() && !shipped.iter().any(|(n, _)| folded(n) == folded(name)) && !add.iter().any(|(n, _)| folded(n) == folded(name)) {
1176 add.push((name.trim().to_string(), trimmed(words)));
1177 }
1178 }
1179 let mut doc = crate::config::read_document(config_path)?;
1180 drop_changes(&mut doc, path);
1181 if !add.is_empty() || !remove.is_empty() {
1182 let table = changes_table(&mut doc, path).ok_or_else(|| format!("{}: words.{path} is not a table", config_path.display()))?;
1183 table.set_implicit(false);
1184 if !add.is_empty() {
1185 let mut names = toml_edit::InlineTable::new();
1186 for (name, words) in &add {
1187 names.insert(name.as_str(), toml_edit::Value::Array(words.iter().map(String::as_str).collect()));
1188 }
1189 table.insert("add", toml_edit::value(names));
1190 }
1191 if !remove.is_empty() {
1192 table.insert("remove", array(&remove));
1193 }
1194 }
1195 crate::config::write_document(config_path, &doc)
1196 })
1197}
1198
1199pub fn write_setting(config_path: &Path, key: &str, values: &[String]) -> Result<(), String> {
1201 crate::filelock::with_lock(config_path, || {
1203 if !matches!(key, "languages" | "countries") {
1204 return Err(format!("words.{key}: no such setting"));
1205 }
1206 let mut doc = crate::config::read_document(config_path)?;
1207 let table = changes_table(&mut doc, "").ok_or_else(|| format!("{}: words is not a table", config_path.display()))?;
1208 table.set_implicit(false);
1209 table.insert(key, array(&deduplicated(values.to_vec())));
1210 crate::config::write_document(config_path, &doc)
1211 })
1212}
1213
1214pub fn keep_first(config_path: &Path) -> Result<bool, String> {
1219 crate::filelock::with_lock(config_path, || {
1221 let Ok(text) = std::fs::read_to_string(config_path) else { return Ok(false) };
1222 let doc: toml_edit::DocumentMut = text.parse().map_err(|e| format!("{}: {e}", config_path.display()))?;
1223 if doc.contains_key("words") {
1224 return Ok(false);
1225 }
1226 write_setting(config_path, "languages", &FIRST_LANGUAGES.map(String::from))?;
1227 write_setting(config_path, "countries", &FIRST_COUNTRIES.map(String::from))?;
1228 Ok(true)
1229 })
1230}
1231
1232pub fn keep_first_once(config_path: &Path, marker: &Path) -> Result<bool, String> {
1237 if marker.exists() {
1238 return Ok(false);
1239 }
1240 let wrote = keep_first(config_path)?;
1241 if let Some(parent) = marker.parent() {
1242 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
1243 }
1244 std::fs::write(marker, b"").map_err(|e| format!("{}: {e}", marker.display()))?;
1245 Ok(wrote)
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250 use super::*;
1251
1252 fn config(text: &str) -> Config {
1253 toml::from_str(text).unwrap()
1254 }
1255
1256 fn known() -> Vec<String> {
1258 let value = toml::Value::try_from(Words::default()).unwrap();
1259 fn walk(value: &toml::Value, path: &str, out: &mut Vec<String>) {
1260 match value {
1261 toml::Value::Table(table) => {
1262 for (key, child) in table {
1263 walk(child, &if path.is_empty() { key.clone() } else { format!("{path}.{key}") }, out);
1264 }
1265 }
1266 _ => out.push(path.to_string()),
1267 }
1268 }
1269 let mut out = Vec::new();
1270 walk(&value, "", &mut out);
1271 out
1272 }
1273
1274 #[test]
1275 fn every_pack_reads_and_holds_known_lists_only() {
1276 let known = known();
1277 for (file, text) in SHIPPED {
1278 let pack = Pack::parse(file, text).unwrap_or_else(|| panic!("{file} is no TOML"));
1279 assert_eq!(pack.checked.len(), 10, "{file}: its checked date");
1280 for (path, words) in pack.lists.flatten() {
1281 let map = MAPS.iter().find(|m| path.starts_with(&format!("{m}.")));
1282 assert!(known.contains(&path) || map.is_some(), "{file}: {path} is no list Sioul reads");
1283 for word in &words {
1284 assert!(!folded(word).is_empty(), "{file}: {path} holds an empty word");
1285 }
1286 }
1287 }
1288 assert_eq!(available_languages(), ["en", "fr", "de", "es", "it"]);
1289 assert_eq!(available_countries(), ["FR"]);
1290 }
1291
1292 #[test]
1295 fn the_packs_give_back_the_lists_of_before() {
1296 let golden: serde_json::Value = serde_json::from_str(include_str!("../tests/fixtures/words-golden.json")).unwrap();
1297 let golden = golden["lists"].as_object().unwrap();
1298 let (languages, countries) = (FIRST_LANGUAGES.map(String::from).to_vec(), FIRST_COUNTRIES.map(String::from).to_vec());
1299 let tree = lists(&in_use(shipped(), &languages, &countries), None);
1300 let ours: std::collections::BTreeMap<String, Vec<String>> = tree.flatten().into_iter().collect();
1301 let set = |words: &[String]| words.iter().map(|w| folded(w)).collect::<std::collections::BTreeSet<_>>();
1302 for (path, words) in golden {
1303 let words: Vec<String> = words.as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect();
1304 let ours_path = if path == "ocr.languages" { "ocr.tesseract" } else { path.as_str() };
1305 let found = ours.get(ours_path).cloned().unwrap_or_default();
1306 assert_eq!(set(&found), set(&words), "{path}");
1307 }
1308 for path in ours.keys() {
1310 let before = if path == "ocr.tesseract" { "ocr.languages" } else { path.as_str() };
1311 assert!(golden.contains_key(before), "{path} is new");
1312 }
1313 }
1314
1315 #[test]
1316 fn typed_lists_and_named_lists_in_their_order() {
1317 let words = Words::builtin();
1318 assert!(words.codes.code.iter().any(|w| w == "code de verification") && words.codes.code.iter().any(|w| w == "verification code"));
1319 assert_eq!(words.letters.months.iter().find(|(n, _)| n == "10").map(|(_, w)| w.len()), Some(3));
1320 let brands: Vec<&str> = words.brands.brands.iter().map(|(n, _)| n.as_str()).collect();
1322 assert_eq!(brands.first(), Some(&"Ameli"));
1323 assert!(brands.contains(&"Amazon") && brands.contains(&"Boursorama"));
1324 assert_eq!(words.capture.in_, ["dans", "in"]);
1325 assert_eq!(words.ocr.tesseract, ["fra", "eng"]);
1326 assert_eq!((words.languages.as_slice(), words.countries.as_slice()), (&["fr".to_string(), "en".into()][..], &["FR".to_string()][..]));
1327 }
1328
1329 #[test]
1330 fn your_changes_merge_with_the_packs() {
1331 let config = config(
1332 "[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n\n\
1333 [words.codes.code]\nadd = [\"Bestätigungscode\", \"código de verificación\"]\nremove = [\"Code Temporaire\"]\n\n\
1334 [words.folders.trash]\nadd = [\"Papierkorb\"]\nremove = [\"bin\", \"corbeille\"]\n\n\
1335 [words.senders.automatic]\nadd = [\"noreply\", \"nicht-antworten\"]\nremove = [\"noreply\"]\n\n\
1336 [words.brands.brands]\nadd = { \"Ma Banque\" = [\"mabanque.example\"], Amazon = [\"amazon.example\"] }\nremove = [\"boursorama\"]\n\n\
1337 [words.letters.senders.\"Ma Caisse\"]\nadd = [\"caisse exemple\"]\n\n\
1338 [words.newer.list]\nadd = [\"anything\"]\n",
1339 );
1340 let words = Words::of(&config);
1341 let has = |list: &[String], w: &str| list.iter().any(|x| x == w);
1342 assert!(has(&words.codes.code, "Bestätigungscode") && has(&words.codes.code, "código de verificación"));
1343 assert!(!has(&words.codes.code, "code temporaire"), "taken away, capitals aside");
1344 assert!(has(&words.folders.trash, "Papierkorb") && !has(&words.folders.trash, "bin") && !has(&words.folders.trash, "corbeille"));
1345 assert!(has(&words.folders.trash, "trash"));
1346 assert!(has(&words.senders.automatic, "noreply"), "taken away and added: put back");
1347 assert_eq!(words.senders.automatic.iter().filter(|w| *w == "noreply").count(), 1);
1348 let brand = |name: &str| words.brands.brands.iter().find(|(n, _)| n == name).map(|(_, d)| d.clone());
1349 assert_eq!(brand("Ma Banque"), Some(vec!["mabanque.example".to_string()]));
1350 assert!(brand("Amazon").unwrap().contains(&"amazon.example".to_string()) && brand("Amazon").unwrap().contains(&"amazon.fr".to_string()));
1351 assert_eq!(brand("Boursorama"), None, "a named list's entry taken away by its name, capitals aside");
1352 assert!(words.letters.senders.iter().any(|(n, w)| n == "Ma Caisse" && w == &["caisse exemple"]));
1353 assert_eq!(words.codes.reset, Words::builtin().codes.reset);
1355 }
1356
1357 #[test]
1358 fn languages_and_countries_in_use() {
1359 let english = config("language = \"en\"\n[words]\nlanguages = [\"en\"]\ncountries = []\n");
1360 let words = Words::of(&english);
1361 assert!(words.codes.code.iter().all(|w| !w.starts_with("code de")), "no French");
1362 assert!(words.brands.brands.iter().all(|(n, _)| n != "Ameli"), "no French names");
1363 assert!(words.brands.brands.iter().any(|(n, _)| n == "Amazon"), "the international pack, always");
1364 assert!(words.folders.sent.iter().any(|w| w == "envoyés"), "folders' names, whatever you read");
1365 assert_eq!(words.ocr.tesseract, ["eng"]);
1366 let unsaid = config("language = \"fr\"\n[contacts]\nregion = \"fr\"\n");
1367 assert_eq!((languages(&unsaid), countries(&unsaid)), (vec!["fr".to_string(), "en".into()], vec!["FR".to_string()]));
1368 let english_first = config("language = \"en_GB\"\n[words]\ncountries = [\"fr\", \"FR\"]\n");
1369 assert_eq!((languages(&english_first), countries(&english_first)), (vec!["en".to_string()], vec!["FR".to_string()]));
1370 }
1371
1372 #[test]
1374 fn german_spanish_and_italian_once_read() {
1375 let today: jiff::civil::Date = "2026-10-03".parse().unwrap();
1376 for (language, subject, body, bin, friday) in [
1377 ("de", "Ihr Bestätigungscode", "Ihr Bestätigungscode lautet 482913. Er ist 10 Minuten gültig.", "Papierkorb", "Freitag"),
1378 ("es", "Tu código de verificación", "Tu código de verificación es 482913. Caduca en 10 minutos.", "Papelera", "viernes"),
1379 ("it", "Il tuo codice di verifica", "Il tuo codice di verifica è 482913. Scade tra 10 minuti.", "Cestino", "venerdi"),
1380 ] {
1381 let words = Words::of(&config(&format!("[words]\nlanguages = [\"{language}\"]\ncountries = []\n")));
1382 let found = crate::codes::detect(&words.codes, subject, body).unwrap_or_else(|| panic!("{language}: no code"));
1383 assert_eq!(found.code.as_deref(), Some("482913"), "{language}");
1384 assert_eq!(crate::folders::role_in(&words.folders, bin, None), crate::folders::Role::Trash, "{language}");
1385 let day = crate::capture::parse_day_with(&words.capture, friday, today).map(|d| d.to_string());
1386 assert_eq!(day.as_deref(), Some("2026-10-09"), "{language}");
1387 assert_eq!(words.ocr.tesseract.len(), 1, "{language}");
1388 }
1389 let english = Words::of(&config("[words]\nlanguages = [\"en\"]\ncountries = []\n"));
1390 assert!(crate::codes::detect(&english.codes, "Ihr Bestätigungscode", "Ihr Bestätigungscode lautet 482913.").is_none());
1391 assert_eq!(crate::folders::role_in(&english.folders, "Papierkorb", None), crate::folders::Role::Other);
1392 }
1393
1394 fn scratch(name: &str) -> PathBuf {
1395 let dir = std::env::temp_dir().join(format!("sioul-words-{name}-{}", std::process::id()));
1396 let _ = std::fs::remove_dir_all(&dir);
1397 std::fs::create_dir_all(&dir).unwrap();
1398 dir.join("config.toml")
1399 }
1400
1401 #[test]
1402 fn writing_keeps_only_the_differences() {
1403 let path = scratch("write");
1404 std::fs::write(&path, "# Mine\nfetch_minutes = 15\n\n[mail]\nthreads = true # by conversation\n").unwrap();
1405 let shipped = Words::builtin().folders.trash.clone();
1406 let mut wanted: Vec<String> = shipped.iter().filter(|w| *w != "bin").cloned().collect();
1407 wanted.push("Papierkorb".into());
1408 write_list(&path, "folders.trash", &wanted, &shipped).unwrap();
1409 let text = std::fs::read_to_string(&path).unwrap();
1410 assert!(text.contains("# Mine") && text.contains("# by conversation"), "comments kept: {text}");
1411 assert!(text.contains("[words.folders.trash]") && !text.contains("[words]\n") && !text.contains("[words.folders]\n"), "{text}");
1412 let config = Config::load(&path).unwrap();
1413 let words = Words::of(&config);
1414 assert!(words.folders.trash.contains(&"Papierkorb".to_string()) && !words.folders.trash.contains(&"bin".to_string()));
1415 write_list(&path, "folders.trash", &shipped, &shipped).unwrap();
1417 let text = std::fs::read_to_string(&path).unwrap();
1418 assert!(!text.contains("words"), "{text}");
1419 write_setting(&path, "languages", &["fr".into(), "en".into()]).unwrap();
1421 write_list(&path, "codes.code", &["mon code".into()], &[]).unwrap();
1422 reset(&path, "codes").unwrap();
1423 let config = Config::load(&path).unwrap();
1424 assert_eq!(languages(&config), ["fr", "en"]);
1425 assert!(!std::fs::read_to_string(&path).unwrap().contains("codes"));
1426 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1427 }
1428
1429 #[test]
1430 fn an_older_configuration_keeps_what_it_read() {
1431 let path = scratch("first");
1432 std::fs::write(&path, "language = \"en\"\n").unwrap();
1433 assert!(keep_first(&path).unwrap());
1434 let config = Config::load(&path).unwrap();
1435 assert_eq!((languages(&config), countries(&config)), (vec!["fr".to_string(), "en".into()], vec!["FR".to_string()]));
1436 assert!(!keep_first(&path).unwrap(), "once");
1437 let none = path.with_file_name("none.toml");
1438 assert!(!keep_first(&none).unwrap(), "no configuration, nothing written");
1439 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1440 }
1441
1442 #[test]
1444 fn the_first_start_keeps_an_older_configuration_once() {
1445 let path = scratch("once");
1446 let marker = path.with_file_name("state").join("words-first-start");
1447 std::fs::write(&path, "language = \"en\"\n").unwrap();
1448 assert!(keep_first_once(&path, &marker).unwrap());
1449 assert!(marker.exists());
1450 std::fs::write(&path, "language = \"en\"\n").unwrap();
1451 assert!(!keep_first_once(&path, &marker).unwrap());
1452 assert!(!std::fs::read_to_string(&path).unwrap().contains("words"));
1453 let new = path.with_file_name("new.toml");
1455 let new_marker = path.with_file_name("new-state").join("words-first-start");
1456 assert!(!keep_first_once(&new, &new_marker).unwrap());
1457 assert!(new_marker.exists());
1458 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1459 }
1460
1461 #[test]
1464 fn a_named_list_is_written_as_its_differences() {
1465 let path = scratch("names");
1466 std::fs::write(&path, "[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n").unwrap();
1467 let shipped = Words::builtin().brands.brands.clone();
1468 let mut wanted = shipped.clone();
1469 wanted.retain(|(name, _)| name != "Chronopost");
1470 if let Some((_, domains)) = wanted.iter_mut().find(|(name, _)| name == "Colissimo") {
1471 domains.push("colissimo.example".into());
1472 }
1473 wanted.push(("Ma Banque".into(), vec!["mabanque.example".into()]));
1474 write_names(&path, "brands.brands", &wanted, &shipped).unwrap();
1475 let brands = Words::of(&Config::load(&path).unwrap()).brands.brands.clone();
1476 assert!(brands.iter().any(|(n, d)| n == "Ma Banque" && d == &["mabanque.example".to_string()]), "{brands:?}");
1477 assert!(!brands.iter().any(|(n, _)| n == "Chronopost"));
1478 let colissimo = |brands: &[(String, Vec<String>)]| brands.iter().find(|(n, _)| n == "Colissimo").map(|(_, d)| d.clone()).unwrap_or_default();
1479 assert!(colissimo(&brands).contains(&"colissimo.example".to_string()) && colissimo(&brands).contains(&"laposte.fr".to_string()));
1480 let mut fewer = brands.clone();
1481 if let Some((_, domains)) = fewer.iter_mut().find(|(name, _)| name == "Colissimo") {
1482 domains.retain(|d| d != "laposte.fr");
1483 }
1484 write_names(&path, "brands.brands", &fewer, &shipped).unwrap();
1485 let brands = Words::of(&Config::load(&path).unwrap()).brands.brands.clone();
1486 assert!(!colissimo(&brands).contains(&"laposte.fr".to_string()) && colissimo(&brands).contains(&"colissimo.example".to_string()), "{brands:?}");
1487 write_names(&path, "brands.brands", &shipped, &shipped).unwrap();
1488 assert!(!std::fs::read_to_string(&path).unwrap().contains("brands"));
1489 write_list(&path, "folders.trash", &["Papierkorb".into()], &Words::builtin().folders.trash).unwrap();
1491 write_list(&path, "folders.sent", &["Gesendet".into()], &Words::builtin().folders.sent).unwrap();
1492 reset_lists(&path, &["folders.trash", "folders.sent"]).unwrap();
1493 let text = std::fs::read_to_string(&path).unwrap();
1494 assert!(!text.contains("folders") && text.contains("languages"), "{text}");
1495 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1496 }
1497}