Skip to main content

mach/
settings.rs

1//! User settings persisted by [`crate::store::Store`].
2
3use semver::Version;
4use serde::{Deserialize, Serialize};
5
6pub const THEMES: [&str; 7] = ["purple", "cyan", "blue", "red", "yellow", "green", "white"];
7pub const DATE_FORMATS: [&str; 3] = ["Y-M-D", "D-M-Y", "M-D-Y"];
8/// Where the task preview / docked editor sits relative to the list.
9pub const PREVIEW_POSITIONS: [&str; 2] = ["bottom", "right"];
10
11/// How tasks are ordered **inside** each category. All Tasks always stacks
12/// categories in sidebar order; this only rearranges rows within a group.
13pub const SORTS: [&str; 4] = ["manual", "important", "done", "due"];
14
15const AUTOMATIC_UPDATE_CHECK_INTERVAL_SECONDS: i64 = 24 * 60 * 60;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub(crate) enum LaunchState {
19    FirstRun,
20    Upgraded,
21    Returning,
22}
23
24/// What the settings panel calls each sort.
25pub fn sort_label(sort: &str) -> &'static str {
26    match sort {
27        "important" => "Most important first",
28        "done" => "Done last",
29        "due" => "By due date",
30        // "manual", legacy "category", and anything unknown: persisted order.
31        _ => "As added",
32    }
33}
34
35/// Display name for a theme id (`"purple"` → `"Purple"`).
36pub fn theme_label(color: &str) -> String {
37    let mut chars = color.chars();
38    match chars.next() {
39        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
40        None => String::new(),
41    }
42}
43
44/// Settings label for preview placement.
45pub fn preview_position_label(pos: &str) -> &'static str {
46    match pos {
47        "right" => "Right (bottom if narrow)",
48        _ => "Bottom (hidden if narrow)",
49    }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Settings {
54    #[serde(default = "default_date_format")]
55    pub date_format: String,
56    #[serde(default = "default_color")]
57    pub selected_color: String,
58    #[serde(default = "default_sort")]
59    pub sort: String,
60    /// `"bottom"` under the task list, or `"right"` beside it (wide terminals).
61    #[serde(default = "default_preview_position")]
62    pub preview_position: String,
63    /// When true, completed tasks stay on disk but leave the list until
64    /// `/done` shows them again.
65    #[serde(default)]
66    pub hide_done: bool,
67    #[serde(default)]
68    pub last_run_version: Option<String>,
69    /// Unix timestamp of the last automatic update-check attempt. Manual
70    /// checks are never rate-limited and do not change this value.
71    #[serde(default)]
72    pub last_update_check_at: Option<i64>,
73}
74
75impl Default for Settings {
76    fn default() -> Self {
77        Self {
78            date_format: default_date_format(),
79            selected_color: default_color(),
80            sort: default_sort(),
81            preview_position: default_preview_position(),
82            hide_done: false,
83            last_run_version: None,
84            last_update_check_at: None,
85        }
86    }
87}
88
89impl Settings {
90    /// Normalize values imported from the legacy JSON settings file.
91    ///
92    /// SQLite-backed settings are validated on every write and therefore do
93    /// not need this compatibility path.
94    pub fn normalized(mut self) -> Self {
95        if !THEMES.contains(&self.selected_color.as_str()) {
96            self.selected_color = default_color();
97        }
98        if !DATE_FORMATS.contains(&self.date_format.as_str()) {
99            self.date_format = default_date_format();
100        }
101        // Legacy "category" meant All-Tasks grouping; that is now always on,
102        // so map it to as-added within each group.
103        if self.sort == "category" || !SORTS.contains(&self.sort.as_str()) {
104            self.sort = default_sort();
105        }
106        if !PREVIEW_POSITIONS.contains(&self.preview_position.as_str()) {
107            self.preview_position = default_preview_position();
108        }
109        self
110    }
111
112    /// Record `version` and classify this launch. A What's New screen is only
113    /// appropriate for a real semantic-version upgrade, never a downgrade or
114    /// an unparseable development build.
115    pub(crate) fn record_launch(&mut self, version: &str) -> LaunchState {
116        let state = match self.last_run_version.as_deref() {
117            None => LaunchState::FirstRun,
118            Some(previous) if previous == version => LaunchState::Returning,
119            Some(previous) => match (Version::parse(previous), Version::parse(version)) {
120                (Ok(previous), Ok(current)) if current > previous => LaunchState::Upgraded,
121                _ => LaunchState::Returning,
122            },
123        };
124        self.last_run_version = Some(version.to_string());
125        state
126    }
127
128    pub(crate) fn automatic_update_check_due(&self, now: i64) -> bool {
129        match self.last_update_check_at {
130            None => true,
131            // A wall-clock rollback must not suppress checks indefinitely.
132            Some(last) if now < last => true,
133            Some(last) => now.saturating_sub(last) >= AUTOMATIC_UPDATE_CHECK_INTERVAL_SECONDS,
134        }
135    }
136
137    /// Claim one automatic check at `now`, returning false while the previous
138    /// attempt is still inside the daily interval.
139    pub(crate) fn take_automatic_update_check(&mut self, now: i64) -> bool {
140        if !self.automatic_update_check_due(now) {
141            return false;
142        }
143        self.last_update_check_at = Some(now);
144        true
145    }
146}
147
148fn default_date_format() -> String {
149    "Y-M-D".to_string()
150}
151
152fn default_color() -> String {
153    "white".to_string()
154}
155
156fn default_sort() -> String {
157    "manual".to_string()
158}
159
160fn default_preview_position() -> String {
161    "bottom".to_string()
162}
163
164/// Step a string setting by `delta` (±1) in a list, wrapping around.
165pub fn cycle_by(values: &[&str], current: &str, delta: isize) -> String {
166    if values.is_empty() {
167        return current.to_string();
168    }
169    let idx = values.iter().position(|v| *v == current).unwrap_or(0) as isize;
170    let n = values.len() as isize;
171    let next = (idx + delta).rem_euclid(n) as usize;
172    values[next].to_string()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn launch_state_distinguishes_first_run_upgrade_and_repeat() {
181        let mut settings = Settings::default();
182
183        assert_eq!(settings.record_launch("0.2.0"), LaunchState::FirstRun);
184        assert_eq!(settings.last_run_version.as_deref(), Some("0.2.0"));
185        assert_eq!(settings.record_launch("0.2.0"), LaunchState::Returning);
186        assert_eq!(settings.record_launch("0.3.0"), LaunchState::Upgraded);
187        assert_eq!(settings.last_run_version.as_deref(), Some("0.3.0"));
188    }
189
190    #[test]
191    fn launch_state_does_not_treat_downgrades_or_invalid_versions_as_upgrades() {
192        let mut settings = Settings {
193            last_run_version: Some("0.3.0".into()),
194            ..Settings::default()
195        };
196
197        assert_eq!(settings.record_launch("0.2.0"), LaunchState::Returning);
198        settings.last_run_version = Some("old-development-build".into());
199        assert_eq!(settings.record_launch("0.4.0"), LaunchState::Returning);
200        assert_eq!(settings.last_run_version.as_deref(), Some("0.4.0"));
201    }
202
203    #[test]
204    fn automatic_update_checks_are_claimed_once_per_day() {
205        let mut settings = Settings::default();
206        let now = 1_800_000_000;
207
208        assert!(settings.take_automatic_update_check(now));
209        assert_eq!(settings.last_update_check_at, Some(now));
210        assert!(!settings.take_automatic_update_check(now + 86_399));
211        assert_eq!(settings.last_update_check_at, Some(now));
212        assert!(settings.take_automatic_update_check(now + 86_400));
213        assert_eq!(settings.last_update_check_at, Some(now + 86_400));
214    }
215
216    #[test]
217    fn automatic_update_check_recovers_from_clock_rollback() {
218        let mut settings = Settings {
219            last_update_check_at: Some(1_900_000_000),
220            ..Settings::default()
221        };
222
223        assert!(settings.take_automatic_update_check(1_800_000_000));
224        assert_eq!(settings.last_update_check_at, Some(1_800_000_000));
225    }
226}