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