1use chrono::{Datelike, FixedOffset, TimeZone, Utc};
7
8use crate::fs::VirtualFs;
9use crate::types::{DIR_USER_ROOT, KnowledgeConfig, Schedule};
10
11#[derive(Debug, thiserror::Error)]
13pub enum ScheduleError {
14 #[error("config read: {0}")]
16 Read(String),
17 #[error("config write: {0}")]
19 Write(String),
20}
21
22pub struct ScheduleManager<'a> {
24 fs: &'a VirtualFs,
25 config_filename: &'a str,
26}
27
28impl<'a> ScheduleManager<'a> {
29 pub fn new(fs: &'a VirtualFs, config_filename: &'a str) -> Self {
31 Self {
32 fs,
33 config_filename,
34 }
35 }
36
37 pub fn schedules(&self) -> Result<Vec<Schedule>, ScheduleError> {
39 let cfg = self.read_config()?;
40 Ok(cfg.schedules)
41 }
42
43 pub fn add(&self, filename: &str, scheduled_at: i64, cron: &str) -> Result<(), ScheduleError> {
45 let mut cfg = self.read_config()?;
46 if let Some(s) = cfg.schedules.iter_mut().find(|s| s.filename == filename) {
47 s.scheduled_at = scheduled_at;
48 s.cron = cron.to_string();
49 } else {
50 cfg.schedules.push(Schedule {
51 filename: filename.to_string(),
52 scheduled_at,
53 cron: cron.to_string(),
54 cmd: String::new(),
55 });
56 }
57 self.write_config(&cfg)
58 }
59
60 pub fn delete(&self, filename: &str) -> Result<(), ScheduleError> {
62 let mut cfg = self.read_config()?;
63 cfg.schedules.retain(|s| s.filename != filename);
64 self.write_config(&cfg)
65 }
66
67 pub fn create_default_if_not_exists(&self) -> Result<(), ScheduleError> {
73 if self
74 .fs
75 .exists(DIR_USER_ROOT, self.config_filename)
76 .map_err(|e| ScheduleError::Read(e.to_string()))?
77 {
78 return Ok(());
79 }
80 self.write_config(&KnowledgeConfig::default())
81 }
82
83 pub fn should_split_checklist(&self, _checklist: &str) -> bool {
88 true
91 }
92
93 pub fn add_move_to_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
99 let mut cfg = self.read_config()?;
100 if cfg.move_to_commands.iter().any(|c| c == cmd) {
101 return Ok(());
102 }
103 cfg.move_to_commands.push(cmd.to_string());
104 self.write_config(&cfg)
105 }
106
107 pub fn move_to_cmds(&self) -> Result<Vec<String>, ScheduleError> {
109 let cfg = self.read_config()?;
110 Ok(cfg.move_to_commands)
111 }
112
113 pub fn del_move_to_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
115 let mut cfg = self.read_config()?;
116 cfg.move_to_commands.retain(|c| c != cmd);
117 self.write_config(&cfg)
118 }
119
120 pub fn add_quick_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
126 let mut cfg = self.read_config()?;
127 if cfg.quick_commands.iter().any(|c| c == cmd) {
128 return Ok(());
129 }
130 cfg.quick_commands.push(cmd.to_string());
131 self.write_config(&cfg)
132 }
133
134 pub fn quick_cmds(&self) -> Result<Vec<String>, ScheduleError> {
136 let cfg = self.read_config()?;
137 Ok(cfg.quick_commands)
138 }
139
140 pub fn del_quick_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
142 let mut cfg = self.read_config()?;
143 cfg.quick_commands.retain(|c| c != cmd);
144 self.write_config(&cfg)
145 }
146
147 fn read_config(&self) -> Result<KnowledgeConfig, ScheduleError> {
148 if !self
149 .fs
150 .exists(DIR_USER_ROOT, self.config_filename)
151 .map_err(|e| ScheduleError::Read(e.to_string()))?
152 {
153 return Ok(KnowledgeConfig::default());
154 }
155 let content = self
156 .fs
157 .read(DIR_USER_ROOT, self.config_filename)
158 .map_err(|e| ScheduleError::Read(e.to_string()))?;
159 serde_json::from_str(&content).map_err(|e| ScheduleError::Read(e.to_string()))
160 }
161
162 fn write_config(&self, cfg: &KnowledgeConfig) -> Result<(), ScheduleError> {
163 let json =
164 serde_json::to_string_pretty(cfg).map_err(|e| ScheduleError::Write(e.to_string()))?;
165 self.fs
166 .write(DIR_USER_ROOT, self.config_filename, &json)
167 .map_err(|e| ScheduleError::Write(e.to_string()))
168 }
169}
170
171pub fn format_schedule_date(scheduled_at: i64, timezone: FixedOffset) -> String {
173 let now = Utc::now().timestamp();
174 let today_start = beginning_of_day(now);
175 let task_start = beginning_of_day(scheduled_at);
176 let diff_days = (task_start - today_start) / 86400;
177
178 let tz_dt = Utc
179 .timestamp_opt(scheduled_at, 0)
180 .single()
181 .expect("valid Unix timestamp")
182 .with_timezone(&timezone);
183
184 match diff_days {
185 0 => "Today".to_string(),
186 1 => "Tomorrow".to_string(),
187 2..=6 => format!("{} {:02}", tz_dt.format("%A"), tz_dt.day()),
188 7..=13 => format!("Next {}", tz_dt.format("%A %d")),
189 _ => format!(
190 "{} {}, {}",
191 tz_dt.format("%d %B"),
192 tz_dt.weekday(),
193 tz_dt.year()
194 ),
195 }
196}
197
198pub fn beginning_of_day(timestamp: i64) -> i64 {
200 let dt = Utc
201 .timestamp_opt(timestamp, 0)
202 .single()
203 .expect("valid Unix timestamp");
204 let date = dt.date_naive();
205 date.and_hms_milli_opt(0, 0, 0, 0)
206 .expect("midnight is always a valid time")
207 .and_utc()
208 .timestamp()
209}
210
211pub fn tomorrow_timestamp() -> i64 {
213 let tomorrow = Utc::now().date_naive() + chrono::Duration::days(1);
214 tomorrow
215 .and_hms_milli_opt(0, 0, 0, 0)
216 .expect("midnight is always a valid time")
217 .and_utc()
218 .timestamp()
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use tempfile::TempDir;
225
226 fn test_fs() -> (VirtualFs, TempDir) {
227 let dir = TempDir::new().unwrap();
228 let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
229 (fs, dir)
230 }
231
232 #[test]
233 fn test_add_and_list() {
234 let (fs, _t) = test_fs();
235 let mgr = ScheduleManager::new(&fs, "config.json");
236 mgr.add("Task.md", 1000000, "").unwrap();
237 mgr.add("Other.md", 2000000, "9:00").unwrap();
238 let schedules = mgr.schedules().unwrap();
239 assert_eq!(schedules.len(), 2);
240 }
241
242 #[test]
243 fn test_update_existing() {
244 let (fs, _t) = test_fs();
245 let mgr = ScheduleManager::new(&fs, "config.json");
246 mgr.add("Task.md", 1000000, "").unwrap();
247 mgr.add("Task.md", 2000000, "10:00").unwrap();
248 let schedules = mgr.schedules().unwrap();
249 assert_eq!(schedules.len(), 1);
250 assert_eq!(schedules[0].scheduled_at, 2000000);
251 }
252
253 #[test]
254 fn test_delete() {
255 let (fs, _t) = test_fs();
256 let mgr = ScheduleManager::new(&fs, "config.json");
257 mgr.add("Task.md", 1000000, "").unwrap();
258 mgr.delete("Task.md").unwrap();
259 assert!(mgr.schedules().unwrap().is_empty());
260 }
261
262 #[test]
263 fn test_format_date() {
264 let tz = FixedOffset::east_opt(0).expect("valid UTC offset");
265 let ts = Utc::now().timestamp() + 86400;
266 let formatted = format_schedule_date(ts, tz);
267 assert_eq!(formatted, "Tomorrow");
268 }
269
270 #[test]
271 fn test_tomorrow() {
272 assert!(tomorrow_timestamp() > Utc::now().timestamp());
273 }
274
275 #[test]
280 fn test_create_default_if_not_exists_creates() {
281 let (fs, _t) = test_fs();
282 let mgr = ScheduleManager::new(&fs, "config.json");
283 assert!(!fs.exists(DIR_USER_ROOT, "config.json").unwrap());
284 mgr.create_default_if_not_exists().unwrap();
285 assert!(fs.exists(DIR_USER_ROOT, "config.json").unwrap());
286 let cfg: KnowledgeConfig =
287 serde_json::from_str(&fs.read(DIR_USER_ROOT, "config.json").unwrap()).unwrap();
288 assert_eq!(cfg.language, "en");
289 assert!(cfg.schedules.is_empty());
290 assert!(cfg.move_to_commands.is_empty());
291 assert!(cfg.quick_commands.is_empty());
292 }
293
294 #[test]
295 fn test_create_default_if_not_exists_idempotent() {
296 let (fs, _t) = test_fs();
297 let mgr = ScheduleManager::new(&fs, "config.json");
298 mgr.create_default_if_not_exists().unwrap();
299 mgr.add("Task.md", 1000, "").unwrap();
301 mgr.create_default_if_not_exists().unwrap();
302 assert_eq!(mgr.schedules().unwrap().len(), 1);
303 }
304
305 #[test]
310 fn test_should_split_checklist() {
311 let (fs, _t) = test_fs();
312 let mgr = ScheduleManager::new(&fs, "config.json");
313 assert!(mgr.should_split_checklist("- item1\n- item2"));
314 assert!(mgr.should_split_checklist("anything"));
315 }
316
317 #[test]
322 fn test_add_move_to_cmd() {
323 let (fs, _t) = test_fs();
324 let mgr = ScheduleManager::new(&fs, "config.json");
325 assert!(mgr.move_to_cmds().unwrap().is_empty());
326 mgr.add_move_to_cmd("Archive").unwrap();
327 mgr.add_move_to_cmd("Later").unwrap();
328 assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Archive", "Later"]);
329 }
330
331 #[test]
332 fn test_add_move_to_cmd_duplicate() {
333 let (fs, _t) = test_fs();
334 let mgr = ScheduleManager::new(&fs, "config.json");
335 mgr.add_move_to_cmd("Archive").unwrap();
336 mgr.add_move_to_cmd("Archive").unwrap();
337 assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Archive"]);
338 }
339
340 #[test]
341 fn test_del_move_to_cmd() {
342 let (fs, _t) = test_fs();
343 let mgr = ScheduleManager::new(&fs, "config.json");
344 mgr.add_move_to_cmd("Archive").unwrap();
345 mgr.add_move_to_cmd("Later").unwrap();
346 mgr.del_move_to_cmd("Archive").unwrap();
347 assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Later"]);
348 }
349
350 #[test]
355 fn test_add_quick_cmd() {
356 let (fs, _t) = test_fs();
357 let mgr = ScheduleManager::new(&fs, "config.json");
358 assert!(mgr.quick_cmds().unwrap().is_empty());
359 mgr.add_quick_cmd("/done").unwrap();
360 mgr.add_quick_cmd("/shop").unwrap();
361 assert_eq!(mgr.quick_cmds().unwrap(), vec!["/done", "/shop"]);
362 }
363
364 #[test]
365 fn test_add_quick_cmd_duplicate() {
366 let (fs, _t) = test_fs();
367 let mgr = ScheduleManager::new(&fs, "config.json");
368 mgr.add_quick_cmd("/done").unwrap();
369 mgr.add_quick_cmd("/done").unwrap();
370 assert_eq!(mgr.quick_cmds().unwrap(), vec!["/done"]);
371 }
372
373 #[test]
374 fn test_del_quick_cmd() {
375 let (fs, _t) = test_fs();
376 let mgr = ScheduleManager::new(&fs, "config.json");
377 mgr.add_quick_cmd("/done").unwrap();
378 mgr.add_quick_cmd("/shop").unwrap();
379 mgr.del_quick_cmd("/done").unwrap();
380 assert_eq!(mgr.quick_cmds().unwrap(), vec!["/shop"]);
381 }
382}