1use 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 pub language: Option<String>,
22 #[serde(default)]
24 pub theme: Option<String>,
25 #[serde(default)]
28 pub places_named: bool,
29 #[serde(default)]
32 pub screen_colours: Option<bool>,
33 #[serde(default)]
35 pub calmer_colours: Option<String>,
36 #[serde(rename = "case_store")]
40 pub notes_root: Option<String>,
41 #[serde(default)]
46 pub share_projects: bool,
47 pub known_senders: Option<String>,
49 pub blocked_senders: Option<String>,
51 #[serde(default)]
54 pub history_weeks: Option<u32>,
55 #[serde(default)]
57 pub fetch_minutes: Option<u32>,
58 #[serde(default)]
60 pub notes_folder: Option<String>,
61 #[serde(default)]
65 pub filed_words: Option<Vec<String>>,
66 #[serde(default)]
68 pub reading: Reading,
69 #[serde(default)]
70 pub mail: MailSettings,
71 #[serde(default)]
73 pub spam: SpamSettings,
74 #[serde(default)]
75 pub tasks: TaskSettings,
76 #[serde(default)]
78 pub planning: PlanningSettings,
79 #[serde(default)]
80 pub agenda: AgendaSettings,
81 #[serde(default)]
82 pub map: MapSettings,
83 #[serde(default)]
85 pub contacts: ContactSettings,
86 #[serde(default)]
90 pub words: toml::Table,
91 #[serde(default)]
93 pub reminders: ReminderSettings,
94 #[serde(default)]
97 pub notify: crate::attention::NotifySettings,
98 #[serde(default)]
102 pub attention: Option<crate::attention::Settings>,
103 #[serde(default)]
105 pub free_time: crate::pause::FreeTimeSettings,
106 #[serde(default)]
108 pub pause: crate::pause::PauseSettings,
109 #[serde(default)]
111 pub dnd: crate::everywhere::DndSettings,
112 #[serde(default)]
114 pub letters: LetterSettings,
115 #[serde(rename = "routine", default)]
117 pub routines: Vec<crate::routines::Routine>,
118 #[serde(default)]
120 pub weather: WeatherSettings,
121 #[serde(default)]
123 pub bitwarden: BitwardenSettings,
124 #[serde(default)]
126 pub github: GithubSettings,
127 #[serde(default)]
129 pub invoice: InvoiceSettings,
130 #[serde(default)]
132 pub mcp: McpSettings,
133 #[serde(rename = "account", default)]
135 pub accounts: Vec<Account>,
136 #[serde(rename = "site", default)]
140 pub sites: Vec<Account>,
141 #[serde(default)]
146 pub site_order: Vec<String>,
147 #[serde(skip)]
149 pub accounts_off: Vec<Account>,
150 #[serde(rename = "window", default)]
151 pub windows: Vec<AdminWindow>,
152 #[serde(rename = "office_hours", default)]
154 pub office_hours: Vec<AdminWindow>,
155 #[serde(rename = "time_off", default)]
157 pub time_off: Vec<TimeOff>,
158 #[serde(default)]
159 pub quiet: QuietSettings,
160 #[serde(default)]
162 pub porch: PorchSettings,
163 #[serde(default)]
166 pub reach: ReachSettings,
167 #[serde(default)]
169 pub calls: CallSettings,
170}
171
172#[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#[derive(Debug, Clone, PartialEq, Deserialize)]
185pub struct PorchSettings {
186 #[serde(default)]
188 pub hidden_projects: Vec<String>,
189 #[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#[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 #[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#[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 #[serde(default)]
239 pub hidden: Option<Vec<String>>,
240}
241
242#[derive(Debug, Clone, PartialEq, Deserialize)]
244pub struct TimeOff {
245 #[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#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
260pub struct QuietSettings {
261 #[serde(default)]
264 pub personal: Option<Vec<String>>,
265 #[serde(default)]
268 pub work: Option<Vec<String>>,
269}
270
271impl Config {
272 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 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 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#[derive(Debug, Clone, PartialEq, Deserialize)]
294pub struct Reading {
295 #[serde(default)]
297 pub family: String,
298 #[serde(default = "Reading::default_size")]
299 pub size: u32,
300 #[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#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
324pub struct SpamSettings {
325 #[serde(default)]
328 pub mode: Option<String>,
329 #[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 #[serde(default)]
340 pub threshold_spam: Option<f32>,
341 #[serde(default)]
343 pub threshold_unsure: Option<f32>,
344 #[serde(default)]
348 pub train_by_itself: Option<bool>,
349}
350
351impl SpamSettings {
352 pub fn trains_by_itself(&self) -> bool {
354 self.train_by_itself.unwrap_or(true)
355 }
356
357 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 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 #[serde(default)]
384 pub threads: bool,
385 #[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 #[serde(default)]
396 pub list: Option<String>,
397 #[serde(default)]
399 pub estimate: Option<u32>,
400 #[serde(rename = "kind", default)]
402 pub kinds: Option<Vec<TaskKind>>,
403 #[serde(default)]
406 pub blocks: Option<String>,
407 #[serde(default)]
410 pub block_alarms: bool,
411}
412
413#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
415pub struct PlanningSettings {
416 #[serde(default)]
418 pub window_days: Option<u32>,
419 #[serde(default)]
421 pub even_days: bool,
422 #[serde(default)]
424 pub start: Option<String>,
425 #[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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
446pub struct TaskKind {
447 pub id: String,
449 #[serde(default)]
451 pub label: String,
452}
453
454fn 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 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
476pub 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 #[serde(default)]
525 pub day_start: Option<u32>,
526}
527
528#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
529pub struct MapSettings {
530 #[serde(default)]
532 pub tiles: Option<String>,
533 #[serde(default)]
535 pub geocode: bool,
536}
537
538#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
540pub struct ContactSettings {
541 #[serde(default)]
545 pub region: Option<String>,
546}
547
548#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
550pub struct LetterSettings {
551 #[serde(default)]
553 pub inbox: Option<String>,
554}
555
556#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
558pub struct ReminderSettings {
559 #[serde(default = "yes")]
561 pub events: bool,
562 #[serde(default = "two")]
564 pub asked_days: u32,
565 #[serde(default = "two")]
567 pub payment_days: u32,
568 #[serde(default = "yes")]
570 pub waits: bool,
571 #[serde(default = "yes")]
574 pub gather: bool,
575 #[serde(default)]
577 pub gathered: Option<Vec<String>>,
578 #[serde(default = "yes")]
583 pub doses_in_sleep: bool,
584 #[serde(default = "fifteen")]
588 pub before_event: u32,
589 #[serde(default = "yes")]
591 pub mail: bool,
592 #[serde(default)]
594 pub mail_newsletters: bool,
595}
596
597impl ReminderSettings {
598 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
621pub struct GithubSettings {
622 #[serde(default)]
623 pub enabled: bool,
624 #[serde(default = "yes")]
626 pub assigned: bool,
627 #[serde(default = "yes")]
629 pub reviews: bool,
630 #[serde(default)]
632 pub created: bool,
633 #[serde(default)]
635 pub mentioned: bool,
636}
637
638fn yes() -> bool {
639 true
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
645pub struct McpSettings {
646 #[serde(default = "yes")]
650 pub spam: bool,
651 #[serde(default = "yes")]
656 pub outside_projects: bool,
657 #[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 #[serde(default)]
680 pub email: Option<String>,
681 #[serde(default)]
683 pub server: Option<String>,
684}
685
686#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
687pub struct WeatherSettings {
688 #[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 #[serde(default)]
703 pub address: String,
704 #[serde(default)]
706 pub siret: String,
707 #[serde(default)]
709 pub vat: String,
710 #[serde(default)]
712 pub prefix: String,
713 #[serde(default)]
715 pub rate: f64,
716 #[serde(default)]
717 pub currency: String,
718 #[serde(default)]
720 pub folder: String,
721 #[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 #[default]
735 Portal,
736 Dav,
738}
739
740#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
742#[serde(rename_all = "lowercase")]
743pub enum Security {
744 #[default]
746 Tls,
747 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
762#[serde(rename_all = "lowercase")]
763pub enum Priority {
764 Above,
766 #[default]
767 Average,
768 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
792pub 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 #[serde(default = "yes")]
802 pub enabled: bool,
803 #[serde(default)]
804 pub address: Option<String>,
805 #[serde(default)]
807 pub maildir: Option<String>,
808 #[serde(default)]
810 pub url: Option<String>,
811 #[serde(default)]
813 pub site: Option<String>,
814 #[serde(default)]
816 pub realtime: bool,
817 #[serde(default)]
819 pub muted: bool,
820 #[serde(default)]
822 pub background: Option<bool>,
823 #[serde(default)]
826 pub microphone: Option<bool>,
827 #[serde(default)]
828 pub camera: Option<bool>,
829 #[serde(default)]
830 pub screen: Option<bool>,
831 #[serde(default)]
833 pub categories: Vec<String>,
834 #[serde(default)]
837 pub announced_by: Vec<String>,
838 #[serde(default)]
840 pub area: Option<String>,
841 #[serde(default)]
843 pub trusted_authserv_ids: Vec<String>,
844 #[serde(default)]
846 pub host: Option<String>,
847 #[serde(default)]
848 pub port: Option<u16>,
849 #[serde(default)]
850 pub security: Security,
851 #[serde(default)]
853 pub username: Option<String>,
854 #[serde(default)]
856 pub sync_days: Option<u32>,
857 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub auth: Option<String>,
860 #[serde(default, skip_serializing_if = "Vec::is_empty")]
862 pub skip_folders: Vec<String>,
863 #[serde(default)]
866 pub history_weeks: Option<u32>,
867 #[serde(default)]
869 pub priority: Priority,
870 #[serde(default)]
872 pub name: Option<String>,
873 #[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 #[serde(default)]
882 pub signature: Option<String>,
883 #[serde(default)]
885 pub fetch_minutes: Option<u32>,
886 #[serde(default)]
888 pub shield: bool,
889 #[serde(default)]
891 pub shield_ai: bool,
892}
893
894impl Account {
895 pub fn history_days(&self) -> Option<i64> {
897 history_days(self.history_weeks)
898 }
899
900 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 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 pub fn login(&self) -> Option<&str> {
947 self.username.as_deref().or(self.address.as_deref())
948 }
949
950 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 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 pub fn syncs(&self) -> bool {
968 self.kind == AccountKind::Imap && self.host.is_some()
969 }
970
971 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 pub fn is_dav(&self) -> bool {
983 self.kind == AccountKind::Dav && self.host.is_some()
984 }
985}
986
987#[derive(Debug, Clone)]
989pub struct Source {
990 pub account: Option<String>,
991 pub address: Option<String>,
993 pub folder: PathBuf,
994 pub trusted_ids: Vec<String>,
995 pub priority: Priority,
996 pub shielded: bool,
998 pub words: std::sync::Arc<crate::words::Words>,
1000 pub spam: Option<crate::spam::Filter>,
1002}
1003
1004impl Config {
1005 pub fn account_of(&self, file: &Path) -> Option<&Account> {
1007 let syncing = || self.accounts.iter().filter(|a| a.syncs());
1008 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 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 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 pub fn site(&self, id: &str) -> Option<&Account> {
1050 self.sites.iter().find(|s| s.id == id)
1051 }
1052
1053 pub fn every_account(&self) -> impl Iterator<Item = &Account> {
1055 self.accounts.iter().chain(self.accounts_off.iter())
1056 }
1057
1058 pub fn history_days(&self) -> Option<i64> {
1060 history_days(self.history_weeks)
1061 }
1062
1063 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 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 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 pub fn safe_senders_path(&self) -> PathBuf {
1081 config_dir().join("safe-senders.txt")
1082 }
1083
1084 pub fn restricted_senders_path(&self) -> PathBuf {
1086 config_dir().join("restricted-senders.txt")
1087 }
1088
1089 pub fn neutral_senders_path(&self) -> PathBuf {
1091 config_dir().join("neutral-senders.txt")
1092 }
1093
1094 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 pub fn account(&self, id: &str) -> Option<&Account> {
1101 self.accounts.iter().find(|a| a.id == id)
1102 }
1103
1104 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 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
1139pub 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
1145pub 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 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
1171pub 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
1193pub fn add_portal(path: &Path, id: &str, url: &str) -> Result<(), String> {
1195 add_site(path, id, "", url)
1196}
1197
1198pub 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
1230pub 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
1241pub fn migrate_sites(path: &Path) -> Result<bool, String> {
1245 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
1268pub fn before_sites_path(path: &Path) -> PathBuf {
1270 path.with_file_name("config-before-sites.toml")
1271}
1272
1273pub 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, ©).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 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
1304pub fn move_site(path: &Path, id: &str, delta: i64, order: bool) -> Result<(), String> {
1312 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 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
1340fn 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
1350pub fn remove_site(path: &Path, id: &str) -> Result<(), String> {
1352 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
1366pub fn set_enabled(path: &Path, id: &str, on: bool) -> Result<(), String> {
1368 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 crate::filelock::with_lock(path, || {
1388 let mut doc = read_document(path)?;
1389 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
1401pub fn move_account(path: &Path, id: &str, delta: i64) -> Result<(), String> {
1404 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 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
1426pub fn remove_account(path: &Path, id: &str) -> Result<(), String> {
1428 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
1441pub 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
1446pub fn set_auth(path: &Path, id: &str, auth: Option<&str>) -> Result<(), String> {
1449 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
1467pub 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
1474pub 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
1482pub const DEFAULT_HISTORY_WEEKS: u32 = 2;
1484
1485pub const DEFAULT_FETCH_MINUTES: u32 = 5;
1487
1488pub fn set_history(path: &Path, weeks: u32) -> Result<(), String> {
1490 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
1498pub fn set_priority(path: &Path, id: &str, priority: Priority) -> Result<(), String> {
1500 set_account_item(path, id, "priority", value(priority.as_str()))
1501}
1502
1503pub fn set_writing(path: &Path, id: &str, name: &str, signature: &str) -> Result<(), String> {
1505 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#[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 Named(Vec<NamedValue>),
1539 Rename(RenameValue),
1541}
1542
1543#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1545pub struct NamedValue {
1546 pub id: String,
1547 pub label: String,
1548 #[serde(default, skip_serializing_if = "String::is_empty")]
1550 pub locked: String,
1551 #[serde(default, skip_serializing_if = "String::is_empty")]
1553 pub detail: String,
1554}
1555
1556#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1558pub struct RenameValue {
1559 pub from: String,
1560 pub to: String,
1561}
1562
1563#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1565pub struct TimeOffValue {
1566 #[serde(default, skip_serializing_if = "String::is_empty")]
1568 pub id: String,
1569 pub from: String,
1571 pub until: String,
1572 #[serde(default)]
1573 pub label: String,
1574}
1575
1576#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1578pub struct WindowValue {
1579 pub day: String,
1580 pub start: String,
1581 #[serde(default)]
1583 pub end: String,
1584 #[serde(default)]
1586 pub minutes: u32,
1587}
1588
1589impl SettingValue {
1590 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
1607pub fn set_value(path: &Path, key: &str, setting: &SettingValue) -> Result<(), String> {
1612 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 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 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 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 SettingValue::Ints(none) if none.is_empty() => &[],
1677 _ => return Err(format!("{key}: windows expected")),
1678 };
1679 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 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 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 let section = doc[table].as_table_like_mut().ok_or_else(|| format!("{}: {table} is not a table", path.display()))?;
1767 match setting.item() {
1768 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 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
1805pub fn set_table(path: &Path, table: &str, keys: &[(String, Option<Vec<String>>)], remove: &[&str]) -> Result<(), String> {
1810 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
1849pub fn set_filters(path: &Path, filters: &[crate::rules::Filter]) -> Result<(), String> {
1854 crate::filelock::with_lock(path, || {
1856 let mut doc = read_document(path)?;
1857 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 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
1895fn 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
1932pub(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 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
1955pub 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
1973pub fn default_path() -> PathBuf {
1975 config_dir().join("config.toml")
1976}
1977
1978pub fn config_dir() -> PathBuf {
1980 xdg("XDG_CONFIG_HOME", "~/.config").join("sioul")
1981}
1982
1983pub fn data_dir() -> PathBuf {
1985 xdg("XDG_DATA_HOME", "~/.local/share").join("sioul")
1986}
1987
1988pub fn state_dir() -> PathBuf {
1990 xdg("XDG_STATE_HOME", "~/.local/state").join("sioul")
1991}
1992
1993fn 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
2012pub fn cache_dir() -> PathBuf {
2014 xdg("XDG_CACHE_HOME", "~/.cache").join("sioul")
2015}
2016
2017pub 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#[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
2049pub 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
2064pub fn expand_home(path: &str) -> PathBuf {
2067 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 (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 #[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 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 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 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 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 #[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 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 #[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 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 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 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 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 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 #[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 std::fs::write(&path, "[[account]]\nid = \"mail\"\nkind = \"imap\"\n").unwrap();
2343 assert!(!migrate_sites_at_start(&path).unwrap());
2344 assert!(!copy.exists());
2345 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(©).unwrap(), first);
2350 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(©).unwrap(), first, "never written over");
2354 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(©).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 let new = root.join("share").join("sioul");
2371 private_dir(&new).unwrap();
2372 assert_eq!(mode(&new), 0o700);
2373 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 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 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 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 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}