Skip to main content

sioul_core/
state.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What the Porch remembers between windows: where you closed it.
5//!
6//! "Done for now" records, for each account, the newest message the Porch
7//! showed (its UIDVALIDITY and UID). Everything up to it has been seen; anything
8//! newer waits for the next window, even when it reached the server earlier and
9//! was fetched later. The server is never told: its read flags stay yours.
10//! Kept in `$XDG_STATE_HOME/sioul/porch.toml`.
11
12use crate::card::ImapOrigin;
13use crate::config::state_dir;
14use crate::porch::Triaged;
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20pub struct PorchState {
21    /// Per account, the newest message shown when the Porch was last closed.
22    #[serde(default)]
23    pub done: BTreeMap<String, ImapOrigin>,
24    /// The file as it was read: saved, only what changed since is written (`filelock::save_merged`).
25    #[serde(skip)]
26    pub read: crate::filelock::Read,
27}
28
29impl PorchState {
30    /// Where the Porch's memory lives by default.
31    pub fn default_path() -> PathBuf {
32        state_dir().join("porch.toml")
33    }
34
35    /// The saved state; an empty one when there is none yet or it cannot be read.
36    pub fn load(path: &Path) -> PorchState {
37        let text = std::fs::read_to_string(path).unwrap_or_default();
38        let mut state: PorchState = toml::from_str(&text).unwrap_or_default();
39        state.read = crate::filelock::Read::of(&text);
40        state
41    }
42
43    /// Written beside, then moved (a crash halfway would leave a file that
44    /// cannot be read, and the Porch would forget where it was closed), under
45    /// the file's lock, which the sharing takes too: only what changed since it
46    /// was read, over what the file holds now.
47    pub fn save(&self, path: &Path) -> Result<(), String> {
48        // A mark never goes backwards: of one account at one validity, the newer message stays.
49        crate::filelock::save_merged_by(path, &self.read, self, |merged, before| {
50            let Some(before) = before.and_then(|b| b.get("done")).and_then(toml::Value::as_table) else { return };
51            let Some(done) = merged.get_mut("done").and_then(toml::Value::as_table_mut) else { return };
52            for (account, mark) in done.iter_mut() {
53                let field = |m: &toml::Value, name: &str| m.get(name).and_then(toml::Value::as_integer);
54                if let Some(there) = before.get(account.as_str())
55                    && field(there, "validity") == field(mark, "validity")
56                    && field(there, "uid") > field(mark, "uid")
57                {
58                    *mark = there.clone();
59                }
60            }
61        })
62    }
63
64    /// Whether a message was shown before the Porch was last closed.
65    pub fn is_done(&self, account: Option<&str>, origin: Option<ImapOrigin>) -> bool {
66        match (account.and_then(|a| self.done.get(a)), origin) {
67            (Some(mark), Some(o)) => o.validity == mark.validity && o.uid <= mark.uid,
68            _ => false,
69        }
70    }
71
72    /// The newest message of each account among those shown; never one of
73    /// the review queue's, which waits for your word whatever you close (a
74    /// message your filter moved is in another folder, numbered apart).
75    pub fn newest_shown(shown: &[Triaged]) -> BTreeMap<String, ImapOrigin> {
76        let mut newest: BTreeMap<String, ImapOrigin> = BTreeMap::new();
77        for t in shown.iter().filter(|t| t.lane != crate::porch::Lane::Review) {
78            let (Some(account), Some(origin)) = (&t.card.account, t.card.origin) else { continue };
79            let mark = newest.entry(account.clone()).or_insert(origin);
80            *mark = (*mark).max(origin);
81        }
82        newest
83    }
84
85    /// Closes the Porch on what was shown. A new UIDVALIDITY replaces the old mark,
86    /// since the server renumbered its messages.
87    pub fn close(&mut self, newest: &BTreeMap<String, ImapOrigin>) {
88        for (account, origin) in newest {
89            let mark = self.done.entry(account.clone()).or_insert(*origin);
90            if mark.validity != origin.validity || mark.uid < origin.uid {
91                *mark = *origin;
92            }
93        }
94    }
95}
96
97/// The messages you said are not payments, by key (see `budget::MailLine`).
98/// Kept in `$XDG_STATE_HOME/sioul/money.toml`.
99#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100pub struct MoneyState {
101    #[serde(default)]
102    pub ignored: std::collections::BTreeSet<String>,
103    /// The file as it was read: saved, only what changed since is written (`filelock::save_merged`).
104    #[serde(skip)]
105    pub read: crate::filelock::Read,
106}
107
108impl MoneyState {
109    pub fn default_path() -> PathBuf {
110        state_dir().join("money.toml")
111    }
112
113    pub fn load(path: &Path) -> MoneyState {
114        let text = std::fs::read_to_string(path).unwrap_or_default();
115        let mut state: MoneyState = toml::from_str(&text).unwrap_or_default();
116        state.read = crate::filelock::Read::of(&text);
117        state
118    }
119
120    /// Written under the file's lock, which the sharing takes too: only what
121    /// changed since it was read, over what the file holds now.
122    pub fn save(&self, path: &Path) -> Result<(), String> {
123        crate::filelock::save_merged(path, &self.read, self)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn closing_marks_what_was_shown() {
133        let mut state = PorchState::default();
134        let mark = |validity, uid| ImapOrigin { validity, uid };
135        state.close(&BTreeMap::from([("personal".to_string(), mark(5, 40))]));
136        assert!(state.is_done(Some("personal"), Some(mark(5, 40))));
137        assert!(!state.is_done(Some("personal"), Some(mark(5, 41))));
138        assert!(!state.is_done(Some("other"), Some(mark(5, 1))));
139        assert!(!state.is_done(Some("personal"), None));
140        // An older snapshot never moves the mark back.
141        state.close(&BTreeMap::from([("personal".to_string(), mark(5, 30))]));
142        assert!(state.is_done(Some("personal"), Some(mark(5, 40))));
143        // A renumbered folder starts over.
144        state.close(&BTreeMap::from([("personal".to_string(), mark(6, 3))]));
145        assert!(!state.is_done(Some("personal"), Some(mark(5, 1))));
146        let text = toml::to_string(&state).unwrap();
147        assert_eq!(toml::from_str::<PorchState>(&text).unwrap(), state);
148    }
149}