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"];
10pub const HINT_LEVELS: [&str; 2] = ["all", "essential"];
11
12/// How tasks are ordered **inside** each category. All Tasks always stacks
13/// categories in sidebar order; this only rearranges rows within a group.
14pub const SORTS: [&str; 4] = ["manual", "important", "done", "due"];
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub(crate) enum LaunchState {
18    FirstRun,
19    Upgraded,
20    Returning,
21}
22
23/// What the settings panel calls each sort.
24pub fn sort_label(sort: &str) -> &'static str {
25    match sort {
26        "important" => "Most important first",
27        "done" => "Done last",
28        "due" => "By due date",
29        // "manual", legacy "category", and anything unknown: persisted order.
30        _ => "As added",
31    }
32}
33
34/// Display name for a theme id (`"purple"` → `"Purple"`).
35pub fn theme_label(color: &str) -> String {
36    let mut chars = color.chars();
37    match chars.next() {
38        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
39        None => String::new(),
40    }
41}
42
43/// Settings label for preview placement.
44pub fn preview_position_label(pos: &str) -> &'static str {
45    match pos {
46        "right" => "Right (bottom if narrow)",
47        _ => "Bottom (hidden if narrow)",
48    }
49}
50
51/// Settings label for the amount of passive shortcut guidance shown.
52pub fn hint_level_label(level: &str) -> &'static str {
53    match level {
54        "essential" => "Essential",
55        _ => "All",
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct Settings {
61    #[serde(default = "default_date_format")]
62    pub date_format: String,
63    #[serde(default = "default_color")]
64    pub selected_color: String,
65    #[serde(default = "default_sort")]
66    pub sort: String,
67    /// `"bottom"` under the task list, or `"right"` beside it (wide terminals).
68    #[serde(default = "default_preview_position")]
69    pub preview_position: String,
70    /// `"all"` includes passive shortcut teaching; `"essential"` keeps only
71    /// guidance needed for the current action or state.
72    #[serde(default = "default_hint_level")]
73    pub hint_level: String,
74    /// When true, completed tasks stay on disk but leave the list until
75    /// `/done` shows them again.
76    #[serde(default)]
77    pub hide_done: bool,
78    #[serde(default)]
79    pub last_run_version: Option<String>,
80    /// Legacy task-store field retained for source compatibility. Update
81    /// scheduling no longer reads it, and new settings writes omit it.
82    #[doc(hidden)]
83    #[serde(default, skip_serializing)]
84    pub last_update_check_at: Option<i64>,
85}
86
87impl Default for Settings {
88    fn default() -> Self {
89        Self {
90            date_format: default_date_format(),
91            selected_color: default_color(),
92            sort: default_sort(),
93            preview_position: default_preview_position(),
94            hint_level: default_hint_level(),
95            hide_done: false,
96            last_run_version: None,
97            last_update_check_at: None,
98        }
99    }
100}
101
102impl Settings {
103    /// Normalize values imported from the legacy JSON settings file.
104    ///
105    /// SQLite-backed settings are validated on every write and therefore do
106    /// not need this compatibility path.
107    pub fn normalized(mut self) -> Self {
108        if !THEMES.contains(&self.selected_color.as_str()) {
109            self.selected_color = default_color();
110        }
111        if !DATE_FORMATS.contains(&self.date_format.as_str()) {
112            self.date_format = default_date_format();
113        }
114        // Legacy "category" meant All-Tasks grouping; that is now always on,
115        // so map it to as-added within each group.
116        if self.sort == "category" || !SORTS.contains(&self.sort.as_str()) {
117            self.sort = default_sort();
118        }
119        if !PREVIEW_POSITIONS.contains(&self.preview_position.as_str()) {
120            self.preview_position = default_preview_position();
121        }
122        if !HINT_LEVELS.contains(&self.hint_level.as_str()) {
123            self.hint_level = default_hint_level();
124        }
125        self.last_update_check_at = None;
126        self
127    }
128
129    /// Record `version` and classify this launch. A What's New screen is only
130    /// appropriate for a real semantic-version upgrade, never a downgrade or
131    /// an unparseable development build.
132    pub(crate) fn record_launch(&mut self, version: &str) -> LaunchState {
133        let state = match self.last_run_version.as_deref() {
134            None => LaunchState::FirstRun,
135            Some(previous) if previous == version => LaunchState::Returning,
136            Some(previous) => match (Version::parse(previous), Version::parse(version)) {
137                (Ok(previous), Ok(current)) if current > previous => LaunchState::Upgraded,
138                _ => LaunchState::Returning,
139            },
140        };
141        self.last_run_version = Some(version.to_string());
142        state
143    }
144
145    pub(crate) fn show_passive_hints(&self) -> bool {
146        self.hint_level == "all"
147    }
148
149    pub(crate) fn cycle_hint_level(&mut self, delta: isize) {
150        self.hint_level = cycle_by(&HINT_LEVELS, &self.hint_level, delta);
151    }
152}
153
154fn default_date_format() -> String {
155    "Y-M-D".to_string()
156}
157
158fn default_color() -> String {
159    "white".to_string()
160}
161
162fn default_sort() -> String {
163    "manual".to_string()
164}
165
166fn default_preview_position() -> String {
167    "bottom".to_string()
168}
169
170fn default_hint_level() -> String {
171    "all".to_string()
172}
173
174/// Step a string setting by `delta` (±1) in a list, wrapping around.
175pub fn cycle_by(values: &[&str], current: &str, delta: isize) -> String {
176    if values.is_empty() {
177        return current.to_string();
178    }
179    let idx = values.iter().position(|v| *v == current).unwrap_or(0) as isize;
180    let n = values.len() as isize;
181    let next = (idx + delta).rem_euclid(n) as usize;
182    values[next].to_string()
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn launch_state_distinguishes_first_run_upgrade_and_repeat() {
191        let mut settings = Settings::default();
192
193        assert_eq!(settings.record_launch("0.2.0"), LaunchState::FirstRun);
194        assert_eq!(settings.last_run_version.as_deref(), Some("0.2.0"));
195        assert_eq!(settings.record_launch("0.2.0"), LaunchState::Returning);
196        assert_eq!(settings.record_launch("0.3.0"), LaunchState::Upgraded);
197        assert_eq!(settings.last_run_version.as_deref(), Some("0.3.0"));
198    }
199
200    #[test]
201    fn launch_state_does_not_treat_downgrades_or_invalid_versions_as_upgrades() {
202        let mut settings = Settings {
203            last_run_version: Some("0.3.0".into()),
204            ..Settings::default()
205        };
206
207        assert_eq!(settings.record_launch("0.2.0"), LaunchState::Returning);
208        settings.last_run_version = Some("old-development-build".into());
209        assert_eq!(settings.record_launch("0.4.0"), LaunchState::Returning);
210        assert_eq!(settings.last_run_version.as_deref(), Some("0.4.0"));
211    }
212
213    #[test]
214    fn legacy_task_store_update_timestamp_is_read_but_not_written() {
215        let settings: Settings = serde_json::from_str(
216            r#"{
217                "date_format":"Y-M-D",
218                "selected_color":"white",
219                "sort":"manual",
220                "preview_position":"bottom",
221                "last_update_check_at":1800000000
222            }"#,
223        )
224        .unwrap();
225
226        assert_eq!(settings.last_update_check_at, Some(1_800_000_000));
227        let encoded = serde_json::to_value(settings).unwrap();
228        assert!(encoded.get("last_update_check_at").is_none());
229    }
230
231    #[test]
232    fn hint_level_defaults_to_all_and_persists_essential() {
233        let existing: Settings = serde_json::from_str(
234            r#"{
235                "date_format":"Y-M-D",
236                "selected_color":"white",
237                "sort":"manual",
238                "preview_position":"bottom"
239            }"#,
240        )
241        .unwrap();
242        assert_eq!(existing.hint_level, "all");
243
244        let essential = Settings {
245            hint_level: "essential".into(),
246            ..Settings::default()
247        };
248        let decoded: Settings = serde_json::from_value(serde_json::to_value(essential).unwrap())
249            .expect("essential hint level should round-trip");
250        assert_eq!(decoded.hint_level, "essential");
251    }
252}