Skip to main content

sioul_core/
config.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The configuration file: accounts, admin windows, the notes folder.
5//!
6//! Default location: `$XDG_CONFIG_HOME/sioul/config.toml`, else
7//! `~/.config/sioul/config.toml`. Passwords never go in it: they belong in the
8//! system keyring (docs/architecture.md). See examples/config.toml.
9//!
10//! Sioul writes to this file too, when you add or remove an account. It edits
11//! it in place with `toml_edit`, so your comments and your order stay.
12
13use crate::window::AdminWindow;
14use serde::Deserialize;
15use std::path::{Path, PathBuf};
16use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, value};
17
18#[derive(Debug, Default, Clone, Deserialize)]
19pub struct Config {
20    /// The language of what Sioul says ("fr", "en"); the session's language if unset.
21    pub language: Option<String>,
22    /// "light" or "dark"; the system's colours if unset.
23    #[serde(default)]
24    pub theme: Option<String>,
25    /// The places, on the left of the window, with their names beside their
26    /// icons: for whoever reads words more easily than icons. Their icons alone if unset.
27    #[serde(default)]
28    pub places_named: bool,
29    /// colour: the sites shown in the screen's own colours, read from its
30    /// colour profile (docs/colour.md); on unless set to false.
31    #[serde(default)]
32    pub screen_colours: Option<bool>,
33    /// colour: loud colours on sites softened: "little", "more"; off if unset.
34    #[serde(default)]
35    pub calmer_colours: Option<String>,
36    /// The notes folder: your Markdown files, with beside them your projects,
37    /// budgets and papers. Its key in the file, `case_store`, is its first
38    /// name, kept so that every configuration reads as before.
39    #[serde(rename = "case_store")]
40    pub notes_root: Option<String>,
41    /// Projects, budgets and the bank's movements (`sioul-projects.toml`,
42    /// `sioul-budgets.toml`, `sioul-bank.toml`) travel sealed with the sharing
43    /// between your devices, for a notes folder no sync carries; else they
44    /// travel with that folder (docs/database.md).
45    #[serde(default)]
46    pub share_projects: bool,
47    /// A text file of senders you know: one address or `@domain` per line.
48    pub known_senders: Option<String>,
49    /// A text file of senders you blocked, in the same form.
50    pub blocked_senders: Option<String>,
51    /// How far back mail and the agenda reach, in weeks; 0 for everything.
52    /// Raising it fetches the older mail the next sync does not have yet.
53    #[serde(default)]
54    pub history_weeks: Option<u32>,
55    /// Minutes between two fetches of the folders a server does not push (IDLE keeps the inbox current).
56    #[serde(default)]
57    pub fetch_minutes: Option<u32>,
58    /// Where new notes go in the notes folder.
59    #[serde(default)]
60    pub notes_folder: Option<String>,
61    /// Words that make a sender automatic, its mail filed: "no-reply",
62    /// "notification"… The whole list, as older settings wrote it; now
63    /// `[words.senders.automatic]` (`words`), which wins once written.
64    #[serde(default)]
65    pub filed_words: Option<Vec<String>>,
66    /// How long text reads: the family, size and line spacing of notes, mail and task notes.
67    #[serde(default)]
68    pub reading: Reading,
69    #[serde(default)]
70    pub mail: MailSettings,
71    /// Sioul's own spam filter: what it does with its verdicts, how sure it must be (`[spam]`).
72    #[serde(default)]
73    pub spam: SpamSettings,
74    #[serde(default)]
75    pub tasks: TaskSettings,
76    /// What a day holds, as the plan learns it (`[planning]`, docs/capacity.md).
77    #[serde(default)]
78    pub planning: PlanningSettings,
79    #[serde(default)]
80    pub agenda: AgendaSettings,
81    #[serde(default)]
82    pub map: MapSettings,
83    /// Contacts: the country of phone numbers written without one.
84    #[serde(default)]
85    pub contacts: ContactSettings,
86    /// The words Sioul looks for (`[words]`, docs/words.md): the languages and
87    /// countries whose packs are read, and your changes to each list
88    /// (`words::Words::of`), kept as written.
89    #[serde(default)]
90    pub words: toml::Table,
91    /// Reminders before dates: events, dates asked, waits, payments (docs/reminders.md).
92    #[serde(default)]
93    pub reminders: ReminderSettings,
94    /// What each kind of notification did at each time, before `[attention]`
95    /// (`[notify]`): read once, to seed it (`attention::seeded`).
96    #[serde(default)]
97    pub notify: crate::attention::NotifySettings,
98    /// What reaches you, and when: a row per who on each channel and per kind
99    /// (`[attention]`, `attention::Attention`, docs/attention.md); none while
100    /// never written, the older keys seeding it.
101    #[serde(default)]
102    pub attention: Option<crate::attention::Settings>,
103    /// Free time, the global pause (`[free_time]`, docs/pauses.md).
104    #[serde(default)]
105    pub free_time: crate::pause::FreeTimeSettings,
106    /// The pause, set up on a calm day (`[pause]`, docs/pauses.md).
107    #[serde(default)]
108    pub pause: crate::pause::PauseSettings,
109    /// Do-not-disturb on every device: what turns it on, who gets through (`[dnd]`, docs/do-not-disturb.md).
110    #[serde(default)]
111    pub dnd: crate::everywhere::DndSettings,
112    /// The call screening, apart from do-not-disturb (`[screening]`,
113    /// `screening::Screening::of`, docs/android.md, "Calls"): each key
114    /// unsaid at its first value; none while never written.
115    #[serde(default)]
116    pub screening: Option<crate::screening::Settings>,
117    /// Paper letters: where their scans arrive (docs/porch.md).
118    #[serde(default)]
119    pub letters: LetterSettings,
120    /// Routines: timed steps played one at a time (docs/tasks.md).
121    #[serde(rename = "routine", default)]
122    pub routines: Vec<crate::routines::Routine>,
123    /// The place of the weather in the status line; none, no weather.
124    #[serde(default)]
125    pub weather: WeatherSettings,
126    /// Your Bitwarden account, to fill sites' forms; read here, nothing installed.
127    #[serde(default)]
128    pub bitwarden: BitwardenSettings,
129    /// GitHub's issues and pull requests as tasks: off unless asked (docs/github.md).
130    #[serde(default)]
131    pub github: GithubSettings,
132    /// Who sends your invoices, and what an hour costs by default.
133    #[serde(default)]
134    pub invoice: InvoiceSettings,
135    /// What an AI agent connected to `sioul mcp` may use (`[mcp]`, docs/mcp.md).
136    #[serde(default)]
137    pub mcp: McpSettings,
138    /// Your accounts, those switched on (`Config::load` sets the others aside).
139    #[serde(rename = "account", default)]
140    pub accounts: Vec<Account>,
141    /// Sites kept open in Sioul (docs/sites.md): not accounts, they hold no
142    /// sign-in of Sioul's. Older files kept them as `[[account]]` of kind
143    /// "portal": `Config::load` reads them here, `migrate_sites` moves them.
144    #[serde(rename = "site", default)]
145    pub sites: Vec<Account>,
146    /// The order the Sites page shows them in, by their ids, as last chosen on
147    /// any device (`move_site`): your devices share each site apart (format 2,
148    /// docs/database.md), and this order with them. Sites it does not name
149    /// come after, in the file's order.
150    #[serde(default)]
151    pub site_order: Vec<String>,
152    /// Accounts switched off: kept with their settings, neither synced nor shown.
153    #[serde(skip)]
154    pub accounts_off: Vec<Account>,
155    #[serde(rename = "window", default)]
156    pub windows: Vec<AdminWindow>,
157    /// When offices are open, for tasks that need one; Monday to Friday 9:00–17:00 when unsaid.
158    #[serde(rename = "office_hours", default)]
159    pub office_hours: Vec<AdminWindow>,
160    /// Days off: holidays, sick leave; quiet from the first day to the last.
161    #[serde(rename = "time_off", default)]
162    pub time_off: Vec<TimeOff>,
163    #[serde(default)]
164    pub quiet: QuietSettings,
165    /// The Porch's own choices (docs/porch.md).
166    #[serde(default)]
167    pub porch: PorchSettings,
168    /// Who may reach you when, on each channel, before `[attention]`: read
169    /// once, to seed it (`attention::seeded`).
170    #[serde(default)]
171    pub reach: ReachSettings,
172    /// The camera, microphone and speaker calls in sites use (docs/sites.md).
173    #[serde(default)]
174    pub calls: CallSettings,
175}
176
177/// The devices of calls in sites, by the names the system gives them; unsaid, the system's own.
178#[derive(Debug, Clone, Default, PartialEq, Deserialize, serde::Serialize)]
179pub struct CallSettings {
180    #[serde(default)]
181    pub camera: Option<String>,
182    #[serde(default)]
183    pub microphone: Option<String>,
184    #[serde(default)]
185    pub speaker: Option<String>,
186}
187
188/// What the Porch shows of what Sioul knows.
189#[derive(Debug, Clone, PartialEq, Deserialize)]
190pub struct PorchSettings {
191    /// Projects whose lane the Porch leaves out: their mail stays on their page in Projects.
192    #[serde(default)]
193    pub hidden_projects: Vec<String>,
194    /// The calls your phones declined, listed on the Porch of every device
195    /// (docs/porch.md, "Calls declined"); off, each phone lists its own only,
196    /// and the computers none. On unless said.
197    #[serde(default = "yes")]
198    pub calls: bool,
199}
200
201impl Default for PorchSettings {
202    fn default() -> PorchSettings {
203        PorchSettings { hidden_projects: Vec::new(), calls: true }
204    }
205}
206
207/// Who may reach you when, channel by channel, as an older Sioul wrote it
208/// before `[attention]`: each row the columns ticked for it ("work",
209/// "admin", "leisure", "meals", "sleep", "pause"); mail's rows at the top of
210/// `[reach]`, calls' in `[reach.calls]`, messages' in `[reach.messages]`; a
211/// row left out kept its usual times, an empty one was never. Read once, to
212/// seed the matrix of what reaches you (`attention::seeded`).
213#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
214pub struct ReachSettings {
215    #[serde(default)]
216    pub safe: Option<Vec<String>>,
217    #[serde(default)]
218    pub neutral: Option<Vec<String>>,
219    #[serde(default)]
220    pub restricted: Option<Vec<String>>,
221    /// Mail from strangers. Its absence says the rows were written before the
222    /// five states: strangers then as the neutral, each row's pause as its sleep.
223    #[serde(default)]
224    pub stranger: Option<Vec<String>>,
225    #[serde(default)]
226    pub calls: RowSettings,
227    #[serde(default)]
228    pub messages: RowSettings,
229}
230
231/// One channel's rows, each the columns ticked for it; a row left out keeps its usual times.
232#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
233pub struct RowSettings {
234    #[serde(default)]
235    pub safe: Option<Vec<String>>,
236    #[serde(default)]
237    pub neutral: Option<Vec<String>>,
238    #[serde(default)]
239    pub restricted: Option<Vec<String>>,
240    #[serde(default)]
241    pub stranger: Option<Vec<String>>,
242    /// Calls only: hidden numbers.
243    #[serde(default)]
244    pub hidden: Option<Vec<String>>,
245}
246
247/// Days off, from one day to another, both included.
248#[derive(Debug, Clone, PartialEq, Deserialize)]
249pub struct TimeOff {
250    /// Its id, given when the sharing takes format 2 (one id per day off); empty before.
251    #[serde(default)]
252    pub id: String,
253    #[serde(deserialize_with = "crate::budget::dates::required")]
254    pub from: jiff::civil::Date,
255    #[serde(deserialize_with = "crate::budget::dates::required")]
256    pub until: jiff::civil::Date,
257    #[serde(default)]
258    pub label: String,
259}
260
261/// What quiet time keeps: the categories of tasks that are yours (family,
262/// friends, leisure). Read through the word lists (`words::TaskWords`:
263/// `personal`, `work`), which these settings replace while written.
264#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
265pub struct QuietSettings {
266    /// Task categories that stay in view in quiet time; the languages' own
267    /// (`words.tasks.personal`: "joy", "personal", "family"…) when unsaid.
268    #[serde(default)]
269    pub personal: Option<Vec<String>>,
270    /// Task categories that are work: never in view in quiet time; the
271    /// languages' own (`words.tasks.work`: "work", "travail", "pro"…) when unsaid.
272    #[serde(default)]
273    pub work: Option<Vec<String>>,
274}
275
276impl Config {
277    /// Working hours, for quiet time: the days written with an end (by the week's
278    /// editor). Older admin windows, a start and minutes, keep opening the Porch
279    /// and leave quiet time off until working hours are set.
280    pub fn working_hours(&self) -> Vec<AdminWindow> {
281        self.windows.iter().filter(|w| w.end.is_some() && w.kind() == "work").cloned().collect()
282    }
283
284    /// The week's hours: work's and admin's (docs/areas.md). Leisure is
285    /// every other time: the free time an older Sioul set as hours is read
286    /// and left aside, kept in the file.
287    pub fn week_hours(&self) -> Vec<AdminWindow> {
288        self.windows.iter().filter(|w| w.end.is_some() && w.kind() != "leisure").cloned().collect()
289    }
290
291    /// When offices are open: the configuration's, else Monday to Friday 9:00–17:00.
292    pub fn office_hours(&self) -> Vec<AdminWindow> {
293        if self.office_hours.is_empty() { crate::window::default_office_hours() } else { self.office_hours.clone() }
294    }
295}
296
297/// How long text reads (docs/client.md, "Calm first"): lines need air.
298#[derive(Debug, Clone, PartialEq, Deserialize)]
299pub struct Reading {
300    /// A font family; empty for the desktop's.
301    #[serde(default)]
302    pub family: String,
303    #[serde(default = "Reading::default_size")]
304    pub size: u32,
305    /// Line height, as a multiple of the font's.
306    #[serde(default = "Reading::default_spacing")]
307    pub spacing: f64,
308}
309
310impl Reading {
311    fn default_size() -> u32 {
312        16
313    }
314
315    fn default_spacing() -> f64 {
316        1.5
317    }
318}
319
320impl Default for Reading {
321    fn default() -> Reading {
322        Reading { family: String::new(), size: Reading::default_size(), spacing: Reading::default_spacing() }
323    }
324}
325
326/// Sioul's own spam filter (`[spam]`, docs/spam-filter.md): what it does with
327/// each verdict, and how sure it must be. One filter for every account.
328#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
329pub struct SpamSettings {
330    /// An older Sioul's one choice, read until the matrix is first changed
331    /// (`spam::Actions::of_mode`): "off", "say", "act".
332    #[serde(default)]
333    pub mode: Option<String>,
334    /// What is done with probable spam, with a doubt, with probable ham:
335    /// "move" (into the Junk folder on the server), "flag" (the review
336    /// queue), "nothing". Unsaid: as `mode` said, else spam and doubts flagged.
337    #[serde(default)]
338    pub action_spam: Option<String>,
339    #[serde(default)]
340    pub action_unsure: Option<String>,
341    #[serde(default)]
342    pub action_ham: Option<String>,
343    /// From this probability on, spam: 0.95 when unsaid.
344    #[serde(default)]
345    pub threshold_spam: Option<f32>,
346    /// From this one on, unsure: 0.5 when unsaid, never above the other.
347    #[serde(default)]
348    pub threshold_unsure: Option<f32>,
349    /// Trained again by itself, once a week, on the computer that made the
350    /// table in use, when it is plugged in and idle (`sioul_learn::auto`):
351    /// on when unsaid.
352    #[serde(default)]
353    pub train_by_itself: Option<bool>,
354}
355
356impl SpamSettings {
357    /// Whether the filter trains again by itself (on unless said otherwise).
358    pub fn trains_by_itself(&self) -> bool {
359        self.train_by_itself.unwrap_or(true)
360    }
361
362    /// The matrix: each class's action as written, else as an older `mode`
363    /// meant it, else spam and doubts flagged, nothing done with the rest.
364    pub fn actions(&self) -> crate::spam::Actions {
365        use crate::spam::{Action, Actions, Class};
366        let base = self.mode.as_deref().and_then(Actions::of_mode).unwrap_or_default();
367        [(Class::Spam, &self.action_spam), (Class::Unsure, &self.action_unsure), (Class::Ham, &self.action_ham)]
368            .into_iter()
369            .fold(base, |actions, (class, written)| match written.as_deref().and_then(Action::read) {
370                Some(action) => actions.with(class, action),
371                None => actions,
372            })
373    }
374
375    /// The thresholds, spam then unsure: each between 0 and 1 (its default
376    /// otherwise), the unsure one never above the other.
377    pub fn thresholds(&self) -> (f32, f32) {
378        let valid = |t: Option<f32>| t.filter(|t| t.is_finite() && (0.0..=1.0).contains(t));
379        let spam = valid(self.threshold_spam).unwrap_or(crate::spam::THRESHOLD_SPAM);
380        let unsure = valid(self.threshold_unsure).unwrap_or(crate::spam::THRESHOLD_UNSURE);
381        (spam, unsure.min(spam))
382    }
383}
384
385#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
386pub struct MailSettings {
387    /// Folders shown by conversation, your answers from Sent among them.
388    #[serde(default)]
389    pub threads: bool,
390    /// The mail filters, one list for every account, in their order
391    /// (`[[mail.filter]]`, `rules::Filter`): one that does not read is left
392    /// out alone (`rules::lenient`); written whole by `set_filters`.
393    #[serde(default, rename = "filter", deserialize_with = "crate::rules::lenient")]
394    pub filters: Vec<crate::rules::Filter>,
395}
396
397#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
398pub struct TaskSettings {
399    /// The list new tasks go into: "account/id".
400    #[serde(default)]
401    pub list: Option<String>,
402    /// Minutes counted for a task without an estimate.
403    #[serde(default)]
404    pub estimate: Option<u32>,
405    /// The kinds of task, as you named them (`[[tasks.kind]]`); Sioul's when unsaid.
406    #[serde(rename = "kind", default)]
407    pub kinds: Option<Vec<TaskKind>>,
408    /// The calendar time blocks go into, "account/id" (docs/tasks.md, "Pinned
409    /// to a time"); unsaid: "Planned tasks", made at first use with the task's list.
410    #[serde(default)]
411    pub blocks: Option<String>,
412    /// An alarm in each block made or moved, five minutes before it; off when
413    /// unsaid (Sioul reminds of blocks as of events; a phone would remind twice).
414    #[serde(default)]
415    pub block_alarms: bool,
416}
417
418/// What a day holds, as the plan learns it from your days (docs/capacity.md).
419#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
420pub struct PlanningSettings {
421    /// The days learned from, back from today: 28 when unsaid, 14 to 90.
422    #[serde(default)]
423    pub window_days: Option<u32>,
424    /// Days kept even: no full day then an empty one (energy-limiting illness).
425    #[serde(default)]
426    pub even_days: bool,
427    /// Where what a day holds starts: "as-now" (unsaid), "lighter", "much-lighter".
428    #[serde(default)]
429    pub start: Option<String>,
430    /// Two slots of time for you a day, silent; on when unsaid.
431    #[serde(default)]
432    pub gain_slots: Option<bool>,
433}
434
435impl PlanningSettings {
436    pub fn window(&self) -> u32 {
437        self.window_days.unwrap_or(crate::capacity::WINDOW_DEFAULT).clamp(crate::capacity::WINDOW_LEAST, crate::capacity::WINDOW_MOST)
438    }
439
440    pub fn start(&self) -> crate::capacity::Start {
441        crate::capacity::Start::parse(self.start.as_deref().unwrap_or(""))
442    }
443
444    pub fn gain_slots(&self) -> bool {
445        self.gain_slots.unwrap_or(true)
446    }
447}
448
449/// A kind of task, as you named it.
450#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
451pub struct TaskKind {
452    /// Written in the task's concept: "call", "errand".
453    pub id: String,
454    /// Empty for Sioul's own kinds: named in your language.
455    #[serde(default)]
456    pub label: String,
457}
458
459/// Sioul's kinds, before you name your own.
460fn default_kinds() -> Vec<TaskKind> {
461    crate::tasks::KINDS.iter().map(|k| TaskKind { id: k.to_string(), label: String::new() }).collect()
462}
463
464impl Config {
465    /// The kinds of task, id and name: yours as you named them, else Sioul's in your language.
466    pub fn task_kinds(&self, tr: &crate::i18n::Translator) -> Vec<(String, String)> {
467        self.tasks
468            .kinds
469            .clone()
470            .unwrap_or_else(default_kinds)
471            .into_iter()
472            .filter(|k| crate::tasks::is_kind_id(&k.id))
473            .map(|k| {
474                let label = if k.label.trim().is_empty() { tr.text(&format!("task-kind-{}", k.id), None) } else { k.label.trim().to_string() };
475                (k.id, label)
476            })
477            .collect()
478    }
479}
480
481/// A kind renamed (`from` and `to`), taken away (`to` empty) or added (`from`
482/// empty, `to` its name). Tasks keep the kind they have: taking a kind away
483/// only takes it off the choices. The kinds are read under the file's lock,
484/// which the sharing takes too, as the file holds them then: a kind another
485/// device's change brought in meanwhile stays.
486pub fn change_kind(path: &Path, from: &str, to: &str) -> Result<(), String> {
487    crate::filelock::with_lock(path, || {
488        let config = if path.exists() { Config::load(path)? } else { Config::default() };
489        let mut kinds = config.tasks.kinds.clone().unwrap_or_else(default_kinds);
490        let to = to.trim();
491        if from.is_empty() {
492            if to.is_empty() {
493                return Ok(());
494            }
495            let taken: Vec<String> = kinds.iter().map(|k| k.id.clone()).collect();
496            kinds.push(TaskKind { id: crate::projects::new_id(to, &taken), label: to.to_string() });
497        } else if to.is_empty() {
498            kinds.retain(|k| k.id != from);
499        } else {
500            let kind = kinds.iter_mut().find(|k| k.id == from).ok_or_else(|| format!("{from}: no such kind"))?;
501            kind.label = to.to_string();
502        }
503        let mut doc = read_document(path)?;
504        if !doc.contains_key("tasks") {
505            doc["tasks"] = toml_edit::table();
506        }
507        let tasks = doc["tasks"].as_table_mut().ok_or_else(|| format!("{}: tasks is not a table", path.display()))?;
508        if kinds.is_empty() {
509            tasks["kind"] = toml_edit::value(toml_edit::Array::new());
510        } else {
511            let mut list = toml_edit::ArrayOfTables::new();
512            for kind in &kinds {
513                let mut table = toml_edit::Table::new();
514                table["id"] = toml_edit::value(kind.id.as_str());
515                if !kind.label.is_empty() {
516                    table["label"] = toml_edit::value(kind.label.as_str());
517                }
518                list.push(table);
519            }
520            tasks["kind"] = Item::ArrayOfTables(list);
521        }
522        write_document(path, &doc)
523    })
524}
525
526#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
527pub struct AgendaSettings {
528    /// The hour the day planning opens on.
529    #[serde(default)]
530    pub day_start: Option<u32>,
531    /// Where a new event goes ("account/id"), Settings ▸ Agenda; unset: the
532    /// first calendar that takes events, as before (`agenda::default_calendar`).
533    #[serde(default)]
534    pub calendar: Option<String>,
535    /// Minutes you keep between two events, Settings ▸ Agenda (22 October
536    /// 2026): closer, they are said too close (`overlaps`); unset, none.
537    #[serde(default)]
538    pub between: Option<u32>,
539}
540
541impl AgendaSettings {
542    /// The time kept between two events: yours, else none.
543    pub fn between(&self) -> u32 {
544        self.between.unwrap_or(0).min(24 * 60)
545    }
546}
547
548#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
549pub struct MapSettings {
550    /// Raster tiles, "https://…/{z}/{x}/{y}.png"; OpenStreetMap's when unset.
551    #[serde(default)]
552    pub tiles: Option<String>,
553    /// Postal addresses may be sent to OpenStreetMap's geocoder, once each, to place contacts.
554    #[serde(default)]
555    pub geocode: bool,
556}
557
558/// How the contacts read phone numbers (docs/client.md, "Duplicates").
559#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
560pub struct ContactSettings {
561    /// The country of numbers written without one, by its ISO code ("FR"),
562    /// to compare "04 65…" with "+33 4 65…"; unset: the system's locale's,
563    /// else Sioul's language's (`phones::chosen`).
564    #[serde(default)]
565    pub region: Option<String>,
566}
567
568/// Paper letters (docs/porch.md): the folder their scans arrive in.
569#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
570pub struct LetterSettings {
571    /// Unset: `letters/inbox` in the notes folder.
572    #[serde(default)]
573    pub inbox: Option<String>,
574}
575
576/// Reminders before dates (docs/reminders.md): one quiet notification each, never again.
577#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
578pub struct ReminderSettings {
579    /// Events: at the end of the working day before, and at the alarms they carry.
580    #[serde(default = "yes")]
581    pub events: bool,
582    /// A date asked: so many working days before, when work starts; 0 for none.
583    #[serde(default = "two")]
584    pub asked_days: u32,
585    /// A payment planned: so many working days before, when work starts; 0 for none.
586    #[serde(default = "two")]
587    pub payment_days: u32,
588    /// A wait after a step done (an answer due), once it is over.
589    #[serde(default = "yes")]
590    pub waits: bool,
591    /// What sites notified, gathered in one notification at set times (a site
592    /// in real time, and a call, come at once).
593    #[serde(default = "yes")]
594    pub gather: bool,
595    /// The times of the gathered notification: "09:00"; unsaid, three a day.
596    #[serde(default)]
597    pub gathered: Option<Vec<String>>,
598    /// Doses reminded during sleep, when nothing else disturbs (docs/health.md):
599    /// you set their times, a dose at 05:00 is meant to wake you. Off, they
600    /// wait for waking. The matrix's cell now (`[attention] doses`), read from
601    /// here once to seed it while no `[attention]` is written.
602    #[serde(default = "yes")]
603    pub doses_in_sleep: bool,
604    /// Minutes before an event, counted before its margin "before" (getting
605    /// ready, getting there), its reminder; 0 for none. Each event may say its
606    /// own (`X-SIOUL-REMIND`, docs/reminders.md).
607    #[serde(default = "fifteen")]
608    pub before_event: u32,
609    /// New mail notified, once per batch, at the times it may come (docs/porch.md, "Notifications").
610    #[serde(default = "yes")]
611    pub mail: bool,
612    /// Newsletters among it (the Filed lane's lists); automatic senders (a bill from no-reply) are.
613    #[serde(default)]
614    pub mail_newsletters: bool,
615}
616
617impl ReminderSettings {
618    /// When sites' notifications are gathered: yours, else 9:00, 13:00 and 18:00
619    /// (three a day helped most in a field trial: Fitz et al. 2019).
620    pub fn gathered_times(&self) -> Vec<String> {
621        self.gathered.clone().filter(|t| !t.is_empty()).unwrap_or_else(|| ["09:00", "13:00", "18:00"].iter().map(|t| t.to_string()).collect())
622    }
623}
624
625impl Default for ReminderSettings {
626    fn default() -> Self {
627        ReminderSettings { events: true, asked_days: 2, payment_days: 2, waits: true, gather: true, gathered: None, doses_in_sleep: true, before_event: 15, mail: true, mail_newsletters: false }
628    }
629}
630
631fn two() -> u32 {
632    2
633}
634
635fn fifteen() -> u32 {
636    15
637}
638
639/// What GitHub brings, once asked; its token is in the keyring.
640#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
641pub struct GithubSettings {
642    #[serde(default)]
643    pub enabled: bool,
644    /// Issues and pull requests assigned to you.
645    #[serde(default = "yes")]
646    pub assigned: bool,
647    /// Pull requests whose review is asked of you.
648    #[serde(default = "yes")]
649    pub reviews: bool,
650    /// What you opened.
651    #[serde(default)]
652    pub created: bool,
653    /// Where you are mentioned.
654    #[serde(default)]
655    pub mentioned: bool,
656}
657
658fn yes() -> bool {
659    true
660}
661
662/// What an AI agent connected to `sioul mcp` may use, beyond reading and
663/// adding (`[mcp]`, docs/mcp.md).
664#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
665pub struct McpSettings {
666    /// The spam filter's tools (`spam_status`… `spam_label`): its numbers,
667    /// its worst errors, what it would do, the review queue, a label, the
668    /// corpus's download and a training run apart. On unless set to false.
669    #[serde(default = "yes")]
670    pub spam: bool,
671    /// Things in no project (the Porch's other mail, tasks of no project,
672    /// general notes, the agenda, contacts, the phone's messages, papers):
673    /// open to agents unless set to false. Projects are each opened on their
674    /// own (`ai = true` in `sioul-projects.toml`, `consent`).
675    #[serde(default = "yes")]
676    pub outside_projects: bool,
677    /// Your texts (SMS and MMS, docs/texts.md): read and searched, a draft
678    /// written that waits in the Texts page. Off unless set to true: texts
679    /// carry other people's words.
680    #[serde(default)]
681    pub texts: bool,
682    /// The mail of work and school Microsoft accounts (docs/microsoft.md):
683    /// kept from agents whatever its projects unless set to true, since
684    /// Microsoft's terms count the organisation among whom must allow it.
685    #[serde(default)]
686    pub work_accounts: bool,
687}
688
689impl Default for McpSettings {
690    fn default() -> Self {
691        McpSettings { spam: true, outside_projects: true, texts: false, work_accounts: false }
692    }
693}
694
695impl Default for GithubSettings {
696    fn default() -> Self {
697        GithubSettings { enabled: false, assigned: true, reviews: true, created: false, mentioned: false }
698    }
699}
700
701#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
702pub struct BitwardenSettings {
703    /// The account's e-mail.
704    #[serde(default)]
705    pub email: Option<String>,
706    /// "bitwarden.com" when unset, "bitwarden.eu", or your server's address.
707    #[serde(default)]
708    pub server: Option<String>,
709}
710
711#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
712pub struct WeatherSettings {
713    /// "Lyon, Auvergne-Rhône-Alpes, France".
714    #[serde(default)]
715    pub place: Option<String>,
716    #[serde(default)]
717    pub latitude: Option<f64>,
718    #[serde(default)]
719    pub longitude: Option<f64>,
720}
721
722#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
723pub struct InvoiceSettings {
724    #[serde(default)]
725    pub name: String,
726    /// Postal address, on lines.
727    #[serde(default)]
728    pub address: String,
729    /// SIRET, or the business number where you are.
730    #[serde(default)]
731    pub siret: String,
732    /// The VAT line: "TVA non applicable, art. 293 B du CGI" for a French micro-entrepreneur.
733    #[serde(default)]
734    pub vat: String,
735    /// Invoice numbers start with it: "2026-".
736    #[serde(default)]
737    pub prefix: String,
738    /// An hour's fee when a project does not say.
739    #[serde(default)]
740    pub rate: f64,
741    #[serde(default)]
742    pub currency: String,
743    /// Where the PDFs of invoices go; "~/Documents/Invoices" when unsaid.
744    #[serde(default)]
745    pub folder: String,
746    /// Payment details printed at the bottom: IBAN, terms.
747    #[serde(default)]
748    pub payment: String,
749}
750
751#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
752#[serde(rename_all = "lowercase")]
753pub enum AccountKind {
754    Imap,
755    Jmap,
756    /// A site kept open in Sioul (`[[site]]`, docs/sites.md): the bank, the tax
757    /// office, Proton without Bridge. Not an account: it holds no sign-in of
758    /// Sioul's. A `[[site]]` says no kind.
759    #[default]
760    Portal,
761    /// Contacts and calendars on a CardDAV and CalDAV server.
762    Dav,
763}
764
765/// How the connection to the mail server is encrypted. There is no plain option.
766#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
767#[serde(rename_all = "lowercase")]
768pub enum Security {
769    /// TLS from the first byte, usually port 993; preferred by RFC 8314 §3.
770    #[default]
771    Tls,
772    /// A plain connection upgraded by STARTTLS before anything else is said, usually port 143.
773    Starttls,
774}
775
776impl Security {
777    pub fn as_str(self) -> &'static str {
778        match self {
779            Security::Tls => "tls",
780            Security::Starttls => "starttls",
781        }
782    }
783}
784
785/// How an account's mail ranks against the others'.
786#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
787#[serde(rename_all = "lowercase")]
788pub enum Priority {
789    /// Its mail comes first in every lane.
790    Above,
791    #[default]
792    Average,
793    /// Its mail skips the screener and waits folded at the bottom of the Porch:
794    /// notifications read sometimes, never answered. Codes still come at once.
795    Below,
796}
797
798impl Priority {
799    pub fn as_str(self) -> &'static str {
800        match self {
801            Priority::Above => "above",
802            Priority::Average => "average",
803            Priority::Below => "below",
804        }
805    }
806
807    pub fn parse(text: &str) -> Option<Priority> {
808        match text.trim().to_ascii_lowercase().as_str() {
809            "above" => Some(Priority::Above),
810            "average" => Some(Priority::Average),
811            "below" => Some(Priority::Below),
812            _ => None,
813        }
814    }
815}
816
817/// Days of mail the first sync brings, when the account does not say.
818pub const DEFAULT_SYNC_DAYS: u32 = 14;
819
820/// Microsoft's IMAP server, for every mail account signed in with Microsoft
821/// (`auth = "microsoft"`, docs/microsoft.md): Outlook.com's and Microsoft 365's alike.
822pub const MICROSOFT_IMAP: &str = "outlook.office365.com";
823/// Microsoft's SMTP server for personal accounts (Outlook.com, Hotmail, Live, MSN).
824pub const MICROSOFT_SMTP_PERSONAL: &str = "smtp-mail.outlook.com";
825/// Microsoft's SMTP server for work and school accounts (Microsoft 365).
826pub const MICROSOFT_SMTP_WORK: &str = "smtp.office365.com";
827
828#[derive(Debug, Clone, Deserialize)]
829pub struct Account {
830    pub id: String,
831    #[serde(default)]
832    pub kind: AccountKind,
833    /// Switched off in Accounts: kept with its settings, neither synced nor shown.
834    #[serde(default = "yes")]
835    pub enabled: bool,
836    #[serde(default)]
837    pub address: Option<String>,
838    /// Where this account's mail is kept on disk (Maildir); see [`Account::maildir_path`].
839    #[serde(default)]
840    pub maildir: Option<String>,
841    /// For portals: the address of the web mailbox.
842    #[serde(default)]
843    pub url: Option<String>,
844    /// For portals, what the site is: "mailbox" (a secure mailbox), "chat", "dating", "other".
845    #[serde(default)]
846    pub site: Option<String>,
847    /// For portals: its notifications shown at once, not at your pace.
848    #[serde(default)]
849    pub realtime: bool,
850    /// For portals: its sounds silenced.
851    #[serde(default)]
852    pub muted: bool,
853    /// For portals: kept open while the window is, to receive its notifications.
854    #[serde(default)]
855    pub background: Option<bool>,
856    /// For portals: the microphone, the camera, sharing the screen, for calls;
857    /// unsaid, as its kind goes (chats, video calls and dating sites have them).
858    #[serde(default)]
859    pub microphone: Option<bool>,
860    #[serde(default)]
861    pub camera: Option<bool>,
862    #[serde(default)]
863    pub screen: Option<bool>,
864    /// For portals: your own categories, any words ("Banque", "Santé"): the list filters by them.
865    #[serde(default)]
866    pub categories: Vec<String>,
867    /// For portals: the domains whose mail says something waits there ("you have a new
868    /// message in your secure mailbox"); the site's own domain when unsaid.
869    #[serde(default)]
870    pub announced_by: Vec<String>,
871    /// "personal" for an address, or a site, of yours outside work: it stays in view in quiet time.
872    #[serde(default)]
873    pub area: Option<String>,
874    /// The authserv-id your provider writes in Authentication-Results (RFC 8601).
875    #[serde(default)]
876    pub trusted_authserv_ids: Vec<String>,
877    /// The IMAP server, found from the address when the account is added.
878    #[serde(default)]
879    pub host: Option<String>,
880    #[serde(default)]
881    pub port: Option<u16>,
882    #[serde(default)]
883    pub security: Security,
884    /// The login, when it is not the address.
885    #[serde(default)]
886    pub username: Option<String>,
887    /// How many days of mail the first sync brings.
888    #[serde(default)]
889    pub sync_days: Option<u32>,
890    /// How it signs in: "google" for Google's OAuth, "microsoft" for
891    /// Microsoft's (their tokens in the keyring); a password otherwise. An
892    /// older Sioul reading "microsoft" takes it for a password account,
893    /// whose login then fails, said as such.
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub auth: Option<String>,
896    /// Folders not kept here, by their server names: they stay on the server only.
897    #[serde(default, skip_serializing_if = "Vec::is_empty")]
898    pub skip_folders: Vec<String>,
899    /// How far back this account's mail reaches, in weeks, 0 for everything;
900    /// the configuration's `history_weeks` when unset.
901    #[serde(default)]
902    pub history_weeks: Option<u32>,
903    /// How its mail ranks against the other accounts'.
904    #[serde(default)]
905    pub priority: Priority,
906    /// Your name as the From line shows it.
907    #[serde(default)]
908    pub name: Option<String>,
909    /// The SMTP submission server, found from the address when first needed.
910    #[serde(default)]
911    pub smtp_host: Option<String>,
912    #[serde(default)]
913    pub smtp_port: Option<u16>,
914    #[serde(default)]
915    pub smtp_security: Security,
916    /// Added below what you write, in Markdown.
917    #[serde(default)]
918    pub signature: Option<String>,
919    /// Minutes between two fetches of this account, when it should come more or less often than the rest.
920    #[serde(default)]
921    pub fetch_minutes: Option<u32>,
922    /// A public address that may bring hostile mail: its mail is read first, held, and summarised (`shield`).
923    #[serde(default)]
924    pub shield: bool,
925    /// For a shielded account, an AI may read its new mail to say its tone and topic (docs/ai.md).
926    #[serde(default)]
927    pub shield_ai: bool,
928    /// Mail signed in with Microsoft: "personal" (Outlook.com, Hotmail, Live,
929    /// MSN) or "work" (work or school, Microsoft 365), as its sign-in found
930    /// it (the ID token's tenant). Kept apart from the sending server, so that
931    /// an edit of that server cannot turn a work account personal; none (an
932    /// account signed in before it was kept) counts as work until the next
933    /// sign-in (docs/microsoft.md).
934    #[serde(default)]
935    pub microsoft_kind: Option<String>,
936}
937
938impl Account {
939    /// How far back this account's mail reaches, in days; None for everything.
940    pub fn history_days(&self) -> Option<i64> {
941        history_days(self.history_weeks)
942    }
943
944    /// A mail account fetched over IMAP; `username` only when it differs from the address.
945    pub fn imap(id: &str, address: &str, host: &str, port: u16, security: Security, username: Option<&str>) -> Account {
946        Account {
947            id: id.to_string(),
948            kind: AccountKind::Imap,
949            enabled: true,
950            address: Some(address.to_string()),
951            maildir: None,
952            url: None,
953            site: None,
954            realtime: false,
955            muted: false,
956            background: None,
957            microphone: None,
958            camera: None,
959            screen: None,
960            categories: Vec::new(),
961            announced_by: Vec::new(),
962            area: None,
963            auth: None,
964            skip_folders: Vec::new(),
965            trusted_authserv_ids: Vec::new(),
966            host: Some(host.to_string()),
967            port: Some(port),
968            security,
969            username: username.filter(|u| *u != address).map(str::to_string),
970            sync_days: None,
971            history_weeks: None,
972            priority: Priority::Average,
973            name: None,
974            smtp_host: None,
975            smtp_port: None,
976            smtp_security: Security::Tls,
977            signature: None,
978            fetch_minutes: None,
979            shield: false,
980            shield_ai: false,
981            microsoft_kind: None,
982        }
983    }
984
985    /// The Maildir of this account: `maildir` if set, else `~/.local/share/sioul/mail/<id>`.
986    pub fn maildir_path(&self) -> PathBuf {
987        self.maildir.as_deref().map_or_else(|| data_dir().join("mail").join(&self.id), expand_home)
988    }
989
990    /// The name to log in with: `username`, else the address.
991    pub fn login(&self) -> Option<&str> {
992        self.username.as_deref().or(self.address.as_deref())
993    }
994
995    /// Mail signed in with Microsoft (`auth = "microsoft"`): its tokens in the
996    /// keyring, no password (docs/microsoft.md).
997    pub fn is_microsoft(&self) -> bool {
998        self.kind == AccountKind::Imap && self.auth.as_deref() == Some("microsoft")
999    }
1000
1001    /// Mail signed in with Microsoft that belongs to an organisation (a work
1002    /// or school account, Microsoft 365), or whose kind is not known: its
1003    /// `microsoft_kind` is not "personal" (the sending server is not read for
1004    /// it: an edit there changes nothing). Microsoft's terms count the
1005    /// organisation among whom must allow its mail to go further than
1006    /// syncing, so the AI shield is not offered for it, agents leave it out
1007    /// unless you open it to them (`[mcp] work_accounts`), and the spam
1008    /// filter does not learn from it.
1009    pub fn is_microsoft_work(&self) -> bool {
1010        self.is_microsoft() && self.microsoft_kind.as_deref() != Some("personal")
1011    }
1012
1013    /// Whether the AI reads this address's new mail first (its shield, and
1014    /// the AI allowed): never a work or school Microsoft account's, whatever
1015    /// the file says (docs/microsoft.md).
1016    pub fn ai_reads(&self) -> bool {
1017        self.shield && self.shield_ai && !self.is_microsoft_work()
1018    }
1019
1020    /// The port to use: `port`, else the usual one for the encryption.
1021    pub fn port_or_default(&self) -> u16 {
1022        self.port.unwrap_or(match self.security {
1023            Security::Tls => 993,
1024            Security::Starttls => 143,
1025        })
1026    }
1027
1028    /// The SMTP port to use: `smtp_port`, else the usual one for the encryption (RFC 8314).
1029    pub fn smtp_port_or_default(&self) -> u16 {
1030        self.smtp_port.unwrap_or(match self.smtp_security {
1031            Security::Tls => 465,
1032            Security::Starttls => 587,
1033        })
1034    }
1035
1036    /// Whether Sioul fetches this account's mail itself.
1037    pub fn syncs(&self) -> bool {
1038        self.kind == AccountKind::Imap && self.host.is_some()
1039    }
1040
1041    /// A contacts-and-calendars account: `host` names its keyring entry, `url` is where its server was found.
1042    pub fn dav(id: &str, address: &str, host: &str, url: Option<&str>, username: Option<&str>) -> Account {
1043        Account {
1044            kind: AccountKind::Dav,
1045            url: url.map(str::to_string),
1046            port: None,
1047            ..Account::imap(id, address, host, 0, Security::Tls, username)
1048        }
1049    }
1050
1051    /// Whether Sioul syncs this account's contacts and calendars.
1052    pub fn is_dav(&self) -> bool {
1053        self.kind == AccountKind::Dav && self.host.is_some()
1054    }
1055}
1056
1057/// A folder of mail to read, the account it belongs to, and the provider ids to trust for it.
1058#[derive(Debug, Clone)]
1059pub struct Source {
1060    pub account: Option<String>,
1061    /// The account's own address.
1062    pub address: Option<String>,
1063    pub folder: PathBuf,
1064    pub trusted_ids: Vec<String>,
1065    pub priority: Priority,
1066    /// A public address whose mail is read first (`shield`).
1067    pub shielded: bool,
1068    /// The words looked for, as the configuration makes them (`words::Words::of`).
1069    pub words: std::sync::Arc<crate::words::Words>,
1070    /// Sioul's own spam filter, one for every account; none, no learned verdict.
1071    pub spam: Option<crate::spam::Filter>,
1072}
1073
1074impl Config {
1075    /// The mail account a stored message belongs to, from where its file is.
1076    pub fn account_of(&self, file: &Path) -> Option<&Account> {
1077        let syncing = || self.accounts.iter().filter(|a| a.syncs());
1078        // Compared as written first; then each path as the system resolves it, where a
1079        // folder sits behind a link (macOS's /var is /private/var; a mail folder linked
1080        // elsewhere): a path found through the link and its account's would not match.
1081        syncing().find(|a| file.starts_with(a.maildir_path())).or_else(|| {
1082            let file = std::fs::canonicalize(file).ok()?;
1083            syncing().find(|a| std::fs::canonicalize(a.maildir_path()).is_ok_and(|root| file.starts_with(root)))
1084        })
1085    }
1086
1087    /// Reads a configuration file; each account takes the general history
1088    /// unless it has its own. Sites written as accounts by older files join
1089    /// the sites; accounts switched off are set aside.
1090    pub fn load(path: &Path) -> Result<Config, String> {
1091        let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
1092        let mut config: Config = toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
1093        config.settle();
1094        Ok(config)
1095    }
1096
1097    /// What `load` does once the file is read: sites apart, accounts switched off apart.
1098    pub fn settle(&mut self) {
1099        let (sites, accounts): (Vec<Account>, Vec<Account>) = std::mem::take(&mut self.accounts).into_iter().partition(|a| a.kind == AccountKind::Portal);
1100        let mut all_sites = sites;
1101        all_sites.append(&mut self.sites);
1102        for site in &mut all_sites {
1103            site.kind = AccountKind::Portal;
1104        }
1105        if !self.site_order.is_empty() {
1106            all_sites.sort_by_key(|site| self.site_order.iter().position(|id| *id == site.id).unwrap_or(usize::MAX));
1107        }
1108        self.sites = all_sites;
1109        let (on, off): (Vec<Account>, Vec<Account>) = accounts.into_iter().partition(|a| a.enabled);
1110        self.accounts = on;
1111        self.accounts_off = off;
1112        let history = self.history_weeks;
1113        for account in self.accounts.iter_mut().chain(self.accounts_off.iter_mut()) {
1114            account.history_weeks = account.history_weeks.or(history);
1115        }
1116    }
1117
1118    /// The site with this id.
1119    pub fn site(&self, id: &str) -> Option<&Account> {
1120        self.sites.iter().find(|s| s.id == id)
1121    }
1122
1123    /// Every account, those switched off too, in the file's order of each.
1124    pub fn every_account(&self) -> impl Iterator<Item = &Account> {
1125        self.accounts.iter().chain(self.accounts_off.iter())
1126    }
1127
1128    /// How far back mail and the agenda reach, in days; None for everything.
1129    pub fn history_days(&self) -> Option<i64> {
1130        history_days(self.history_weeks)
1131    }
1132
1133    /// The notes folder, with `~` expanded. On Android, unset: a folder
1134    /// of the app's own storage, which no other app, nor any sync, reads.
1135    pub fn notes_root_path(&self) -> Option<PathBuf> {
1136        self.notes_root.as_deref().map(expand_home).or_else(|| cfg!(target_os = "android").then(|| expand_home("~/Notes")))
1137    }
1138
1139    /// The known senders' file: `known_senders`, else `known-senders.txt` next to the configuration.
1140    pub fn known_senders_path(&self) -> PathBuf {
1141        self.known_senders.as_deref().map_or_else(|| config_dir().join("known-senders.txt"), expand_home)
1142    }
1143
1144    /// The blocked senders' file: `blocked_senders`, else `blocked-senders.txt` next to the configuration.
1145    pub fn blocked_senders_path(&self) -> PathBuf {
1146        self.blocked_senders.as_deref().map_or_else(|| config_dir().join("blocked-senders.txt"), expand_home)
1147    }
1148
1149    /// Who is safe (by default, their mail reaches you at any time): `safe-senders.txt` next to the configuration.
1150    pub fn safe_senders_path(&self) -> PathBuf {
1151        config_dir().join("safe-senders.txt")
1152    }
1153
1154    /// Who is restricted (by default, their mail comes in working hours only): `restricted-senders.txt` next to the configuration.
1155    pub fn restricted_senders_path(&self) -> PathBuf {
1156        config_dir().join("restricted-senders.txt")
1157    }
1158
1159    /// Who is neutral by your word, where a broader safe or blocked entry would name them otherwise.
1160    pub fn neutral_senders_path(&self) -> PathBuf {
1161        config_dir().join("neutral-senders.txt")
1162    }
1163
1164    /// Every authserv-id of every account, for messages read outside an account.
1165    pub fn all_trusted_ids(&self) -> Vec<String> {
1166        self.accounts.iter().flat_map(|a| a.trusted_authserv_ids.iter().cloned()).collect()
1167    }
1168
1169    /// The account with this id.
1170    pub fn account(&self, id: &str) -> Option<&Account> {
1171        self.accounts.iter().find(|a| a.id == id)
1172    }
1173
1174    /// The mail folders of every account that has mail on disk (portals have none).
1175    /// Sioul's own checks are trusted first, then the provider's.
1176    pub fn mail_sources(&self) -> Vec<Source> {
1177        let own = sioul_authserv_id();
1178        let spam = crate::spam::Filter::of(self);
1179        let words = crate::words::Words::of(self);
1180        self.accounts
1181            .iter()
1182            .filter(|a| a.kind != AccountKind::Portal)
1183            .map(|a| Source {
1184                account: Some(a.id.clone()),
1185                address: a.address.clone(),
1186                folder: a.maildir_path(),
1187                trusted_ids: std::iter::once(own.clone()).chain(a.trusted_authserv_ids.iter().cloned()).collect(),
1188                priority: a.priority,
1189                shielded: a.shield,
1190                words: words.clone(),
1191                spam: Some(spam.clone()),
1192            })
1193            .collect()
1194    }
1195
1196    /// An id for a new account from its address, unused yet: "example", then "example-someone".
1197    pub fn free_id(&self, address: &str) -> String {
1198        let (local, domain) = address.rsplit_once('@').unwrap_or((address, address));
1199        let label = domain.split('.').next().unwrap_or(domain);
1200        [label.to_string(), format!("{label}-{local}")]
1201            .into_iter()
1202            .chain((2..).map(|n| format!("{label}-{n}")))
1203            .map(|id| slug(&id))
1204            .find(|id| self.every_account().all(|a| &a.id != id))
1205            .unwrap_or_default()
1206    }
1207}
1208
1209/// Lowercase letters, digits and dashes: safe as a folder name and a TOML value.
1210pub fn slug(text: &str) -> String {
1211    let mapped: String = text.to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
1212    mapped.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-")
1213}
1214
1215/// Adds a mail account at the end of the configuration file, creating the file if needed.
1216pub fn add_imap_account(path: &Path, account: &Account) -> Result<(), String> {
1217    let host = account.host.as_deref().ok_or_else(|| format!("{}: no server", account.id))?;
1218    let mut table = Table::new();
1219    table["id"] = value(account.id.as_str());
1220    table["kind"] = value("imap");
1221    if let Some(address) = &account.address {
1222        table["address"] = value(address.as_str());
1223    }
1224    table["host"] = value(host);
1225    table["port"] = value(i64::from(account.port_or_default()));
1226    table["security"] = value(account.security.as_str());
1227    if let Some(username) = &account.username {
1228        table["username"] = value(username.as_str());
1229    }
1230    if let Some(days) = account.sync_days {
1231        table["sync_days"] = value(i64::from(days));
1232    }
1233    // Signed in with Google or Microsoft (an access token, not a password: docs/google.md, "Mail"; docs/microsoft.md).
1234    if let Some(auth) = &account.auth {
1235        table["auth"] = value(auth.as_str());
1236    }
1237    table["trusted_authserv_ids"] = value(account.trusted_authserv_ids.iter().map(String::as_str).collect::<Array>());
1238    append_account(path, table)
1239}
1240
1241/// Adds a contacts-and-calendars account at the end of the configuration file.
1242pub fn add_dav_account(path: &Path, account: &Account) -> Result<(), String> {
1243    let host = account.host.as_deref().ok_or_else(|| format!("{}: no server", account.id))?;
1244    let mut table = Table::new();
1245    table["id"] = value(account.id.as_str());
1246    table["kind"] = value("dav");
1247    if let Some(address) = &account.address {
1248        table["address"] = value(address.as_str());
1249    }
1250    table["host"] = value(host);
1251    if let Some(url) = &account.url {
1252        table["url"] = value(url.as_str());
1253    }
1254    if let Some(username) = &account.username {
1255        table["username"] = value(username.as_str());
1256    }
1257    if let Some(auth) = &account.auth {
1258        table["auth"] = value(auth.as_str());
1259    }
1260    append_account(path, table)
1261}
1262
1263/// Adds a site: a web-only mailbox, a chat, opened in Sioul's Sites.
1264pub fn add_portal(path: &Path, id: &str, url: &str) -> Result<(), String> {
1265    add_site(path, id, "", url)
1266}
1267
1268/// Adds a site as the Sites page offers it: its name, address, type
1269/// ("mailbox", "chat"…), what it is for ("" for its type's), the domains whose
1270/// mail announces it, your categories ("Banque").
1271pub fn add_site_as(path: &Path, id: &str, name: &str, url: &str, kind: &str, area: &str, announced_by: &[String], categories: &[String]) -> Result<(), String> {
1272    let mut table = Table::new();
1273    table["id"] = value(id);
1274    if !name.trim().is_empty() {
1275        table["name"] = value(name.trim());
1276    }
1277    table["url"] = value(url);
1278    table["site"] = value(crate::sites::type_of(kind));
1279    if let Some(area) = crate::areas::Area::parse(area) {
1280        table["area"] = value(area.id());
1281    }
1282    if !announced_by.is_empty() {
1283        let mut list = toml_edit::Array::new();
1284        for domain in announced_by.iter().map(|d| d.trim().trim_start_matches('@').to_lowercase()).filter(|d| !d.is_empty()) {
1285            list.push(domain);
1286        }
1287        table["announced_by"] = value(list);
1288    }
1289    let categories: Vec<&str> = categories.iter().map(|c| c.trim()).filter(|c| !c.is_empty()).collect();
1290    if !categories.is_empty() {
1291        let mut list = toml_edit::Array::new();
1292        for category in categories {
1293            list.push(category);
1294        }
1295        table["categories"] = value(list);
1296    }
1297    append_site(path, table)
1298}
1299
1300/// Adds a site with the name it shows.
1301pub fn add_site(path: &Path, id: &str, name: &str, url: &str) -> Result<(), String> {
1302    let mut table = Table::new();
1303    table["id"] = value(id);
1304    if !name.trim().is_empty() {
1305        table["name"] = value(name.trim());
1306    }
1307    table["url"] = value(url);
1308    append_site(path, table)
1309}
1310
1311/// Sites written as `[[account]]` of kind "portal" by older files, moved to
1312/// `[[site]]`, in their order, each with the comments above it; their kind
1313/// goes. Returns whether any moved.
1314pub fn migrate_sites(path: &Path) -> Result<bool, String> {
1315    // Read, changed and written under the file's lock, which the sharing takes too.
1316    crate::filelock::with_lock(path, || {
1317        let mut doc = read_document(path)?;
1318        let Some(accounts) = doc.get_mut("account").and_then(Item::as_array_of_tables_mut) else { return Ok(false) };
1319        let is_site = |t: &Table| t.get("kind").and_then(Item::as_str) == Some("portal");
1320        let moving: Vec<Table> = accounts.iter().filter(|t| is_site(t)).cloned().collect();
1321        if moving.is_empty() {
1322            return Ok(false);
1323        }
1324        accounts.retain(|t| !is_site(t));
1325        if accounts.is_empty() {
1326            doc.remove("account");
1327        }
1328        let sites = doc.entry("site").or_insert(Item::ArrayOfTables(ArrayOfTables::new())).as_array_of_tables_mut().ok_or_else(|| format!("{}: `site` is not a list of tables", path.display()))?;
1329        for mut table in moving {
1330            table.remove("kind");
1331            sites.push(table);
1332        }
1333        write_document(path, &doc)?;
1334        Ok(true)
1335    })
1336}
1337
1338/// The copy of `config.toml` kept before the sites moved: `config-before-sites.toml`, beside it.
1339pub fn before_sites_path(path: &Path) -> PathBuf {
1340    path.with_file_name("config-before-sites.toml")
1341}
1342
1343/// The sites migration as the window runs it at each start (`migrate_sites`),
1344/// while the file still holds a site among its accounts: the file copied
1345/// aside first (`before_sites_path`), once only, so that a migration that
1346/// fails at each start never writes over the first copy, the file as it was.
1347/// Returns whether any site moved.
1348pub fn migrate_sites_at_start(path: &Path) -> Result<bool, String> {
1349    if !std::fs::read_to_string(path).is_ok_and(|text| text.contains("kind = \"portal\"")) {
1350        return Ok(false);
1351    }
1352    let copy = before_sites_path(path);
1353    if !copy.exists() {
1354        std::fs::copy(path, &copy).map_err(|e| format!("{}: {e}", copy.display()))?;
1355    }
1356    migrate_sites(path)
1357}
1358
1359fn append_site(path: &Path, mut table: Table) -> Result<(), String> {
1360    // Read, changed and written under the file's lock, which the sharing takes too.
1361    crate::filelock::with_lock(path, || {
1362        migrate_sites(path)?;
1363        let mut doc = read_document(path)?;
1364        let trailing = doc.trailing().as_str().unwrap_or("").trim_end().to_string();
1365        table.decor_mut().set_prefix(if trailing.trim().is_empty() { "\n".to_string() } else { format!("{trailing}\n\n") });
1366        doc.set_trailing("");
1367        let sites = doc.entry("site").or_insert(Item::ArrayOfTables(ArrayOfTables::new()));
1368        let sites = sites.as_array_of_tables_mut().ok_or_else(|| format!("{}: `site` is not a list of tables", path.display()))?;
1369        sites.push(table);
1370        write_document(path, &doc)
1371    })
1372}
1373
1374/// Moves a site `delta` places in the list: the order the Sites page shows
1375/// (`site_order`, then the file's). At either end, it stays. The file's
1376/// tables take that order. With `order` (the sharing writes format 2, where
1377/// each site travels apart: docs/database.md, "The format of what travels"),
1378/// `site_order` says it too, for your other devices; without, the list's
1379/// own order travels with it, as it always did, and `site_order`, if there,
1380/// follows it.
1381pub fn move_site(path: &Path, id: &str, delta: i64, order: bool) -> Result<(), String> {
1382    // Read, changed and written under the file's lock, which the sharing takes too.
1383    crate::filelock::with_lock(path, || {
1384        migrate_sites(path)?;
1385        let mut doc = read_document(path)?;
1386        let chosen: Vec<String> = doc.get("site_order").and_then(Item::as_array).map(|list| list.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
1387        let Some(sites) = doc.get_mut("site").and_then(Item::as_array_of_tables_mut) else { return Ok(()) };
1388        let id_of = |table: &Table| table.get("id").and_then(Item::as_str).unwrap_or_default().to_string();
1389        let mut tables: Vec<Table> = sites.iter().cloned().collect();
1390        let slots: Vec<Option<isize>> = tables.iter().map(Table::position).collect();
1391        tables.sort_by_key(|table| chosen.iter().position(|chosen| *chosen == id_of(table)).unwrap_or(usize::MAX));
1392        let Some(from) = tables.iter().position(|t| id_of(t) == id) else { return Ok(()) };
1393        let Some(to) = usize::try_from(from as i64 + delta).ok().filter(|to| *to < tables.len()) else { return Ok(()) };
1394        tables.swap(from, to);
1395        // Each table takes the slot of its new place; each keeps the comments above it.
1396        let (with_order, order): (bool, Array) = (order, tables.iter().map(id_of).collect());
1397        let mut moved = ArrayOfTables::new();
1398        for (mut table, slot) in tables.into_iter().zip(slots) {
1399            table.set_position(slot);
1400            moved.push(table);
1401        }
1402        *sites = moved;
1403        if with_order || doc.contains_key("site_order") {
1404            doc["site_order"] = value(order);
1405        }
1406        write_document(path, &doc)
1407    })
1408}
1409
1410/// Two tables trade places. Each read from the file keeps its place in it
1411/// (`Table::position`), which orders the file when written: the places stay
1412/// with the slots, or the file would come back in its old order.
1413fn swap_tables(tables: &mut [Table], from: usize, to: usize) {
1414    let (a, b) = (tables[from].position(), tables[to].position());
1415    tables.swap(from, to);
1416    tables[from].set_position(a);
1417    tables[to].set_position(b);
1418}
1419
1420/// Takes a site out of the configuration; what its pages kept (sign-ins, cookies) stays in its profile.
1421pub fn remove_site(path: &Path, id: &str) -> Result<(), String> {
1422    // Read, changed and written under the file's lock, which the sharing takes too.
1423    crate::filelock::with_lock(path, || {
1424        migrate_sites(path)?;
1425        let mut doc = read_document(path)?;
1426        let Some(sites) = doc.get_mut("site").and_then(Item::as_array_of_tables_mut) else { return Ok(()) };
1427        let before = sites.len();
1428        sites.retain(|t| t.get("id").and_then(Item::as_str) != Some(id));
1429        if sites.len() == before {
1430            return Err(format!("{}: no site {id}", path.display()));
1431        }
1432        write_document(path, &doc)
1433    })
1434}
1435
1436/// An account switched on or off: off, it keeps its settings and is neither synced nor shown.
1437pub fn set_enabled(path: &Path, id: &str, on: bool) -> Result<(), String> {
1438    // Read, changed and written under the file's lock, which the sharing takes too.
1439    crate::filelock::with_lock(path, || {
1440        let mut doc = read_document(path)?;
1441        let table = doc
1442            .get_mut("account")
1443            .and_then(Item::as_array_of_tables_mut)
1444            .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1445            .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1446        if on {
1447            table.remove("enabled");
1448        } else {
1449            table["enabled"] = value(false);
1450        }
1451        write_document(path, &doc)
1452    })
1453}
1454
1455fn append_account(path: &Path, mut table: Table) -> Result<(), String> {
1456    // Read, changed and written under the file's lock, which the sharing takes too.
1457    crate::filelock::with_lock(path, || {
1458        let mut doc = read_document(path)?;
1459        // Comments at the end of the file stay above what is appended, where they
1460        // were written: below it, a commented `language = …` would land inside the account.
1461        let trailing = doc.trailing().as_str().unwrap_or("").trim_end().to_string();
1462        table.decor_mut().set_prefix(if trailing.trim().is_empty() { "\n".to_string() } else { format!("{trailing}\n\n") });
1463        doc.set_trailing("");
1464        let accounts = doc.entry("account").or_insert(Item::ArrayOfTables(ArrayOfTables::new()));
1465        let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| format!("{}: `account` is not a list of tables", path.display()))?;
1466        accounts.push(table);
1467        write_document(path, &doc)
1468    })
1469}
1470
1471/// Moves an account `delta` places among those of its kind (sites among
1472/// sites): the order the lists show them in. At either end, it stays.
1473pub fn move_account(path: &Path, id: &str, delta: i64) -> Result<(), String> {
1474    // Read, changed and written under the file's lock, which the sharing takes too.
1475    crate::filelock::with_lock(path, || {
1476        let mut doc = read_document(path)?;
1477        let Some(accounts) = doc.get_mut("account").and_then(Item::as_array_of_tables_mut) else { return Ok(()) };
1478        let mut tables: Vec<Table> = accounts.iter().cloned().collect();
1479        let kind_of = |t: &Table| t.get("kind").and_then(Item::as_str).unwrap_or("").to_string();
1480        let Some(from) = tables.iter().position(|t| t.get("id").and_then(Item::as_str) == Some(id)) else { return Ok(()) };
1481        let kind = kind_of(&tables[from]);
1482        let same: Vec<usize> = tables.iter().enumerate().filter(|(_, t)| kind_of(t) == kind).map(|(i, _)| i).collect();
1483        let at = same.iter().position(|&i| i == from).unwrap_or(0) as i64;
1484        let Some(&to) = usize::try_from(at + delta).ok().and_then(|t| same.get(t)) else { return Ok(()) };
1485        // The tables trade places; each keeps the comments above it.
1486        swap_tables(&mut tables, from, to);
1487        let mut moved = ArrayOfTables::new();
1488        for table in tables {
1489            moved.push(table);
1490        }
1491        *accounts = moved;
1492        write_document(path, &doc)
1493    })
1494}
1495
1496/// Removes an account from the configuration. Its mail and its password are not touched here.
1497pub fn remove_account(path: &Path, id: &str) -> Result<(), String> {
1498    // Read, changed and written under the file's lock, which the sharing takes too.
1499    crate::filelock::with_lock(path, || {
1500        let mut doc = read_document(path)?;
1501        let accounts = doc.get_mut("account").and_then(Item::as_array_of_tables_mut);
1502        let Some(accounts) = accounts else { return Ok(()) };
1503        let index = accounts.iter().position(|t| t.get("id").and_then(Item::as_str) == Some(id));
1504        if let Some(index) = index {
1505            accounts.remove(index);
1506        }
1507        write_document(path, &doc)
1508    })
1509}
1510
1511/// Records the authserv-ids to trust for an account (learned at its first sync).
1512pub fn set_trusted_ids(path: &Path, id: &str, ids: &[String]) -> Result<(), String> {
1513    set_account_item(path, id, "trusted_authserv_ids", value(ids.iter().map(String::as_str).collect::<Array>()))
1514}
1515
1516/// How an account signs in: `Some("google")` for Google's sign-in,
1517/// `Some("microsoft")` for Microsoft's (an access token), None for a password
1518/// (an app password given instead).
1519pub fn set_auth(path: &Path, id: &str, auth: Option<&str>) -> Result<(), String> {
1520    // Read, changed and written under the file's lock, which the sharing takes too.
1521    crate::filelock::with_lock(path, || {
1522        match auth {
1523            Some(auth) => set_account_item(path, id, "auth", value(auth)),
1524            None => {
1525                let mut doc = read_document(path)?;
1526                let table = doc
1527                    .get_mut("account")
1528                    .and_then(Item::as_array_of_tables_mut)
1529                    .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1530                    .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1531                table.remove("auth");
1532                write_document(path, &doc)
1533            }
1534        }
1535    })
1536}
1537
1538/// An account signed in again with Microsoft's sign-in (docs/microsoft.md),
1539/// one added with a password before among them: how it signs in, its
1540/// servers and its user name, written at once from `account`.
1541pub fn set_sign_in(path: &Path, account: &Account) -> Result<(), String> {
1542    // Read, changed and written under the file's lock, which the sharing takes too.
1543    crate::filelock::with_lock(path, || {
1544        let mut doc = read_document(path)?;
1545        let id = account.id.as_str();
1546        let table = doc
1547            .get_mut("account")
1548            .and_then(Item::as_array_of_tables_mut)
1549            .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1550            .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1551        match &account.auth {
1552            Some(auth) => table["auth"] = value(auth.as_str()),
1553            None => drop(table.remove("auth")),
1554        }
1555        if let Some(host) = &account.host {
1556            table["host"] = value(host.as_str());
1557        }
1558        table["port"] = value(i64::from(account.port_or_default()));
1559        table["security"] = value(account.security.as_str());
1560        match &account.username {
1561            Some(username) => table["username"] = value(username.as_str()),
1562            None => drop(table.remove("username")),
1563        }
1564        match &account.microsoft_kind {
1565            Some(kind) => table["microsoft_kind"] = value(kind.as_str()),
1566            None => drop(table.remove("microsoft_kind")),
1567        }
1568        if let Some(host) = &account.smtp_host {
1569            table["smtp_host"] = value(host.as_str());
1570            table["smtp_port"] = value(i64::from(account.smtp_port_or_default()));
1571            table["smtp_security"] = value(account.smtp_security.as_str());
1572        }
1573        write_document(path, &doc)
1574    })
1575}
1576
1577/// Records an account's sending server (found from its address when first needed).
1578pub fn set_smtp(path: &Path, id: &str, host: &str, port: u16, security: Security) -> Result<(), String> {
1579    set_account_item(path, id, "smtp_host", value(host))?;
1580    set_account_item(path, id, "smtp_port", value(i64::from(port)))?;
1581    set_account_item(path, id, "smtp_security", value(security.as_str()))
1582}
1583
1584/// Days of history from a setting in weeks: two weeks by default, None for everything (0).
1585pub fn history_days(weeks: Option<u32>) -> Option<i64> {
1586    match weeks.unwrap_or(DEFAULT_HISTORY_WEEKS) {
1587        0 => None,
1588        weeks => Some(i64::from(weeks) * 7),
1589    }
1590}
1591
1592/// Weeks of history when the configuration says nothing.
1593pub const DEFAULT_HISTORY_WEEKS: u32 = 2;
1594
1595/// Minutes between two fetches of the folders the server does not push.
1596pub const DEFAULT_FETCH_MINUTES: u32 = 5;
1597
1598/// Sets how far back mail and the agenda reach, in weeks (0 for everything).
1599pub fn set_history(path: &Path, weeks: u32) -> Result<(), String> {
1600    // Read, changed and written under the file's lock, which the sharing takes too.
1601    crate::filelock::with_lock(path, || {
1602        let mut doc = read_document(path)?;
1603        doc["history_weeks"] = value(i64::from(weeks));
1604        write_document(path, &doc)
1605    })
1606}
1607
1608/// Sets an account's priority; "average", the default, is written too, so the choice shows.
1609pub fn set_priority(path: &Path, id: &str, priority: Priority) -> Result<(), String> {
1610    set_account_item(path, id, "priority", value(priority.as_str()))
1611}
1612
1613/// Sets your name as recipients see it and your signature (Markdown); empty removes them.
1614pub fn set_writing(path: &Path, id: &str, name: &str, signature: &str) -> Result<(), String> {
1615    // Read, changed and written under the file's lock, which the sharing takes too.
1616    crate::filelock::with_lock(path, || {
1617        let mut doc = read_document(path)?;
1618        let table = doc
1619            .get_mut("account")
1620            .and_then(Item::as_array_of_tables_mut)
1621            .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1622            .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1623        for (key, text) in [("name", name.trim()), ("signature", signature.trim())] {
1624            if text.is_empty() {
1625                table.remove(key);
1626            } else {
1627                table[key] = value(text);
1628            }
1629        }
1630        write_document(path, &doc)
1631    })
1632}
1633
1634/// A setting's value, as the window gives it back.
1635#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1636#[serde(untagged)]
1637pub enum SettingValue {
1638    Bool(bool),
1639    Int(i64),
1640    Float(f64),
1641    Text(String),
1642    Ints(Vec<i64>),
1643    Texts(Vec<String>),
1644    Windows(Vec<WindowValue>),
1645    TimeOff(Vec<TimeOffValue>),
1646    Routes(Vec<crate::projects::RouteValue>),
1647    /// Names with their ids: kinds of task, categories. Shown, never read back.
1648    Named(Vec<NamedValue>),
1649    /// One name changed: renamed (`from`, `to`), taken away (`to` empty), added (`from` empty).
1650    Rename(RenameValue),
1651}
1652
1653/// A name and what it stands for.
1654#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1655pub struct NamedValue {
1656    pub id: String,
1657    pub label: String,
1658    /// Why it cannot be renamed or taken away here (Google's calendars); empty when it can.
1659    #[serde(default, skip_serializing_if = "String::is_empty")]
1660    pub locked: String,
1661    /// Said beside it, not part of its name: where a calendar lives.
1662    #[serde(default, skip_serializing_if = "String::is_empty")]
1663    pub detail: String,
1664}
1665
1666/// One name changed.
1667#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1668pub struct RenameValue {
1669    pub from: String,
1670    pub to: String,
1671}
1672
1673/// Days off, as the settings edit them.
1674#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1675pub struct TimeOffValue {
1676    /// Its id, when it has one (`TimeOff::id`): an edit of it names it so.
1677    #[serde(default, skip_serializing_if = "String::is_empty")]
1678    pub id: String,
1679    /// "2026-12-24".
1680    pub from: String,
1681    pub until: String,
1682    #[serde(default)]
1683    pub label: String,
1684}
1685
1686/// A day's hours, as the settings edit them.
1687#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1688pub struct WindowValue {
1689    pub day: String,
1690    pub start: String,
1691    /// "17:00".
1692    #[serde(default)]
1693    pub end: String,
1694    /// Older form of the end, read and not written.
1695    #[serde(default)]
1696    pub minutes: u32,
1697}
1698
1699impl SettingValue {
1700    /// As a TOML item: an empty text or list removes the key (the default comes back).
1701    fn item(&self) -> Option<Item> {
1702        Some(match self {
1703            SettingValue::Bool(b) => value(*b),
1704            SettingValue::Int(n) => value(*n),
1705            SettingValue::Float(f) => value(*f),
1706            SettingValue::Text(t) if t.trim().is_empty() => return None,
1707            SettingValue::Text(t) => value(t.trim()),
1708            SettingValue::Ints(list) if list.is_empty() => return None,
1709            SettingValue::Ints(list) => value(list.iter().copied().collect::<Array>()),
1710            SettingValue::Texts(list) if list.is_empty() => return None,
1711            SettingValue::Texts(list) => value(list.iter().map(String::as_str).collect::<Array>()),
1712            SettingValue::Windows(_) | SettingValue::TimeOff(_) | SettingValue::Routes(_) | SettingValue::Named(_) | SettingValue::Rename(_) => return None,
1713        })
1714    }
1715}
1716
1717/// Sets one setting in the file, in place, keeping its comments and order:
1718/// `history_weeks`, `reading.size` (a table's key), `account.<id>.priority`
1719/// (an account's), `window` (every admin window at once). An empty value
1720/// removes the key, so its default comes back.
1721pub fn set_value(path: &Path, key: &str, setting: &SettingValue) -> Result<(), String> {
1722    // Read, changed and written under the file's lock, which the sharing takes too.
1723    crate::filelock::with_lock(path, || {
1724        let mut doc = read_document(path)?;
1725        if key == "time_off" {
1726            let days: &[TimeOffValue] = match setting {
1727                SettingValue::TimeOff(days) => days,
1728                // An empty list reads as the first kind of list it fits, numbers: none.
1729                SettingValue::Ints(none) if none.is_empty() => &[],
1730                _ => return Err(format!("{key}: days off expected")),
1731            };
1732            let date = |text: &str| -> Result<toml_edit::Datetime, String> {
1733                let d: jiff::civil::Date = text.trim().parse().map_err(|_| format!("{text}: a day as 2026-12-24"))?;
1734                Ok(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 })
1735            };
1736            // Each day off already there keeps its table (its id, given when the
1737            // sharing took format 2, and what else it holds); a new one takes an
1738            // id of its own once the list carries ids (format 2: one id per day off).
1739            let mut there: Vec<Table> = doc.get(key).and_then(Item::as_array_of_tables).map(|list| list.iter().cloned().collect()).unwrap_or_default();
1740            let with_ids = there.iter().any(|t| t.get("id").and_then(Item::as_str).is_some_and(|id| !id.is_empty()));
1741            let same = |t: &Table, from: &toml_edit::Datetime, until: &toml_edit::Datetime, label: &str| {
1742                t.get("from").and_then(Item::as_datetime) == Some(from) && t.get("until").and_then(Item::as_datetime) == Some(until) && t.get("label").and_then(Item::as_str).unwrap_or_default() == label
1743            };
1744            let mut tables = ArrayOfTables::new();
1745            for off in days {
1746                let (from, until, label) = (date(&off.from)?, date(&off.until)?, off.label.trim());
1747                // Named by its id: changed in its own table, which keeps its id and the rest.
1748                if let Some(at) = there.iter().position(|t| !off.id.is_empty() && t.get("id").and_then(Item::as_str) == Some(off.id.as_str())) {
1749                    let mut table = there.remove(at);
1750                    table["from"] = value(from);
1751                    table["until"] = value(until);
1752                    if label.is_empty() {
1753                        table.remove("label");
1754                    } else {
1755                        table["label"] = value(label);
1756                    }
1757                    tables.push(table);
1758                    continue;
1759                }
1760                if let Some(at) = there.iter().position(|t| same(t, &from, &until, label)) {
1761                    tables.push(there.remove(at));
1762                    continue;
1763                }
1764                let mut table = Table::new();
1765                if with_ids {
1766                    table["id"] = value(crate::ids::new());
1767                }
1768                table["from"] = value(from);
1769                table["until"] = value(until);
1770                if !label.is_empty() {
1771                    table["label"] = value(label);
1772                }
1773                tables.push(table);
1774            }
1775            if days.is_empty() {
1776                doc.remove(key);
1777            } else {
1778                doc[key] = Item::ArrayOfTables(tables);
1779            }
1780            return write_document(path, &doc);
1781        }
1782        if key == "window" || key == "window.admin" || key == "window.leisure" || key == "office_hours" {
1783            let windows: &[WindowValue] = match setting {
1784                SettingValue::Windows(windows) => windows,
1785                // An empty list reads as the first kind of list it fits, numbers: none.
1786                SettingValue::Ints(none) if none.is_empty() => &[],
1787                _ => return Err(format!("{key}: windows expected")),
1788            };
1789            // The week's hours of one kind: those of the other kinds stay as they are.
1790            let (table_key, kind) = match key {
1791                "window.admin" => ("window", "admin"),
1792                "window.leisure" => ("window", "leisure"),
1793                "window" => ("window", "work"),
1794                other => (other, ""),
1795            };
1796            let kind_of = |t: &Table| match t.get("kind").and_then(Item::as_str) {
1797                Some("admin") => "admin",
1798                Some("leisure") => "leisure",
1799                _ => "work",
1800            };
1801            let mut tables = ArrayOfTables::new();
1802            if !kind.is_empty()
1803                && let Some(old) = doc.get(table_key).and_then(Item::as_array_of_tables)
1804            {
1805                for table in old.iter().filter(|t| kind_of(t) != kind) {
1806                    tables.push(table.clone());
1807                }
1808            }
1809            // One row a day: a day given twice keeps its first hours.
1810            let mut seen: Vec<String> = Vec::new();
1811            for w in windows {
1812                if seen.contains(&w.day) {
1813                    continue;
1814                }
1815                seen.push(w.day.clone());
1816                let mut table = Table::new();
1817                table["day"] = value(w.day.as_str());
1818                table["start"] = value(w.start.as_str());
1819                if w.end.is_empty() {
1820                    table["minutes"] = value(i64::from(w.minutes));
1821                } else {
1822                    table["end"] = value(w.end.as_str());
1823                }
1824                if kind == "admin" || kind == "leisure" {
1825                    table["kind"] = value(kind);
1826                }
1827                tables.push(table);
1828            }
1829            if tables.is_empty() {
1830                doc.remove(table_key);
1831            } else {
1832                doc[table_key] = Item::ArrayOfTables(tables);
1833            }
1834            return write_document(path, &doc);
1835        }
1836        // A site's field: in `[[site]]`, where an older file's sites are moved first.
1837        if let Some(rest) = key.strip_prefix("site.") {
1838            let (id, field) = rest.rsplit_once('.').ok_or_else(|| format!("{key}: site.<id>.<field> expected"))?;
1839            drop(doc);
1840            migrate_sites(path)?;
1841            let mut doc = read_document(path)?;
1842            let table = doc
1843                .get_mut("site")
1844                .and_then(Item::as_array_of_tables_mut)
1845                .and_then(|sites| sites.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1846                .ok_or_else(|| format!("{}: no site {id}", path.display()))?;
1847            match setting.item() {
1848                Some(item) => table[field] = item,
1849                None => {
1850                    table.remove(field);
1851                }
1852            }
1853            return write_document(path, &doc);
1854        }
1855        if let Some(rest) = key.strip_prefix("account.") {
1856            let (id, field) = rest.rsplit_once('.').ok_or_else(|| format!("{key}: account.<id>.<field> expected"))?;
1857            let table = doc
1858                .get_mut("account")
1859                .and_then(Item::as_array_of_tables_mut)
1860                .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1861                .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1862            match setting.item() {
1863                Some(item) => table[field] = item,
1864                None => {
1865                    table.remove(field);
1866                }
1867            }
1868            return write_document(path, &doc);
1869        }
1870        match key.split_once('.') {
1871            Some((table, field)) => {
1872                if !doc.contains_key(table) {
1873                    doc[table] = toml_edit::table();
1874                }
1875                // A table written inline (`reading = { size = 18 }`) is a table too.
1876                let section = doc[table].as_table_like_mut().ok_or_else(|| format!("{}: {table} is not a table", path.display()))?;
1877                match setting.item() {
1878                    // In place: the comments above the key stay.
1879                    Some(item) => match section.get_mut(field) {
1880                        Some(slot) => *slot = item,
1881                        None => {
1882                            section.insert(field, item);
1883                        }
1884                    },
1885                    None => {
1886                        section.remove(field);
1887                    }
1888                }
1889            }
1890            None => match setting.item() {
1891                Some(item) => doc[key] = item,
1892                None => {
1893                    doc.remove(key);
1894                }
1895            },
1896        }
1897        write_document(path, &doc)
1898    })
1899}
1900
1901fn set_account_item(path: &Path, id: &str, key: &str, item: Item) -> Result<(), String> {
1902    // Read, changed and written under the file's lock, which the sharing takes too.
1903    crate::filelock::with_lock(path, || {
1904        let mut doc = read_document(path)?;
1905        let table = doc
1906            .get_mut("account")
1907            .and_then(Item::as_array_of_tables_mut)
1908            .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1909            .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1910        table[key] = item;
1911        write_document(path, &doc)
1912    })
1913}
1914
1915/// A table's keys written at once, each a list of words (`Some`) or taken
1916/// out (`None`), the table kept even empty (it says it was written); and
1917/// `remove`, keys or tables taken out ("reach", "pause.doses"): one write,
1918/// the comments kept (`attention`'s rows, the older keys they replace).
1919pub fn set_table(path: &Path, table: &str, keys: &[(String, Option<Vec<String>>)], remove: &[&str]) -> Result<(), String> {
1920    // Read, changed and written under the file's lock, which the sharing takes too.
1921    crate::filelock::with_lock(path, || {
1922        let mut doc = read_document(path)?;
1923        for key in remove {
1924            match key.split_once('.') {
1925                Some((outer, field)) => {
1926                    if let Some(section) = doc.get_mut(outer).and_then(Item::as_table_like_mut) {
1927                        section.remove(field);
1928                    }
1929                }
1930                None => {
1931                    doc.remove(key);
1932                }
1933            }
1934        }
1935        if !doc.contains_key(table) {
1936            doc[table] = toml_edit::table();
1937        }
1938        let section = doc[table].as_table_like_mut().ok_or_else(|| format!("{}: {table} is not a table", path.display()))?;
1939        for (key, words) in keys {
1940            match words {
1941                Some(words) => {
1942                    let item = value(words.iter().map(String::as_str).collect::<Array>());
1943                    match section.get_mut(key) {
1944                        Some(slot) => *slot = item,
1945                        None => {
1946                            section.insert(key, item);
1947                        }
1948                    }
1949                }
1950                None => {
1951                    section.remove(key);
1952                }
1953            }
1954        }
1955        write_document(path, &doc)
1956    })
1957}
1958
1959/// Writes the mail filters whole, in their order, as `[[mail.filter]]`
1960/// tables, each condition and each action an inline table on a line of its
1961/// own (`rules::Filter`); the rest of the file and its comments stay. None:
1962/// the list is taken out.
1963pub fn set_filters(path: &Path, filters: &[crate::rules::Filter]) -> Result<(), String> {
1964    // Read, changed and written under the file's lock, which the sharing takes too.
1965    crate::filelock::with_lock(path, || {
1966        let mut doc = read_document(path)?;
1967        // A filter a newer Sioul wrote, holding what this one does not know
1968        // (`Filter::unknown`), is written back as it was while nothing of it
1969        // changed here: its words kept, not "unknown".
1970        let before: Vec<(crate::rules::Filter, Table)> = doc
1971            .get("mail")
1972            .and_then(|mail| mail.get("filter"))
1973            .and_then(Item::as_array_of_tables)
1974            .map(|list| list.iter().filter_map(|table| toml::from_str::<crate::rules::Filter>(&table.to_string()).ok().map(|filter| (filter, table.clone()))).collect())
1975            .unwrap_or_default();
1976        if !doc.contains_key("mail") {
1977            let mut mail = Table::new();
1978            mail.set_implicit(true);
1979            doc["mail"] = Item::Table(mail);
1980        }
1981        let mail = doc["mail"].as_table_mut().ok_or_else(|| format!("{}: mail is not a table", path.display()))?;
1982        if filters.is_empty() {
1983            mail.remove("filter");
1984        } else {
1985            let mut tables = ArrayOfTables::new();
1986            for filter in filters {
1987                match before.iter().find(|(was, _)| filter.unknown() && was == filter) {
1988                    // Its values as they were, in a table of its own (not its old place in the file).
1989                    Some((_, was)) => {
1990                        let mut kept = Table::new();
1991                        for (key, item) in was.iter() {
1992                            kept.insert(key, item.clone());
1993                        }
1994                        tables.push(kept);
1995                    }
1996                    None => tables.push(filter_table(filter)?),
1997                }
1998            }
1999            mail.insert("filter", Item::ArrayOfTables(tables));
2000        }
2001        write_document(path, &doc)
2002    })
2003}
2004
2005/// One filter as its table, through what `rules::Filter` writes (the usual
2006/// values left out); its lists of conditions and actions inline.
2007fn filter_table(filter: &crate::rules::Filter) -> Result<Table, String> {
2008    let text = toml::to_string(filter).map_err(|e| e.to_string())?;
2009    let written: DocumentMut = text.parse().map_err(|e| format!("{e}"))?;
2010    let mut table = Table::new();
2011    for (key, item) in written.iter() {
2012        let item = match item {
2013            Item::ArrayOfTables(list) => {
2014                let mut array = Array::new();
2015                for element in list.iter() {
2016                    let mut inline = element.clone().into_inline_table();
2017                    inline.fmt();
2018                    let mut element = toml_edit::Value::InlineTable(inline);
2019                    element.decor_mut().set_prefix("\n    ");
2020                    array.push_formatted(element);
2021                }
2022                array.set_trailing_comma(true);
2023                array.set_trailing("\n");
2024                value(array)
2025            }
2026            other => other.clone(),
2027        };
2028        table.insert(key, item);
2029    }
2030    Ok(table)
2031}
2032
2033pub(crate) fn read_document(path: &Path) -> Result<DocumentMut, String> {
2034    let text = match std::fs::read_to_string(path) {
2035        Ok(text) => text,
2036        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::from("# Sioul configuration (examples/config.toml explains each setting).\n"),
2037        Err(e) => return Err(format!("{}: {e}", path.display())),
2038    };
2039    text.parse().map_err(|e| format!("{}: {e}", path.display()))
2040}
2041
2042/// Writes next to the file, then renames, so a crash never leaves half a
2043/// configuration. A configuration that is a link (kept in a dotfiles folder)
2044/// is written where it points, the link kept.
2045pub(crate) fn write_document(path: &Path, doc: &DocumentMut) -> Result<(), String> {
2046    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
2047    let target = std::fs::canonicalize(path).ok().filter(|_| path.is_symlink());
2048    let path = target.as_deref().unwrap_or(path);
2049    let text = doc.to_string();
2050    // What Sioul could read before, it reads after: a value of the wrong kind
2051    // (a number with decimals where a whole one goes) is refused, never written.
2052    if let Err(e) = toml::from_str::<Config>(&text)
2053        && std::fs::read_to_string(path).is_ok_and(|old| toml::from_str::<Config>(&old).is_ok())
2054    {
2055        return Err(format!("{}: {e}", path.display()));
2056    }
2057    if let Some(parent) = path.parent() {
2058        std::fs::create_dir_all(parent).map_err(fail)?;
2059    }
2060    let temporary = path.with_extension("toml.new");
2061    std::fs::write(&temporary, text).map_err(fail)?;
2062    std::fs::rename(&temporary, path).map_err(fail)
2063}
2064
2065/// The name under which Sioul writes its own checks of a message (RFC 8601
2066/// authserv-id): unique to this installation and under `.invalid`, so no
2067/// sender can write results that pass for Sioul's. Made once, kept in the state folder.
2068pub fn sioul_authserv_id() -> String {
2069    let path = state_dir().join("authserv-id");
2070    if let Some(id) = std::fs::read_to_string(&path).ok().map(|t| t.trim().to_string()).filter(|t| t.ends_with(".invalid")) {
2071        return id;
2072    }
2073    use std::hash::{Hash, Hasher};
2074    let mut hasher = std::collections::hash_map::DefaultHasher::new();
2075    std::time::SystemTime::now().hash(&mut hasher);
2076    std::process::id().hash(&mut hasher);
2077    path.hash(&mut hasher);
2078    let id = format!("sioul-{:012x}.invalid", hasher.finish() & 0xffff_ffff_ffff);
2079    let _ = std::fs::create_dir_all(state_dir()).and_then(|()| std::fs::write(&path, &id));
2080    id
2081}
2082
2083/// Where the configuration lives by default.
2084pub fn default_path() -> PathBuf {
2085    config_dir().join("config.toml")
2086}
2087
2088/// `$XDG_CONFIG_HOME/sioul`, else `~/.config/sioul`.
2089pub fn config_dir() -> PathBuf {
2090    xdg("XDG_CONFIG_HOME", "~/.config").join("sioul")
2091}
2092
2093/// `$XDG_DATA_HOME/sioul`, else `~/.local/share/sioul`: the mail.
2094pub fn data_dir() -> PathBuf {
2095    xdg("XDG_DATA_HOME", "~/.local/share").join("sioul")
2096}
2097
2098/// `$XDG_STATE_HOME/sioul`, else `~/.local/state/sioul`: where sync and the Porch stopped.
2099pub fn state_dir() -> PathBuf {
2100    xdg("XDG_STATE_HOME", "~/.local/state").join("sioul")
2101}
2102
2103/// `$<variable>/sioul` when the variable is set, on every system (tests set
2104/// them); else Linux's XDG fallback, and the system's own folders on Windows
2105/// (`%APPDATA%`, `%LOCALAPPDATA%`) and macOS (`~/Library/Application Support`).
2106fn xdg(variable: &str, fallback: &str) -> PathBuf {
2107    if let Some(value) = std::env::var_os(variable).filter(|v| !v.is_empty()) {
2108        return PathBuf::from(value);
2109    }
2110    #[cfg(any(windows, target_os = "macos"))]
2111    if let Some(dirs) = directories::BaseDirs::new() {
2112        return match variable {
2113            "XDG_CONFIG_HOME" => dirs.config_dir().to_path_buf(),
2114            "XDG_CACHE_HOME" => dirs.cache_dir().to_path_buf(),
2115            "XDG_STATE_HOME" => dirs.data_local_dir().join("state"),
2116            _ => dirs.data_local_dir().to_path_buf(),
2117        };
2118    }
2119    expand_home(fallback)
2120}
2121
2122/// `$XDG_CACHE_HOME/sioul`, else `~/.cache/sioul`: copies made to open attachments.
2123pub fn cache_dir() -> PathBuf {
2124    xdg("XDG_CACHE_HOME", "~/.cache").join("sioul")
2125}
2126
2127/// Makes Sioul's own folders (configuration, data, state, cache) yours
2128/// alone, as they hold mail, keys, drafts and caches: made with mode 0700 on
2129/// Unix, and narrowed to it when they exist already. Their parents are made
2130/// as usual and never changed, and a folder that is a link is left as it is.
2131/// Nothing to do elsewhere: a Windows profile's folders are the user's own.
2132/// Called at start; what cannot be done is left, said by the first write that fails.
2133pub fn make_private_dirs() {
2134    #[cfg(unix)]
2135    for dir in [config_dir(), data_dir(), state_dir(), cache_dir()] {
2136        let _ = private_dir(&dir);
2137    }
2138}
2139
2140/// One folder of Sioul's own, made or narrowed to 0700.
2141#[cfg(unix)]
2142fn private_dir(dir: &Path) -> std::io::Result<()> {
2143    use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
2144    if let Some(parent) = dir.parent() {
2145        std::fs::create_dir_all(parent)?;
2146    }
2147    match std::fs::DirBuilder::new().mode(0o700).create(dir) {
2148        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2149            let metadata = std::fs::symlink_metadata(dir)?;
2150            if metadata.is_dir() && metadata.permissions().mode() & 0o077 != 0 {
2151                std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
2152            }
2153            Ok(())
2154        }
2155        other => other,
2156    }
2157}
2158
2159/// Where the desktop puts downloads: `XDG_DOWNLOAD_DIR` on Linux, the system's elsewhere.
2160pub fn downloads_dir() -> PathBuf {
2161    #[cfg(any(windows, target_os = "macos"))]
2162    if let Some(dir) = directories::UserDirs::new().and_then(|u| u.download_dir().map(Path::to_path_buf)) {
2163        return dir;
2164    }
2165    let dirs = expand_home("~/.config/user-dirs.dirs");
2166    let named = std::fs::read_to_string(dirs).ok().and_then(|text| {
2167        let line = text.lines().find(|l| l.trim_start().starts_with("XDG_DOWNLOAD_DIR="))?;
2168        let value = line.split_once('=')?.1.trim().trim_matches('"');
2169        Some(PathBuf::from(value.replace("$HOME", &std::env::var("HOME").unwrap_or_default())))
2170    });
2171    named.unwrap_or_else(|| expand_home("~/Downloads"))
2172}
2173
2174/// Expands a leading `~` to the home folder (`USERPROFILE` on Windows):
2175/// "~/Notes", and "~\Notes" as Windows writes it.
2176pub fn expand_home(path: &str) -> PathBuf {
2177    // Windows' own first there: a Unix shell's HOME may read "/c/Users/…".
2178    let (first, second) = if cfg!(windows) { ("USERPROFILE", "HOME") } else { ("HOME", "USERPROFILE") };
2179    let home = std::env::var_os(first).or_else(|| std::env::var_os(second)).map(PathBuf::from);
2180    match (path.strip_prefix('~'), home) {
2181        // A rest starting with a separator would replace the home when joined.
2182        (Some(rest), Some(home)) => home.join(rest.trim_start_matches(['/', '\\'])),
2183        _ => PathBuf::from(path),
2184    }
2185}
2186
2187#[cfg(test)]
2188mod tests {
2189    use super::*;
2190
2191    /// A message found by the path the system resolves (macOS's /var is
2192    /// /private/var) still has its account, the account's folder named
2193    /// through a link.
2194    #[cfg(unix)]
2195    #[test]
2196    fn an_account_is_found_through_a_link() {
2197        let dir = std::env::temp_dir().join(format!("sioul-account-link-{}", std::process::id()));
2198        let _ = std::fs::remove_dir_all(&dir);
2199        std::fs::create_dir_all(dir.join("real/mail-a/cur")).unwrap();
2200        std::os::unix::fs::symlink(dir.join("real"), dir.join("link")).unwrap();
2201        let file = dir.join("real/mail-a/cur/1.x:2,S");
2202        std::fs::write(&file, "Subject: x\r\n\r\nx").unwrap();
2203        let config: Config = toml::from_str(&format!("[[account]]\nid = \"a\"\nkind = \"imap\"\nhost = \"imap.example.org\"\nmaildir = \"{}\"\n", dir.join("link/mail-a").display())).unwrap();
2204        let resolved = std::fs::canonicalize(&file).unwrap();
2205        assert_eq!(config.account_of(&resolved).map(|a| a.id.as_str()), Some("a"));
2206        assert_eq!(config.account_of(&dir.join("link/mail-a/cur/1.x:2,S")).map(|a| a.id.as_str()), Some("a"));
2207        assert!(config.account_of(&dir.join("elsewhere/1.x")).is_none());
2208        let _ = std::fs::remove_dir_all(&dir);
2209    }
2210
2211    #[test]
2212    fn settings_are_written_in_place() {
2213        let dir = std::env::temp_dir().join(format!("sioul-settings-{}", std::process::id()));
2214        std::fs::create_dir_all(&dir).unwrap();
2215        let path = dir.join("config.toml");
2216        std::fs::write(&path, "# Mine.\ncase_store = \"~/Notes\"\n\n[[account]]\nid = \"a\"\nkind = \"imap\"\n").unwrap();
2217        set_value(&path, "reading.spacing", &SettingValue::Float(1.6)).unwrap();
2218        set_value(&path, "some_days", &SettingValue::Ints(vec![30, 30, 30, 30, 30, 0, 0])).unwrap();
2219        set_value(&path, "account.a.shield", &SettingValue::Bool(true)).unwrap();
2220        set_value(&path, "window", &SettingValue::Windows(vec![WindowValue { day: "tuesday".into(), start: "10:00".into(), end: "17:00".into(), minutes: 0 }])).unwrap();
2221        // Hours for admin beside working hours: each kind kept when the other is saved.
2222        set_value(&path, "window.admin", &SettingValue::Windows(vec![WindowValue { day: "tuesday".into(), start: "18:00".into(), end: "19:00".into(), minutes: 0 }])).unwrap();
2223        let both = Config::load(&path).unwrap();
2224        assert_eq!(both.working_hours().len(), 1);
2225        assert_eq!(both.week_hours().iter().filter(|w| w.kind() == "admin").count(), 1);
2226        set_value(&path, "window", &SettingValue::Windows(vec![WindowValue { day: "monday".into(), start: "09:00".into(), end: "17:00".into(), minutes: 0 }])).unwrap();
2227        let again = Config::load(&path).unwrap();
2228        assert_eq!(again.working_hours()[0].day, "monday");
2229        assert_eq!(again.week_hours().iter().filter(|w| w.kind() == "admin").map(|w| w.start.as_str()).collect::<Vec<_>>(), vec!["18:00"]);
2230        set_value(&path, "case_store", &SettingValue::Text("~/Notes".into())).unwrap();
2231        set_value(&path, "reading.size", &SettingValue::Int(18)).unwrap();
2232        set_value(&path, "reading.size", &SettingValue::Text(String::new())).unwrap();
2233        let config = Config::load(&path).unwrap();
2234        assert_eq!((config.reading.spacing, config.accounts[0].shield), (1.6, true));
2235        assert!(std::fs::read_to_string(&path).unwrap().contains("some_days = [30, 30, 30, 30, 30, 0, 0]"));
2236        assert_eq!((config.windows.len(), config.notes_root.as_deref(), config.reading.size), (2, Some("~/Notes"), 16));
2237        assert!(std::fs::read_to_string(&path).unwrap().starts_with("# Mine."));
2238        // The last day off, the last hours taken away: the window's JSON `[]` reads as numbers.
2239        let empty: SettingValue = serde_json::from_str("[]").unwrap();
2240        set_value(&path, "time_off", &SettingValue::TimeOff(vec![TimeOffValue { id: String::new(), from: "2026-12-24".into(), until: "2026-12-26".into(), label: String::new() }])).unwrap();
2241        set_value(&path, "time_off", &empty).unwrap();
2242        set_value(&path, "window.admin", &empty).unwrap();
2243        let config = Config::load(&path).unwrap();
2244        assert!(config.time_off.is_empty() && config.week_hours().iter().all(|w| w.kind() != "admin"));
2245        // A table written inline takes a setting too, its comment above it kept.
2246        std::fs::write(&path, "# Mine.\n# Easier to read.\nreading = { size = 18 }\n").unwrap();
2247        set_value(&path, "reading.spacing", &SettingValue::Float(1.8)).unwrap();
2248        set_value(&path, "reading.size", &SettingValue::Int(20)).unwrap();
2249        let text = std::fs::read_to_string(&path).unwrap();
2250        assert!(text.contains("# Easier to read.\nreading = {"), "{text}");
2251        assert_eq!((Config::load(&path).unwrap().reading.size, Config::load(&path).unwrap().reading.spacing), (20, 1.8));
2252        // A value Sioul could not read back is refused: the file stays readable.
2253        assert!(set_value(&path, "reading.size", &SettingValue::Float(1.5)).is_err());
2254        assert_eq!(Config::load(&path).unwrap().reading.size, 20);
2255        std::fs::remove_dir_all(&dir).unwrap();
2256    }
2257
2258    #[test]
2259    fn sites_keep_the_order_chosen() {
2260        let dir = std::env::temp_dir().join(format!("sioul-order-{}", std::process::id()));
2261        let path = dir.join("config.toml");
2262        std::fs::create_dir_all(&dir).unwrap();
2263        std::fs::write(&path, "# Mine.\n[[site]]\nid = \"a\"\nurl = \"https://a.example.org\"\n\n# The second.\n[[site]]\nid = \"b\"\nurl = \"https://b.example.org\"\n").unwrap();
2264        move_site(&path, "b", -1, true).unwrap();
2265        let ids = |path: &Path| Config::load(path).unwrap().sites.iter().map(|s| s.id.clone()).collect::<Vec<_>>();
2266        assert_eq!(ids(&path), ["b", "a"]);
2267        let text = std::fs::read_to_string(&path).unwrap();
2268        assert!(text.contains("# The second.\n[[site]]\nid = \"b\""), "{text}");
2269        std::fs::write(&path, "[[account]]\nid = \"one\"\nkind = \"imap\"\n\n[[account]]\nid = \"two\"\nkind = \"imap\"\n").unwrap();
2270        move_account(&path, "two", -1).unwrap();
2271        assert_eq!(Config::load(&path).unwrap().accounts.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(), ["two", "one"]);
2272        std::fs::remove_dir_all(&dir).unwrap();
2273    }
2274
2275    /// The sites' order as its own setting (`site_order`), once each site
2276    /// travels apart (format 2 of the sharing): written by a move, it orders
2277    /// the sites wherever their tables stand in the file, those it does not
2278    /// name after; before format 2, a move writes the tables' order alone.
2279    #[test]
2280    fn the_sites_order_travels_as_its_own_setting() {
2281        let dir = std::env::temp_dir().join(format!("sioul-site-order-{}", std::process::id()));
2282        let path = dir.join("config.toml");
2283        std::fs::create_dir_all(&dir).unwrap();
2284        let ids = |path: &Path| Config::load(path).unwrap().sites.iter().map(|s| s.id.clone()).collect::<Vec<_>>();
2285        let three = "[[site]]\nid = \"a\"\nurl = \"https://a.example.org\"\n\n[[site]]\nid = \"b\"\nurl = \"https://b.example.org\"\n\n[[site]]\nid = \"c\"\nurl = \"https://c.example.org\"\n";
2286        std::fs::write(&path, three).unwrap();
2287        move_site(&path, "c", -1, false).unwrap();
2288        assert_eq!(ids(&path), ["a", "c", "b"]);
2289        assert!(!std::fs::read_to_string(&path).unwrap().contains("site_order"), "format 1: the list's own order travels");
2290        move_site(&path, "a", 1, true).unwrap();
2291        assert_eq!(ids(&path), ["c", "a", "b"]);
2292        assert!(std::fs::read_to_string(&path).unwrap().contains("site_order = [\"c\", \"a\", \"b\"]"));
2293        // Another device's order, its tables where they stood, a site pinned since last.
2294        std::fs::write(&path, format!("site_order = [\"b\", \"a\"]\n{three}")).unwrap();
2295        assert_eq!(ids(&path), ["b", "a", "c"]);
2296        move_site(&path, "c", -1, true).unwrap();
2297        assert_eq!(ids(&path), ["b", "c", "a"]);
2298        std::fs::remove_dir_all(&dir).unwrap();
2299    }
2300
2301    /// A configuration kept in a dotfiles folder, linked from its place: written there, the link kept.
2302    #[cfg(unix)]
2303    #[test]
2304    fn a_linked_configuration_stays_linked() {
2305        let dir = std::env::temp_dir().join(format!("sioul-linked-{}", std::process::id()));
2306        let _ = std::fs::remove_dir_all(&dir);
2307        std::fs::create_dir_all(dir.join("dotfiles")).unwrap();
2308        let (real, link) = (dir.join("dotfiles").join("config.toml"), dir.join("config.toml"));
2309        std::fs::write(&real, "# Mine.\n").unwrap();
2310        std::os::unix::fs::symlink(&real, &link).unwrap();
2311        set_history(&link, 4).unwrap();
2312        assert!(link.is_symlink());
2313        assert!(std::fs::read_to_string(&real).unwrap().contains("history_weeks = 4"));
2314        std::fs::remove_dir_all(&dir).unwrap();
2315    }
2316
2317    #[test]
2318    fn history_goes_above_the_accounts() {
2319        let path = std::env::temp_dir().join(format!("sioul-history-{}.toml", std::process::id()));
2320        std::fs::write(&path, "# mine\nlanguage = \"fr\"\n\n[[account]]\nid = \"a\"\nkind = \"imap\"\n").unwrap();
2321        set_history(&path, 0).unwrap();
2322        let text = std::fs::read_to_string(&path).unwrap();
2323        assert!(text.find("history_weeks = 0").unwrap() < text.find("[[account]]").unwrap(), "{text}");
2324        let config = Config::load(&path).unwrap();
2325        assert_eq!((config.history_days(), config.accounts[0].history_days()), (None, None));
2326        set_history(&path, 6).unwrap();
2327        assert_eq!(Config::load(&path).unwrap().accounts[0].history_days(), Some(42));
2328        std::fs::remove_file(&path).unwrap();
2329    }
2330
2331    #[test]
2332    fn kinds_named_your_way() {
2333        let path = std::env::temp_dir().join(format!("sioul-kinds-{}.toml", std::process::id()));
2334        std::fs::write(&path, "# mine\n[tasks]\nestimate = 20\n\n[[account]]\nid = \"a\"\nkind = \"imap\"\n").unwrap();
2335        let tr = crate::i18n::Translator::new("en");
2336        let ids = |config: &Config| config.task_kinds(&tr).into_iter().map(|(id, _)| id).collect::<Vec<_>>();
2337        let config = Config::load(&path).unwrap();
2338        assert_eq!(ids(&config).len(), crate::tasks::KINDS.len(), "Sioul's kinds until you name yours");
2339        change_kind(&path, "", "Rendez-vous").unwrap();
2340        let config = Config::load(&path).unwrap();
2341        assert_eq!(ids(&config).last().map(String::as_str), Some("rendez-vous"));
2342        change_kind(&path, "call", "Phone").unwrap();
2343        change_kind(&path, "think", "").unwrap();
2344        let config = Config::load(&path).unwrap();
2345        let kinds = config.task_kinds(&tr);
2346        assert!(kinds.contains(&("call".to_string(), "Phone".to_string())));
2347        assert!(kinds.contains(&("write".to_string(), "Writing".to_string())), "the others keep Sioul's names");
2348        assert!(!kinds.iter().any(|(id, _)| id == "think"));
2349        let text = std::fs::read_to_string(&path).unwrap();
2350        assert!(text.starts_with("# mine") && text.contains("estimate = 20") && config.accounts.len() == 1, "{text}");
2351        std::fs::remove_file(&path).unwrap();
2352    }
2353
2354    #[test]
2355    fn reads_the_example() {
2356        let config: Config = toml::from_str(include_str!("../../../examples/config.toml")).unwrap();
2357        assert!(config.sites.iter().any(|s| s.id == "proton" && s.kind == AccountKind::Portal));
2358        assert_eq!((config.windows.len(), config.working_hours().len(), config.time_off.len()), (3, 3, 1));
2359        assert_eq!(config.account("personal").and_then(|a| a.area.as_deref()), Some("personal"));
2360        let gmail = config.account("gmail").unwrap();
2361        assert_eq!(gmail.port_or_default(), 993);
2362        assert!(gmail.syncs());
2363    }
2364
2365    #[test]
2366    fn an_account_signed_in_again_with_microsoft_is_written_at_once() {
2367        let dir = std::env::temp_dir().join(format!("sioul-config-microsoft-{}", std::process::id()));
2368        std::fs::create_dir_all(&dir).unwrap();
2369        let path = dir.join("config.toml");
2370        std::fs::write(&path, "# mine\n[[account]]\nid = \"old\"\nkind = \"imap\"\naddress = \"old@hotmail.com\"\nhost = \"outlook.office365.com\"\nport = 993\nusername = \"Old@Hotmail.com\"\nsmtp_host = \"smtp.example.org\"\n").unwrap();
2371        let mut account = Config::load(&path).unwrap().account("old").unwrap().clone();
2372        account.auth = Some("microsoft".into());
2373        account.username = None;
2374        account.smtp_host = Some(MICROSOFT_SMTP_PERSONAL.into());
2375        account.smtp_port = Some(587);
2376        account.smtp_security = Security::Starttls;
2377        account.microsoft_kind = Some("personal".into());
2378        set_sign_in(&path, &account).unwrap();
2379        let back = Config::load(&path).unwrap().account("old").unwrap().clone();
2380        assert_eq!((back.auth.as_deref(), back.username.as_deref(), back.smtp_host.as_deref(), back.smtp_port, back.smtp_security), (Some("microsoft"), None, Some("smtp-mail.outlook.com"), Some(587), Security::Starttls));
2381        assert_eq!(back.microsoft_kind.as_deref(), Some("personal"));
2382        assert!(back.is_microsoft() && !back.is_microsoft_work());
2383        assert!(std::fs::read_to_string(&path).unwrap().starts_with("# mine"), "the file's comments stay");
2384        std::fs::remove_dir_all(&dir).unwrap();
2385    }
2386
2387    /// The review of 15 October 2026, L4: a work account's kind is its own;
2388    /// its sending server set by hand to Outlook.com's does not make it
2389    /// personal (the AI shield and agents would follow); none said counts as work.
2390    #[test]
2391    fn a_microsoft_accounts_kind_is_its_own() {
2392        let config: Config = toml::from_str(
2393            "[[account]]\nid = \"work\"\nkind = \"imap\"\naddress = \"you@contoso.example\"\nhost = \"outlook.office365.com\"\nauth = \"microsoft\"\nmicrosoft_kind = \"work\"\nsmtp_host = \"smtp-mail.outlook.com\"\nshield = true\nshield_ai = true\n\
2394             [[account]]\nid = \"home\"\nkind = \"imap\"\naddress = \"you@outlook.com\"\nhost = \"outlook.office365.com\"\nauth = \"microsoft\"\nmicrosoft_kind = \"personal\"\nsmtp_host = \"smtp.office365.com\"\n\
2395             [[account]]\nid = \"older\"\nkind = \"imap\"\naddress = \"me@hotmail.com\"\nhost = \"outlook.office365.com\"\nauth = \"microsoft\"\nsmtp_host = \"smtp-mail.outlook.com\"\n",
2396        )
2397        .unwrap();
2398        let work = config.account("work").unwrap();
2399        assert!(work.is_microsoft_work() && !work.ai_reads(), "Outlook.com's server, by hand: still a work account");
2400        assert!(!config.account("home").unwrap().is_microsoft_work());
2401        assert!(config.account("older").unwrap().is_microsoft_work(), "no kind said: work, until signed in again");
2402    }
2403
2404    #[test]
2405    fn a_mail_account_signed_in_with_google_keeps_it() {
2406        let dir = std::env::temp_dir().join(format!("sioul-config-google-{}", std::process::id()));
2407        let path = dir.join("config.toml");
2408        std::fs::create_dir_all(&dir).unwrap();
2409        let mut account = Account::imap("gmail", "you@gmail.com", "imap.gmail.com", 993, Security::Tls, None);
2410        account.auth = Some("google".into());
2411        add_imap_account(&path, &account).unwrap();
2412        assert_eq!(Config::load(&path).unwrap().account("gmail").and_then(|a| a.auth.clone()).as_deref(), Some("google"));
2413        // An account with a password says nothing of it.
2414        add_imap_account(&path, &Account::imap("other", "you@example.org", "imap.example.org", 993, Security::Tls, None)).unwrap();
2415        assert_eq!(Config::load(&path).unwrap().account("other").and_then(|a| a.auth.clone()), None);
2416        // Given an app password instead: a password's account again.
2417        set_auth(&path, "gmail", None).unwrap();
2418        assert_eq!(Config::load(&path).unwrap().account("gmail").and_then(|a| a.auth.clone()), None);
2419        set_auth(&path, "gmail", Some("google")).unwrap();
2420        assert_eq!(Config::load(&path).unwrap().account("gmail").and_then(|a| a.auth.clone()).as_deref(), Some("google"));
2421        std::fs::remove_dir_all(&dir).unwrap();
2422    }
2423
2424    #[test]
2425    fn writes_accounts_and_keeps_comments() {
2426        let dir = std::env::temp_dir().join(format!("sioul-config-{}", std::process::id()));
2427        let path = dir.join("config.toml");
2428        std::fs::create_dir_all(&dir).unwrap();
2429        std::fs::write(&path, "# My settings.\ncase_store = \"~/Notes\"\n\n# Uncomment to choose:\n# language = \"en\"\n").unwrap();
2430        let account = Account::imap("example", "someone@example.org", "imap.example.org", 993, Security::Tls, Some("someone@example.org"));
2431        assert_eq!(account.username, None);
2432        add_imap_account(&path, &account).unwrap();
2433        add_portal(&path, "portal", "https://mail.example.net").unwrap();
2434        set_trusted_ids(&path, "example", &["mx.example.org".into()]).unwrap();
2435        set_priority(&path, "example", Priority::Below).unwrap();
2436        let text = std::fs::read_to_string(&path).unwrap();
2437        assert!(text.starts_with("# My settings.\n"));
2438        assert!(text.find("# language").unwrap() < text.find("[[account]]").unwrap(), "{text}");
2439        // A site is no account: it has its own list.
2440        assert!(text.contains("[[site]]") && !text.contains("kind = \"portal\""), "{text}");
2441        let config = Config::load(&path).unwrap();
2442        assert_eq!((config.accounts.len(), config.sites.len()), (1, 1));
2443        assert_eq!(config.accounts[0].trusted_authserv_ids, vec!["mx.example.org"]);
2444        assert_eq!(config.accounts[0].priority, Priority::Below);
2445        assert_eq!(config.free_id("someone@example.org"), "example-someone");
2446        // Switched off: kept, set aside.
2447        set_enabled(&path, "example", false).unwrap();
2448        let config = Config::load(&path).unwrap();
2449        assert_eq!((config.accounts.len(), config.accounts_off.len()), (0, 1));
2450        assert_eq!(config.free_id("someone@example.org"), "example-someone", "an id stays taken while off");
2451        set_enabled(&path, "example", true).unwrap();
2452        remove_account(&path, "example").unwrap();
2453        let config = Config::load(&path).unwrap();
2454        assert_eq!(config.accounts.len(), 0);
2455        assert_eq!(config.free_id("someone@example.org"), "example");
2456        std::fs::remove_dir_all(&dir).unwrap();
2457    }
2458
2459    #[test]
2460    fn sites_moved_out_of_the_accounts() {
2461        let dir = std::env::temp_dir().join(format!("sioul-sites-{}", std::process::id()));
2462        let path = dir.join("config.toml");
2463        std::fs::create_dir_all(&dir).unwrap();
2464        std::fs::write(&path, "[[account]]\nid = \"mail\"\nkind = \"imap\"\nhost = \"imap.example.org\"\n\n# My bank.\n[[account]]\nid = \"bank\"\nkind = \"portal\"\nurl = \"https://bank.example.org\"\nrealtime = true\n").unwrap();
2465        // Read before moving: among the sites already.
2466        let config = Config::load(&path).unwrap();
2467        assert_eq!((config.accounts.len(), config.sites.len()), (1, 1));
2468        assert!(migrate_sites(&path).unwrap());
2469        let text = std::fs::read_to_string(&path).unwrap();
2470        assert!(text.contains("# My bank.\n[[site]]\nid = \"bank\"") && !text.contains("portal"), "{text}");
2471        assert!(!migrate_sites(&path).unwrap(), "once");
2472        set_value(&path, "site.bank.muted", &SettingValue::Bool(true)).unwrap();
2473        move_site(&path, "bank", -1, false).unwrap();
2474        let config = Config::load(&path).unwrap();
2475        assert!(config.site("bank").is_some_and(|s| s.muted && s.realtime));
2476        remove_site(&path, "bank").unwrap();
2477        assert!(Config::load(&path).unwrap().sites.is_empty());
2478        std::fs::remove_dir_all(&dir).unwrap();
2479    }
2480
2481    /// At each start, the file is copied aside before its sites move, once:
2482    /// a migration that fails again and again keeps the first copy.
2483    #[test]
2484    fn the_copy_before_the_sites_move_is_made_once() {
2485        let dir = std::env::temp_dir().join(format!("sioul-sites-start-{}", std::process::id()));
2486        let _ = std::fs::remove_dir_all(&dir);
2487        std::fs::create_dir_all(&dir).unwrap();
2488        let path = dir.join("config.toml");
2489        let copy = before_sites_path(&path);
2490        // Nothing to move: no copy.
2491        std::fs::write(&path, "[[account]]\nid = \"mail\"\nkind = \"imap\"\n").unwrap();
2492        assert!(!migrate_sites_at_start(&path).unwrap());
2493        assert!(!copy.exists());
2494        // A "site" that is no list of tables: the move fails, the file as it was copied aside.
2495        let first = "site = 1\n\n[[account]]\nid = \"bank\"\nkind = \"portal\"\nurl = \"https://bank.example.org\"\n";
2496        std::fs::write(&path, first).unwrap();
2497        assert!(migrate_sites_at_start(&path).is_err());
2498        assert_eq!(std::fs::read_to_string(&copy).unwrap(), first);
2499        // Started again, changed meanwhile, failing again: the first copy stays.
2500        std::fs::write(&path, format!("{first}\n[[account]]\nid = \"post\"\nkind = \"portal\"\nurl = \"https://post.example.org\"\n")).unwrap();
2501        assert!(migrate_sites_at_start(&path).is_err());
2502        assert_eq!(std::fs::read_to_string(&copy).unwrap(), first, "never written over");
2503        // Mended: the sites move, the copy as it was.
2504        std::fs::write(&path, first.replace("site = 1\n\n", "")).unwrap();
2505        assert!(migrate_sites_at_start(&path).unwrap());
2506        assert!(!std::fs::read_to_string(&path).unwrap().contains("portal"));
2507        assert_eq!(std::fs::read_to_string(&copy).unwrap(), first);
2508        std::fs::remove_dir_all(&dir).unwrap();
2509    }
2510
2511    #[cfg(unix)]
2512    #[test]
2513    fn sioul_folders_are_yours_alone() {
2514        use std::os::unix::fs::PermissionsExt;
2515        let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
2516        let root = std::env::temp_dir().join(format!("sioul-private-{}", std::process::id()));
2517        let _ = std::fs::remove_dir_all(&root);
2518        // Made: 0700.
2519        let new = root.join("share").join("sioul");
2520        private_dir(&new).unwrap();
2521        assert_eq!(mode(&new), 0o700);
2522        // There already, open to others: narrowed; its parent is left as it was.
2523        let old = root.join("state").join("sioul");
2524        std::fs::create_dir_all(&old).unwrap();
2525        std::fs::set_permissions(&old, std::fs::Permissions::from_mode(0o755)).unwrap();
2526        std::fs::set_permissions(root.join("state"), std::fs::Permissions::from_mode(0o755)).unwrap();
2527        private_dir(&old).unwrap();
2528        assert_eq!((mode(&old), mode(&root.join("state"))), (0o700, 0o755));
2529        // A link to a folder elsewhere is left as its owner made it.
2530        let elsewhere = root.join("synced");
2531        std::fs::create_dir_all(&elsewhere).unwrap();
2532        std::fs::set_permissions(&elsewhere, std::fs::Permissions::from_mode(0o755)).unwrap();
2533        let link = root.join("cache");
2534        std::fs::create_dir_all(&link).unwrap();
2535        std::os::unix::fs::symlink(&elsewhere, link.join("sioul")).unwrap();
2536        private_dir(&link.join("sioul")).unwrap();
2537        assert_eq!(mode(&elsewhere), 0o755);
2538        std::fs::remove_dir_all(&root).unwrap();
2539    }
2540
2541    #[test]
2542    fn filters_are_written_whole_a_newer_one_as_it_was() {
2543        use crate::rules::{Act, Condition, Field, Filter, Test};
2544        let path = std::env::temp_dir().join(format!("sioul-filters-{}.toml", std::process::id()));
2545        std::fs::write(
2546            &path,
2547            "# Mine\nfetch_minutes = 15\n\n[mail]\nthreads = true # by conversation\n\n\
2548             [[mail.filter]]\nname = \"Newer\"\nif = [{ field = \"frobnicate\", test = \"contains\", value = \"x\" }]\nthen = [{ do = \"read\" }]\n\n\
2549             [[mail.filter]]\nname = \"Bank\"\nif = [{ field = \"from\", test = \"contains\", value = \"@bank.example\" }]\nthen = [{ do = \"read\" }]\n",
2550        )
2551        .unwrap();
2552        let read = |path: &Path| Config::load(path).unwrap().mail.filters;
2553        let mut filters = read(&path);
2554        assert_eq!(filters.len(), 2);
2555        assert!(filters[0].unknown() && !filters[1].unknown());
2556        // The bank's filter changed and put first, one added, the newer one untouched.
2557        filters[1].actions.push(Act::Archive);
2558        filters.swap(0, 1);
2559        filters.push(Filter { name: "Lists".into(), conditions: vec![Condition::new(Field::List, Test::Exists, "")], actions: vec![Act::Read], ..Filter::default() });
2560        set_filters(&path, &filters).unwrap();
2561        let text = std::fs::read_to_string(&path).unwrap();
2562        assert!(text.contains("# Mine") && text.contains("# by conversation"), "comments kept: {text}");
2563        assert!(text.contains("frobnicate") && !text.contains("unknown"), "a newer Sioul's words kept: {text}");
2564        let again = read(&path);
2565        assert_eq!(again.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Bank", "Newer", "Lists"]);
2566        assert_eq!(again[0].actions, vec![Act::Read, Act::Archive]);
2567        // Changed here, it is written as this Sioul reads it.
2568        let mut renamed = again.clone();
2569        renamed[1].name = "Renamed".into();
2570        set_filters(&path, &renamed).unwrap();
2571        assert!(std::fs::read_to_string(&path).unwrap().contains("field = \"unknown\""));
2572        // None: the list taken out, the rest kept.
2573        set_filters(&path, &[]).unwrap();
2574        let text = std::fs::read_to_string(&path).unwrap();
2575        assert!(read(&path).is_empty() && !text.contains("[[mail.filter]]") && text.contains("threads = true"), "{text}");
2576        let _ = std::fs::remove_file(&path);
2577    }
2578}