1use chrono::{Datelike, FixedOffset, TimeZone, Utc};
10use regex::Regex;
11
12use crate::chat::read_chat_msgs;
13use crate::fs::VirtualFs;
14use crate::journal::add_record as journal_add_record;
15use crate::parser::norm_new_lines;
16use crate::types::{
17 CHAT_FILENAME, DIR_ARCHIVE, DIR_USER_ROOT, DONE_FILENAME, FsError, KnowledgeConfig,
18 LATER_FILENAME,
19};
20
21#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
23pub struct NightlyReport {
24 pub archived_count: usize,
26 pub journal_count: usize,
28}
29
30pub fn remove_completed_items(
36 fs: &VirtualFs,
37 config: &KnowledgeConfig,
38) -> Result<NightlyReport, FsError> {
39 let tz = parse_timezone(&config.timezone);
40 let mut report = NightlyReport::default();
41
42 type Reducer = fn(&str) -> (String, String);
47
48 let targets: &[(&str, Reducer)] = &[
49 (CHAT_FILENAME, remove_completed_checklist),
50 (LATER_FILENAME, remove_completed_checklist),
51 (CHAT_FILENAME, remove_completed_inbox_entries),
52 ];
53
54 for &(filename, reducer) in targets {
55 let md = match fs.read(DIR_USER_ROOT, filename) {
56 Ok(content) => content,
57 Err(FsError::Io(_)) => continue, Err(e) => return Err(e),
59 };
60
61 let (reduced_md, removed_md) = reducer(&md);
62 if removed_md.is_empty() {
63 continue;
64 }
65
66 fs.write(DIR_USER_ROOT, filename, &reduced_md)?;
67
68 let done_md = match fs.read(DIR_ARCHIVE, DONE_FILENAME) {
70 Ok(content) => content,
71 Err(FsError::Io(_)) => String::new(),
72 Err(e) => return Err(e),
73 };
74
75 let now_tz = Utc::now().with_timezone(&tz);
76 let header = format!(
77 "#### {} {}, {}",
78 now_tz.day(),
79 now_tz.format("%B"),
80 now_tz.format("%A")
81 );
82
83 let updated_done = add_header_and_text(&done_md, &header, &removed_md);
84 fs.write(DIR_ARCHIVE, DONE_FILENAME, &updated_done)?;
85
86 let tasks = checklist_items(&removed_md);
88 for task in &tasks {
89 let stripped = strip_chat_timestamp(task);
90 let _ = journal_add_record(fs, &format!("✅ {stripped}"), tz);
91 report.journal_count += 1;
92 }
93 report.archived_count += tasks.len();
94 }
95
96 Ok(report)
97}
98
99pub fn remove_completed_checklist(md: &str) -> (String, String) {
103 let mut kept = Vec::new();
104 let mut removed = String::new();
105
106 for line in md.lines() {
107 let trimmed = line.trim();
108 if trimmed.starts_with("- [x] ") || trimmed.starts_with("- [X] ") {
109 removed.push_str(trimmed);
110 removed.push('\n');
111 } else {
112 kept.push(line);
113 }
114 }
115
116 (kept.join("\n"), removed)
117}
118
119pub fn remove_completed_inbox_entries(md: &str) -> (String, String) {
124 let blocks = read_chat_msgs(md);
125
126 let done_re = Regex::new(r"^- \[[xX]\] ").expect("valid regex literal");
127 let ts_re = Regex::new(r"^(?:- \[[ xX]\] )?`\d{2}:\d{2}` ").expect("valid regex literal");
129
130 let mut kept: Vec<String> = Vec::new();
131 let mut removed = String::new();
132
133 for block in blocks {
134 let first_line = if let Some(nl) = block.find('\n') {
135 &block[..nl]
136 } else {
137 &block
138 };
139
140 if !done_re.is_match(first_line) {
141 kept.push(block);
142 continue;
143 }
144
145 let body = ts_re.replace_all(&block, "");
147 let body = body.replace('\n', " ");
149 removed.push_str("- [x] ");
150 removed.push_str(&body);
151 removed.push('\n');
152 }
153
154 let new_md = kept.join("\n").trim().to_string();
155 (new_md, removed)
156}
157
158pub fn move_due_tasks(
165 fs: &VirtualFs,
166 config: &mut KnowledgeConfig,
167) -> Result<Vec<String>, FsError> {
168 let now_ts = Utc::now().timestamp();
169 let mut moved = Vec::new();
170
171 let due_indices: Vec<usize> = config
173 .schedules
174 .iter()
175 .enumerate()
176 .filter(|(_, s)| s.scheduled_at <= now_ts)
177 .map(|(i, _)| i)
178 .collect();
179
180 for idx in due_indices.into_iter().rev() {
182 let schedule = &config.schedules[idx];
183 let filename = schedule.filename.clone();
184 let cron = schedule.cron.clone();
185
186 if let Ok(task_content) = fs.read(DIR_USER_ROOT, &filename) {
188 append_to_chat(fs, &task_content)?;
189 }
190
191 moved.push(filename.clone());
192
193 if !cron.is_empty() {
194 if let Some(next_ts) = next_exclude_today(&cron) {
196 if let Some(s) = config.schedules.get_mut(idx) {
198 s.scheduled_at = next_ts;
199 }
200 }
201 } else {
202 config.schedules.remove(idx);
204 }
205 }
206
207 Ok(moved)
208}
209
210pub fn schedule_report(schedules: &[(String, i64)]) -> String {
215 let mut day_order: Vec<String> = Vec::new();
216 let mut day_tasks: std::collections::HashMap<String, Vec<String>> =
217 std::collections::HashMap::new();
218 let now_ts = Utc::now().timestamp();
219
220 for (display_name, scheduled_at) in schedules {
221 let day = format_schedule_day(*scheduled_at, now_ts);
222 if !day_tasks.contains_key(&day) {
223 day_order.push(day.clone());
224 }
225 day_tasks.entry(day).or_default().push(display_name.clone());
226 }
227
228 let mut report = String::new();
229 for day in &day_order {
230 report.push_str(&format!("{day}\n"));
231 if let Some(tasks) = day_tasks.get(day) {
232 for task in tasks {
233 report.push_str(&format!("- {task}\n"));
234 }
235 }
236 report.push('\n');
237 }
238
239 report.trim().to_string()
240}
241
242pub fn next_exclude_today(cron_expr: &str) -> Option<i64> {
247 let parts: Vec<&str> = cron_expr.trim().split(':').collect();
249 if parts.len() != 2 {
250 return None;
251 }
252
253 let hour: u32 = parts[0].parse().ok()?;
254 let minute: u32 = parts[1].parse().ok()?;
255
256 if hour > 23 || minute > 59 {
257 return None;
258 }
259
260 let now = Utc::now();
262 let tomorrow = now.date_naive() + chrono::Duration::days(1);
263 let target = tomorrow.and_hms_opt(hour, minute, 0)?.and_utc().timestamp();
264
265 Some(target)
266}
267
268fn parse_timezone(tz_str: &str) -> FixedOffset {
272 if tz_str == "UTC" || tz_str.is_empty() {
273 return FixedOffset::east_opt(0).expect("valid UTC offset");
274 }
275 if let Ok(offset) = tz_str.parse::<FixedOffset>() {
277 return offset;
278 }
279 FixedOffset::east_opt(0).expect("valid UTC offset")
280}
281
282fn checklist_items(md: &str) -> Vec<String> {
285 let re = Regex::new(r"^- \[[ xX]\] (.+)$").expect("valid regex literal");
286 let mut items = Vec::new();
287 for line in md.lines() {
288 let trimmed = line.trim();
289 if let Some(caps) = re.captures(trimmed)
290 && let Some(m) = caps.get(1)
291 {
292 items.push(m.as_str().to_string());
293 }
294 }
295 items
296}
297
298fn strip_chat_timestamp(s: &str) -> String {
300 let re = Regex::new(r"^`\d{2}:\d{2}` ").expect("valid regex literal");
301 re.replace(s, "").to_string()
302}
303
304fn add_header_and_text(existing: &str, header: &str, text: &str) -> String {
306 let mut result = existing.trim().to_string();
307 if !result.is_empty() {
308 result.push('\n');
309 }
310 result.push_str(header);
311 result.push('\n');
312 result.push_str(text.trim());
313 result
314}
315
316fn append_to_chat(fs: &VirtualFs, content: &str) -> Result<(), FsError> {
318 let existing = match fs.read(DIR_USER_ROOT, CHAT_FILENAME) {
319 Ok(c) => c,
320 Err(FsError::Io(_)) => String::new(),
321 Err(e) => return Err(e),
322 };
323
324 let normalized = norm_new_lines(&existing);
325 let mut new_content = normalized.trim().to_string();
326 if !new_content.is_empty() {
327 new_content.push('\n');
328 }
329
330 let now = Utc::now();
331 let header = format!(
332 "#### {} {}, {}",
333 now.date_naive().day(),
334 now.format("%B"),
335 now.format("%A")
336 );
337
338 new_content.push_str(&header);
339 new_content.push('\n');
340 new_content.push_str(content.trim());
341 new_content.push('\n');
342
343 fs.write(DIR_USER_ROOT, CHAT_FILENAME, &new_content)
344}
345
346fn format_schedule_day(scheduled_at: i64, now_ts: i64) -> String {
348 let today_start = beginning_of_day(now_ts);
349 let task_start = beginning_of_day(scheduled_at);
350 let diff_days = (task_start - today_start) / 86400;
351
352 let dt = Utc
353 .timestamp_opt(scheduled_at, 0)
354 .single()
355 .expect("valid Unix timestamp");
356
357 match diff_days {
358 0 => "Today".to_string(),
359 1 => "Tomorrow".to_string(),
360 2..=6 => format!("{} {:02}", dt.format("%A"), dt.day()),
361 7..=13 => format!("Next {}", dt.format("%A %d")),
362 _ => format!("{} {}, {}", dt.format("%d %B"), dt.weekday(), dt.year()),
363 }
364}
365
366fn beginning_of_day(timestamp: i64) -> i64 {
368 let dt = Utc
369 .timestamp_opt(timestamp, 0)
370 .single()
371 .expect("valid Unix timestamp");
372 let date = dt.date_naive();
373 date.and_hms_milli_opt(0, 0, 0, 0)
374 .expect("midnight is always a valid time")
375 .and_utc()
376 .timestamp()
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use tempfile::TempDir;
383
384 use chrono::Timelike;
385
386 fn test_fs() -> (VirtualFs, TempDir) {
387 let dir = TempDir::new().unwrap();
388 let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
389 (fs, dir)
390 }
391
392 #[test]
395 fn test_remove_completed_checklist() {
396 let md = "- [ ] Pending\n- [x] Done\n- [X] Also done\n- [ ] Keep";
397 let (kept, removed) = remove_completed_checklist(md);
398 assert!(kept.contains("Pending"));
399 assert!(kept.contains("Keep"));
400 assert!(!kept.contains("Done"));
401 assert!(removed.contains("- [x] Done"));
402 assert!(removed.contains("- [X] Also done"));
403 }
404
405 #[test]
406 fn test_remove_completed_checklist_no_completed() {
407 let md = "- [ ] Pending\n- [ ] Another";
408 let (kept, removed) = remove_completed_checklist(md);
409 assert_eq!(removed, "");
410 assert!(kept.contains("Pending"));
411 }
412
413 #[test]
414 fn test_remove_completed_checklist_empty() {
415 let md = "";
416 let (kept, removed) = remove_completed_checklist(md);
417 assert_eq!(kept, "");
418 assert_eq!(removed, "");
419 }
420
421 #[test]
424 fn test_remove_completed_inbox_entries() {
425 let md = "#### 19 May\n- [x] `09:00` Completed task\n- [ ] `10:00` Pending task";
426 let (kept, removed) = remove_completed_inbox_entries(md);
427 assert!(kept.contains("Pending"));
428 assert!(!kept.contains("Completed"));
429 assert!(removed.contains("- [x] Completed task"));
430 }
431
432 #[test]
433 fn test_remove_completed_inbox_entries_multiline_block() {
434 let md = "- [x] `09:00` Multi\nline task\n- [ ] Keep this";
435 let (kept, removed) = remove_completed_inbox_entries(md);
436 assert!(kept.contains("Keep"));
437 assert!(removed.contains("- [x] Multi line task"));
438 }
439
440 #[test]
441 fn test_remove_completed_inbox_entries_no_completed() {
442 let md = "#### 19 May\n- [ ] `09:00` Pending\n- [ ] `10:00` Also pending";
443 let (_kept, removed) = remove_completed_inbox_entries(md);
444 assert!(removed.is_empty());
445 }
446
447 #[test]
450 fn test_remove_completed_items_basic() {
451 let (fs, _t) = test_fs();
452 fs.create_system_dirs().unwrap();
453
454 fs.write(
456 DIR_USER_ROOT,
457 CHAT_FILENAME,
458 "- [x] Completed task\n- [ ] Pending task",
459 )
460 .unwrap();
461
462 let config = KnowledgeConfig::default();
463 let report = remove_completed_items(&fs, &config).unwrap();
464 assert_eq!(report.archived_count, 1); let chat = fs.read(DIR_USER_ROOT, CHAT_FILENAME).unwrap();
468 assert!(chat.contains("Pending"));
469 assert!(!chat.contains("Completed task"));
470
471 let done = fs.read(DIR_ARCHIVE, DONE_FILENAME).unwrap();
473 assert!(done.contains("Completed task"));
474 }
475
476 #[test]
477 fn test_remove_completed_items_both_files() {
478 let (fs, _t) = test_fs();
479 fs.create_system_dirs().unwrap();
480
481 fs.write(
482 DIR_USER_ROOT,
483 CHAT_FILENAME,
484 "- [x] Chat done\n- [ ] Chat pending",
485 )
486 .unwrap();
487 fs.write(
488 DIR_USER_ROOT,
489 LATER_FILENAME,
490 "- [x] Later done\n- [ ] Later pending",
491 )
492 .unwrap();
493
494 let config = KnowledgeConfig::default();
495 let report = remove_completed_items(&fs, &config).unwrap();
496 assert!(report.archived_count >= 2);
497
498 let later = fs.read(DIR_USER_ROOT, LATER_FILENAME).unwrap();
500 assert!(later.contains("Later pending"));
501 assert!(!later.contains("Later done"));
502 }
503
504 #[test]
507 fn test_next_exclude_today_valid() {
508 let result = next_exclude_today("9:00");
509 assert!(result.is_some());
510 let ts = result.unwrap();
511 assert!(ts > Utc::now().timestamp());
513 }
514
515 #[test]
516 fn test_next_exclude_today_invalid() {
517 assert!(next_exclude_today("invalid").is_none());
518 assert!(next_exclude_today("25:00").is_none());
519 assert!(next_exclude_today("9:60").is_none());
520 assert!(next_exclude_today("").is_none());
521 }
522
523 #[test]
524 fn test_next_exclude_today_format() {
525 let result = next_exclude_today("14:30");
526 assert!(result.is_some());
527 let ts = result.unwrap();
528 let dt = Utc
529 .timestamp_opt(ts, 0)
530 .single()
531 .expect("valid Unix timestamp");
532 assert_eq!(dt.hour(), 14);
533 assert_eq!(dt.minute(), 30);
534 }
535
536 #[test]
539 fn test_schedule_report() {
540 let now_ts = Utc::now().timestamp();
541 let schedules = vec![
542 ("Task A".to_string(), now_ts),
543 ("Task B".to_string(), now_ts + 86400),
544 ];
545 let report = schedule_report(&schedules);
546 assert!(report.contains("Today"));
547 assert!(report.contains("Tomorrow"));
548 assert!(report.contains("Task A"));
549 assert!(report.contains("Task B"));
550 }
551
552 #[test]
555 fn test_move_due_tasks_past_schedule() {
556 let (fs, _t) = test_fs();
557
558 let past_ts = Utc::now().timestamp() - 3600; let mut config = KnowledgeConfig::default();
560 config.schedules.push(crate::types::Schedule {
561 filename: "Task.md".to_string(),
562 scheduled_at: past_ts,
563 cron: String::new(),
564 cmd: String::new(),
565 });
566
567 let moved = move_due_tasks(&fs, &mut config).unwrap();
568 assert_eq!(moved.len(), 1);
569 assert_eq!(moved[0], "Task.md");
570 assert!(config.schedules.is_empty());
572 }
573
574 #[test]
575 fn test_move_due_tasks_future_schedule() {
576 let (fs, _t) = test_fs();
577
578 let future_ts = Utc::now().timestamp() + 86400; let mut config = KnowledgeConfig::default();
580 config.schedules.push(crate::types::Schedule {
581 filename: "Task.md".to_string(),
582 scheduled_at: future_ts,
583 cron: String::new(),
584 cmd: String::new(),
585 });
586
587 let moved = move_due_tasks(&fs, &mut config).unwrap();
588 assert!(moved.is_empty());
589 assert_eq!(config.schedules.len(), 1);
590 }
591
592 #[test]
593 fn test_move_due_tasks_cron_reschedules() {
594 let (fs, _t) = test_fs();
595
596 let past_ts = Utc::now().timestamp() - 3600;
597 let mut config = KnowledgeConfig::default();
598 config.schedules.push(crate::types::Schedule {
599 filename: "Recurring.md".to_string(),
600 scheduled_at: past_ts,
601 cron: "9:00".to_string(),
602 cmd: String::new(),
603 });
604
605 let moved = move_due_tasks(&fs, &mut config).unwrap();
606 assert_eq!(moved.len(), 1);
607 assert_eq!(config.schedules.len(), 1);
609 assert!(config.schedules[0].scheduled_at > Utc::now().timestamp());
610 }
611
612 #[test]
615 fn test_add_header_and_text() {
616 let result = add_header_and_text("existing", "#### Header", "some text");
617 assert!(result.contains("existing"));
618 assert!(result.contains("#### Header"));
619 assert!(result.contains("some text"));
620 }
621
622 #[test]
623 fn test_add_header_and_text_empty_existing() {
624 let result = add_header_and_text("", "#### Header", "some text");
625 assert!(result.starts_with("#### Header"));
626 }
627}