1use chrono::{Datelike, FixedOffset, TimeZone, Utc};
7
8use crate::fs::VirtualFs;
9use crate::types::{KnowledgeConfig, Schedule, DIR_USER_ROOT};
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
90 }
91
92 pub fn add_move_to_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
98 let mut cfg = self.read_config()?;
99 if cfg.move_to_commands.iter().any(|c| c == cmd) {
100 return Ok(());
101 }
102 cfg.move_to_commands.push(cmd.to_string());
103 self.write_config(&cfg)
104 }
105
106 pub fn move_to_cmds(&self) -> Result<Vec<String>, ScheduleError> {
108 let cfg = self.read_config()?;
109 Ok(cfg.move_to_commands)
110 }
111
112 pub fn del_move_to_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
114 let mut cfg = self.read_config()?;
115 cfg.move_to_commands.retain(|c| c != cmd);
116 self.write_config(&cfg)
117 }
118
119 pub fn add_quick_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
125 let mut cfg = self.read_config()?;
126 if cfg.quick_commands.iter().any(|c| c == cmd) {
127 return Ok(());
128 }
129 cfg.quick_commands.push(cmd.to_string());
130 self.write_config(&cfg)
131 }
132
133 pub fn quick_cmds(&self) -> Result<Vec<String>, ScheduleError> {
135 let cfg = self.read_config()?;
136 Ok(cfg.quick_commands)
137 }
138
139 pub fn del_quick_cmd(&self, cmd: &str) -> Result<(), ScheduleError> {
141 let mut cfg = self.read_config()?;
142 cfg.quick_commands.retain(|c| c != cmd);
143 self.write_config(&cfg)
144 }
145
146 fn read_config(&self) -> Result<KnowledgeConfig, ScheduleError> {
147 if !self
148 .fs
149 .exists(DIR_USER_ROOT, self.config_filename)
150 .map_err(|e| ScheduleError::Read(e.to_string()))?
151 {
152 return Ok(KnowledgeConfig::default());
153 }
154 let content = self
155 .fs
156 .read(DIR_USER_ROOT, self.config_filename)
157 .map_err(|e| ScheduleError::Read(e.to_string()))?;
158 serde_json::from_str(&content).map_err(|e| ScheduleError::Read(e.to_string()))
159 }
160
161 fn write_config(&self, cfg: &KnowledgeConfig) -> Result<(), ScheduleError> {
162 let json =
163 serde_json::to_string_pretty(cfg).map_err(|e| ScheduleError::Write(e.to_string()))?;
164 self.fs
165 .write(DIR_USER_ROOT, self.config_filename, &json)
166 .map_err(|e| ScheduleError::Write(e.to_string()))
167 }
168}
169
170pub fn format_schedule_date(scheduled_at: i64, timezone: FixedOffset) -> String {
172 let now = Utc::now().timestamp();
173 let today_start = beginning_of_day(now);
174 let task_start = beginning_of_day(scheduled_at);
175 let diff_days = (task_start - today_start) / 86400;
176
177 let tz_dt = Utc
178 .timestamp_opt(scheduled_at, 0)
179 .unwrap()
180 .with_timezone(&timezone);
181
182 match diff_days {
183 0 => "Today".to_string(),
184 1 => "Tomorrow".to_string(),
185 2..=6 => format!("{} {:02}", tz_dt.format("%A"), tz_dt.day()),
186 7..=13 => format!("Next {}", tz_dt.format("%A %d")),
187 _ => format!(
188 "{} {}, {}",
189 tz_dt.format("%d %B"),
190 tz_dt.weekday(),
191 tz_dt.year()
192 ),
193 }
194}
195
196pub fn beginning_of_day(timestamp: i64) -> i64 {
198 let dt = Utc.timestamp_opt(timestamp, 0).unwrap();
199 let date = dt.date_naive();
200 date.and_hms_milli_opt(0, 0, 0, 0)
201 .unwrap()
202 .and_utc()
203 .timestamp()
204}
205
206pub fn tomorrow_timestamp() -> i64 {
208 let tomorrow = Utc::now().date_naive() + chrono::Duration::days(1);
209 tomorrow
210 .and_hms_milli_opt(0, 0, 0, 0)
211 .unwrap()
212 .and_utc()
213 .timestamp()
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use tempfile::TempDir;
220
221 fn test_fs() -> (VirtualFs, TempDir) {
222 let dir = TempDir::new().unwrap();
223 let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
224 (fs, dir)
225 }
226
227 #[test]
228 fn test_add_and_list() {
229 let (fs, _t) = test_fs();
230 let mgr = ScheduleManager::new(&fs, "config.json");
231 mgr.add("Task.md", 1000000, "").unwrap();
232 mgr.add("Other.md", 2000000, "9:00").unwrap();
233 let schedules = mgr.schedules().unwrap();
234 assert_eq!(schedules.len(), 2);
235 }
236
237 #[test]
238 fn test_update_existing() {
239 let (fs, _t) = test_fs();
240 let mgr = ScheduleManager::new(&fs, "config.json");
241 mgr.add("Task.md", 1000000, "").unwrap();
242 mgr.add("Task.md", 2000000, "10:00").unwrap();
243 let schedules = mgr.schedules().unwrap();
244 assert_eq!(schedules.len(), 1);
245 assert_eq!(schedules[0].scheduled_at, 2000000);
246 }
247
248 #[test]
249 fn test_delete() {
250 let (fs, _t) = test_fs();
251 let mgr = ScheduleManager::new(&fs, "config.json");
252 mgr.add("Task.md", 1000000, "").unwrap();
253 mgr.delete("Task.md").unwrap();
254 assert!(mgr.schedules().unwrap().is_empty());
255 }
256
257 #[test]
258 fn test_format_date() {
259 let tz = FixedOffset::east_opt(0).unwrap();
260 let ts = Utc::now().timestamp() + 86400;
261 let formatted = format_schedule_date(ts, tz);
262 assert_eq!(formatted, "Tomorrow");
263 }
264
265 #[test]
266 fn test_tomorrow() {
267 assert!(tomorrow_timestamp() > Utc::now().timestamp());
268 }
269
270 #[test]
275 fn test_create_default_if_not_exists_creates() {
276 let (fs, _t) = test_fs();
277 let mgr = ScheduleManager::new(&fs, "config.json");
278 assert!(!fs.exists(DIR_USER_ROOT, "config.json").unwrap());
279 mgr.create_default_if_not_exists().unwrap();
280 assert!(fs.exists(DIR_USER_ROOT, "config.json").unwrap());
281 let cfg: KnowledgeConfig =
282 serde_json::from_str(&fs.read(DIR_USER_ROOT, "config.json").unwrap()).unwrap();
283 assert_eq!(cfg.language, "en");
284 assert!(cfg.schedules.is_empty());
285 assert!(cfg.move_to_commands.is_empty());
286 assert!(cfg.quick_commands.is_empty());
287 }
288
289 #[test]
290 fn test_create_default_if_not_exists_idempotent() {
291 let (fs, _t) = test_fs();
292 let mgr = ScheduleManager::new(&fs, "config.json");
293 mgr.create_default_if_not_exists().unwrap();
294 mgr.add("Task.md", 1000, "").unwrap();
296 mgr.create_default_if_not_exists().unwrap();
297 assert_eq!(mgr.schedules().unwrap().len(), 1);
298 }
299
300 #[test]
305 fn test_should_split_checklist() {
306 let (fs, _t) = test_fs();
307 let mgr = ScheduleManager::new(&fs, "config.json");
308 assert!(mgr.should_split_checklist("- item1\n- item2"));
309 assert!(mgr.should_split_checklist("anything"));
310 }
311
312 #[test]
317 fn test_add_move_to_cmd() {
318 let (fs, _t) = test_fs();
319 let mgr = ScheduleManager::new(&fs, "config.json");
320 assert!(mgr.move_to_cmds().unwrap().is_empty());
321 mgr.add_move_to_cmd("Archive").unwrap();
322 mgr.add_move_to_cmd("Later").unwrap();
323 assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Archive", "Later"]);
324 }
325
326 #[test]
327 fn test_add_move_to_cmd_duplicate() {
328 let (fs, _t) = test_fs();
329 let mgr = ScheduleManager::new(&fs, "config.json");
330 mgr.add_move_to_cmd("Archive").unwrap();
331 mgr.add_move_to_cmd("Archive").unwrap();
332 assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Archive"]);
333 }
334
335 #[test]
336 fn test_del_move_to_cmd() {
337 let (fs, _t) = test_fs();
338 let mgr = ScheduleManager::new(&fs, "config.json");
339 mgr.add_move_to_cmd("Archive").unwrap();
340 mgr.add_move_to_cmd("Later").unwrap();
341 mgr.del_move_to_cmd("Archive").unwrap();
342 assert_eq!(mgr.move_to_cmds().unwrap(), vec!["Later"]);
343 }
344
345 #[test]
350 fn test_add_quick_cmd() {
351 let (fs, _t) = test_fs();
352 let mgr = ScheduleManager::new(&fs, "config.json");
353 assert!(mgr.quick_cmds().unwrap().is_empty());
354 mgr.add_quick_cmd("/done").unwrap();
355 mgr.add_quick_cmd("/shop").unwrap();
356 assert_eq!(mgr.quick_cmds().unwrap(), vec!["/done", "/shop"]);
357 }
358
359 #[test]
360 fn test_add_quick_cmd_duplicate() {
361 let (fs, _t) = test_fs();
362 let mgr = ScheduleManager::new(&fs, "config.json");
363 mgr.add_quick_cmd("/done").unwrap();
364 mgr.add_quick_cmd("/done").unwrap();
365 assert_eq!(mgr.quick_cmds().unwrap(), vec!["/done"]);
366 }
367
368 #[test]
369 fn test_del_quick_cmd() {
370 let (fs, _t) = test_fs();
371 let mgr = ScheduleManager::new(&fs, "config.json");
372 mgr.add_quick_cmd("/done").unwrap();
373 mgr.add_quick_cmd("/shop").unwrap();
374 mgr.del_quick_cmd("/done").unwrap();
375 assert_eq!(mgr.quick_cmds().unwrap(), vec!["/shop"]);
376 }
377}