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 FsError, KnowledgeConfig, CHAT_FILENAME, DIR_ARCHIVE, DIR_USER_ROOT, DONE_FILENAME,
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]\] ").unwrap();
127 let ts_re = Regex::new(r"^(?:- \[[ xX]\] )?`\d{2}:\d{2}` ").unwrap();
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).unwrap();
274 }
275 if let Ok(offset) = tz_str.parse::<FixedOffset>() {
277 return offset;
278 }
279 FixedOffset::east_opt(0).unwrap()
280}
281
282fn checklist_items(md: &str) -> Vec<String> {
285 let re = Regex::new(r"^- \[[ xX]\] (.+)$").unwrap();
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 if let Some(m) = caps.get(1) {
291 items.push(m.as_str().to_string());
292 }
293 }
294 }
295 items
296}
297
298fn strip_chat_timestamp(s: &str) -> String {
300 let re = Regex::new(r"^`\d{2}:\d{2}` ").unwrap();
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.timestamp_opt(scheduled_at, 0).unwrap();
353
354 match diff_days {
355 0 => "Today".to_string(),
356 1 => "Tomorrow".to_string(),
357 2..=6 => format!("{} {:02}", dt.format("%A"), dt.day()),
358 7..=13 => format!("Next {}", dt.format("%A %d")),
359 _ => format!("{} {}, {}", dt.format("%d %B"), dt.weekday(), dt.year()),
360 }
361}
362
363fn beginning_of_day(timestamp: i64) -> i64 {
365 let dt = Utc.timestamp_opt(timestamp, 0).unwrap();
366 let date = dt.date_naive();
367 date.and_hms_milli_opt(0, 0, 0, 0)
368 .unwrap()
369 .and_utc()
370 .timestamp()
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use tempfile::TempDir;
377
378 use chrono::Timelike;
379
380 fn test_fs() -> (VirtualFs, TempDir) {
381 let dir = TempDir::new().unwrap();
382 let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
383 (fs, dir)
384 }
385
386 #[test]
389 fn test_remove_completed_checklist() {
390 let md = "- [ ] Pending\n- [x] Done\n- [X] Also done\n- [ ] Keep";
391 let (kept, removed) = remove_completed_checklist(md);
392 assert!(kept.contains("Pending"));
393 assert!(kept.contains("Keep"));
394 assert!(!kept.contains("Done"));
395 assert!(removed.contains("- [x] Done"));
396 assert!(removed.contains("- [X] Also done"));
397 }
398
399 #[test]
400 fn test_remove_completed_checklist_no_completed() {
401 let md = "- [ ] Pending\n- [ ] Another";
402 let (kept, removed) = remove_completed_checklist(md);
403 assert_eq!(removed, "");
404 assert!(kept.contains("Pending"));
405 }
406
407 #[test]
408 fn test_remove_completed_checklist_empty() {
409 let md = "";
410 let (kept, removed) = remove_completed_checklist(md);
411 assert_eq!(kept, "");
412 assert_eq!(removed, "");
413 }
414
415 #[test]
418 fn test_remove_completed_inbox_entries() {
419 let md = "#### 19 May\n- [x] `09:00` Completed task\n- [ ] `10:00` Pending task";
420 let (kept, removed) = remove_completed_inbox_entries(md);
421 assert!(kept.contains("Pending"));
422 assert!(!kept.contains("Completed"));
423 assert!(removed.contains("- [x] Completed task"));
424 }
425
426 #[test]
427 fn test_remove_completed_inbox_entries_multiline_block() {
428 let md = "- [x] `09:00` Multi\nline task\n- [ ] Keep this";
429 let (kept, removed) = remove_completed_inbox_entries(md);
430 assert!(kept.contains("Keep"));
431 assert!(removed.contains("- [x] Multi line task"));
432 }
433
434 #[test]
435 fn test_remove_completed_inbox_entries_no_completed() {
436 let md = "#### 19 May\n- [ ] `09:00` Pending\n- [ ] `10:00` Also pending";
437 let (_kept, removed) = remove_completed_inbox_entries(md);
438 assert!(removed.is_empty());
439 }
440
441 #[test]
444 fn test_remove_completed_items_basic() {
445 let (fs, _t) = test_fs();
446 fs.create_system_dirs().unwrap();
447
448 fs.write(
450 DIR_USER_ROOT,
451 CHAT_FILENAME,
452 "- [x] Completed task\n- [ ] Pending task",
453 )
454 .unwrap();
455
456 let config = KnowledgeConfig::default();
457 let report = remove_completed_items(&fs, &config).unwrap();
458 assert_eq!(report.archived_count, 1); let chat = fs.read(DIR_USER_ROOT, CHAT_FILENAME).unwrap();
462 assert!(chat.contains("Pending"));
463 assert!(!chat.contains("Completed task"));
464
465 let done = fs.read(DIR_ARCHIVE, DONE_FILENAME).unwrap();
467 assert!(done.contains("Completed task"));
468 }
469
470 #[test]
471 fn test_remove_completed_items_both_files() {
472 let (fs, _t) = test_fs();
473 fs.create_system_dirs().unwrap();
474
475 fs.write(
476 DIR_USER_ROOT,
477 CHAT_FILENAME,
478 "- [x] Chat done\n- [ ] Chat pending",
479 )
480 .unwrap();
481 fs.write(
482 DIR_USER_ROOT,
483 LATER_FILENAME,
484 "- [x] Later done\n- [ ] Later pending",
485 )
486 .unwrap();
487
488 let config = KnowledgeConfig::default();
489 let report = remove_completed_items(&fs, &config).unwrap();
490 assert!(report.archived_count >= 2);
491
492 let later = fs.read(DIR_USER_ROOT, LATER_FILENAME).unwrap();
494 assert!(later.contains("Later pending"));
495 assert!(!later.contains("Later done"));
496 }
497
498 #[test]
501 fn test_next_exclude_today_valid() {
502 let result = next_exclude_today("9:00");
503 assert!(result.is_some());
504 let ts = result.unwrap();
505 assert!(ts > Utc::now().timestamp());
507 }
508
509 #[test]
510 fn test_next_exclude_today_invalid() {
511 assert!(next_exclude_today("invalid").is_none());
512 assert!(next_exclude_today("25:00").is_none());
513 assert!(next_exclude_today("9:60").is_none());
514 assert!(next_exclude_today("").is_none());
515 }
516
517 #[test]
518 fn test_next_exclude_today_format() {
519 let result = next_exclude_today("14:30");
520 assert!(result.is_some());
521 let ts = result.unwrap();
522 let dt = Utc.timestamp_opt(ts, 0).unwrap();
523 assert_eq!(dt.hour(), 14);
524 assert_eq!(dt.minute(), 30);
525 }
526
527 #[test]
530 fn test_schedule_report() {
531 let now_ts = Utc::now().timestamp();
532 let schedules = vec![
533 ("Task A".to_string(), now_ts),
534 ("Task B".to_string(), now_ts + 86400),
535 ];
536 let report = schedule_report(&schedules);
537 assert!(report.contains("Today"));
538 assert!(report.contains("Tomorrow"));
539 assert!(report.contains("Task A"));
540 assert!(report.contains("Task B"));
541 }
542
543 #[test]
546 fn test_move_due_tasks_past_schedule() {
547 let (fs, _t) = test_fs();
548
549 let past_ts = Utc::now().timestamp() - 3600; let mut config = KnowledgeConfig::default();
551 config.schedules.push(crate::types::Schedule {
552 filename: "Task.md".to_string(),
553 scheduled_at: past_ts,
554 cron: String::new(),
555 cmd: String::new(),
556 });
557
558 let moved = move_due_tasks(&fs, &mut config).unwrap();
559 assert_eq!(moved.len(), 1);
560 assert_eq!(moved[0], "Task.md");
561 assert!(config.schedules.is_empty());
563 }
564
565 #[test]
566 fn test_move_due_tasks_future_schedule() {
567 let (fs, _t) = test_fs();
568
569 let future_ts = Utc::now().timestamp() + 86400; let mut config = KnowledgeConfig::default();
571 config.schedules.push(crate::types::Schedule {
572 filename: "Task.md".to_string(),
573 scheduled_at: future_ts,
574 cron: String::new(),
575 cmd: String::new(),
576 });
577
578 let moved = move_due_tasks(&fs, &mut config).unwrap();
579 assert!(moved.is_empty());
580 assert_eq!(config.schedules.len(), 1);
581 }
582
583 #[test]
584 fn test_move_due_tasks_cron_reschedules() {
585 let (fs, _t) = test_fs();
586
587 let past_ts = Utc::now().timestamp() - 3600;
588 let mut config = KnowledgeConfig::default();
589 config.schedules.push(crate::types::Schedule {
590 filename: "Recurring.md".to_string(),
591 scheduled_at: past_ts,
592 cron: "9:00".to_string(),
593 cmd: String::new(),
594 });
595
596 let moved = move_due_tasks(&fs, &mut config).unwrap();
597 assert_eq!(moved.len(), 1);
598 assert_eq!(config.schedules.len(), 1);
600 assert!(config.schedules[0].scheduled_at > Utc::now().timestamp());
601 }
602
603 #[test]
606 fn test_add_header_and_text() {
607 let result = add_header_and_text("existing", "#### Header", "some text");
608 assert!(result.contains("existing"));
609 assert!(result.contains("#### Header"));
610 assert!(result.contains("some text"));
611 }
612
613 #[test]
614 fn test_add_header_and_text_empty_existing() {
615 let result = add_header_and_text("", "#### Header", "some text");
616 assert!(result.starts_with("#### Header"));
617 }
618}