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"];
10
11pub const SORTS: [&str; 4] = ["manual", "important", "done", "due"];
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(crate) enum LaunchState {
17 FirstRun,
18 Upgraded,
19 Returning,
20}
21
22pub fn sort_label(sort: &str) -> &'static str {
24 match sort {
25 "important" => "Most important first",
26 "done" => "Done last",
27 "due" => "By due date",
28 _ => "As added",
30 }
31}
32
33pub fn theme_label(color: &str) -> String {
35 let mut chars = color.chars();
36 match chars.next() {
37 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
38 None => String::new(),
39 }
40}
41
42pub fn preview_position_label(pos: &str) -> &'static str {
44 match pos {
45 "right" => "Right (bottom if narrow)",
46 _ => "Bottom (hidden if narrow)",
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Settings {
52 #[serde(default = "default_date_format")]
53 pub date_format: String,
54 #[serde(default = "default_color")]
55 pub selected_color: String,
56 #[serde(default = "default_sort")]
57 pub sort: String,
58 #[serde(default = "default_preview_position")]
60 pub preview_position: String,
61 #[serde(default)]
64 pub hide_done: bool,
65 #[serde(default)]
66 pub last_run_version: Option<String>,
67 #[doc(hidden)]
70 #[serde(default, skip_serializing)]
71 pub last_update_check_at: Option<i64>,
72}
73
74impl Default for Settings {
75 fn default() -> Self {
76 Self {
77 date_format: default_date_format(),
78 selected_color: default_color(),
79 sort: default_sort(),
80 preview_position: default_preview_position(),
81 hide_done: false,
82 last_run_version: None,
83 last_update_check_at: None,
84 }
85 }
86}
87
88impl Settings {
89 pub fn normalized(mut self) -> Self {
94 if !THEMES.contains(&self.selected_color.as_str()) {
95 self.selected_color = default_color();
96 }
97 if !DATE_FORMATS.contains(&self.date_format.as_str()) {
98 self.date_format = default_date_format();
99 }
100 if self.sort == "category" || !SORTS.contains(&self.sort.as_str()) {
103 self.sort = default_sort();
104 }
105 if !PREVIEW_POSITIONS.contains(&self.preview_position.as_str()) {
106 self.preview_position = default_preview_position();
107 }
108 self.last_update_check_at = None;
109 self
110 }
111
112 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
129fn default_date_format() -> String {
130 "Y-M-D".to_string()
131}
132
133fn default_color() -> String {
134 "white".to_string()
135}
136
137fn default_sort() -> String {
138 "manual".to_string()
139}
140
141fn default_preview_position() -> String {
142 "bottom".to_string()
143}
144
145pub fn cycle_by(values: &[&str], current: &str, delta: isize) -> String {
147 if values.is_empty() {
148 return current.to_string();
149 }
150 let idx = values.iter().position(|v| *v == current).unwrap_or(0) as isize;
151 let n = values.len() as isize;
152 let next = (idx + delta).rem_euclid(n) as usize;
153 values[next].to_string()
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn launch_state_distinguishes_first_run_upgrade_and_repeat() {
162 let mut settings = Settings::default();
163
164 assert_eq!(settings.record_launch("0.2.0"), LaunchState::FirstRun);
165 assert_eq!(settings.last_run_version.as_deref(), Some("0.2.0"));
166 assert_eq!(settings.record_launch("0.2.0"), LaunchState::Returning);
167 assert_eq!(settings.record_launch("0.3.0"), LaunchState::Upgraded);
168 assert_eq!(settings.last_run_version.as_deref(), Some("0.3.0"));
169 }
170
171 #[test]
172 fn launch_state_does_not_treat_downgrades_or_invalid_versions_as_upgrades() {
173 let mut settings = Settings {
174 last_run_version: Some("0.3.0".into()),
175 ..Settings::default()
176 };
177
178 assert_eq!(settings.record_launch("0.2.0"), LaunchState::Returning);
179 settings.last_run_version = Some("old-development-build".into());
180 assert_eq!(settings.record_launch("0.4.0"), LaunchState::Returning);
181 assert_eq!(settings.last_run_version.as_deref(), Some("0.4.0"));
182 }
183
184 #[test]
185 fn legacy_task_store_update_timestamp_is_read_but_not_written() {
186 let settings: Settings = serde_json::from_str(
187 r#"{
188 "date_format":"Y-M-D",
189 "selected_color":"white",
190 "sort":"manual",
191 "preview_position":"bottom",
192 "last_update_check_at":1800000000
193 }"#,
194 )
195 .unwrap();
196
197 assert_eq!(settings.last_update_check_at, Some(1_800_000_000));
198 let encoded = serde_json::to_value(settings).unwrap();
199 assert!(encoded.get("last_update_check_at").is_none());
200 }
201}