1use 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"];
8pub const PREVIEW_POSITIONS: [&str; 2] = ["bottom", "right"];
10pub const HINT_LEVELS: [&str; 2] = ["all", "essential"];
11
12pub 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
23pub 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 _ => "As added",
31 }
32}
33
34pub 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
43pub 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
51pub 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 #[serde(default = "default_preview_position")]
69 pub preview_position: String,
70 #[serde(default = "default_hint_level")]
73 pub hint_level: String,
74 #[serde(default)]
77 pub hide_done: bool,
78 #[serde(default)]
79 pub last_run_version: Option<String>,
80 #[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 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 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 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
174pub 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}