1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use chrono::Utc;
5use serde::Serialize;
6
7use crate::config;
8use crate::error::{Error, Result};
9use crate::graph::Graph;
10use crate::store;
11use crate::task::{self, Priority, Status, Task, TaskType};
12
13#[allow(clippy::too_many_arguments)]
15pub fn create_task(
16 base: &Path,
17 tasks: &HashMap<String, Task>,
18 title: String,
19 priority: Priority,
20 tags: Vec<String>,
21 depends_on: Vec<String>,
22 parent: Option<String>,
23 body: String,
24 task_type: TaskType,
25) -> Result<Task> {
26 let config = config::load(base)?;
27 let mut existing_ids: HashSet<String> = tasks.keys().cloned().collect();
28 existing_ids.extend(store::archived_id_set(base));
30 let id = task::generate_id(&existing_ids, config.id_length as usize);
31
32 let mut resolved_deps = Vec::with_capacity(depends_on.len());
34 let mut unknown = Vec::new();
35 for dep in depends_on {
36 match store::resolve_prefix(tasks, &dep) {
37 Ok(dep_id) => resolved_deps.push(dep_id),
38 Err(Error::TaskNotFound(_)) => unknown.push(dep),
39 Err(e) => return Err(e),
40 }
41 }
42 if !unknown.is_empty() {
43 return Err(Error::UnknownDependency { ids: unknown });
44 }
45
46 let parent = match parent.filter(|p| !p.is_empty()) {
51 Some(pid) => {
52 let parent_id = store::resolve_prefix(tasks, &pid)?;
53 if !tasks[&parent_id].task_type.is_epic() {
54 return Err(Error::ParentNotEpic(parent_id));
55 }
56 Some(parent_id)
57 }
58 None => None,
59 };
60
61 let mut t = Task::new(id, title, priority);
62 t.task_type = task_type;
63 t.tags = tags;
64 t.depends_on = resolved_deps;
65 t.parent = parent;
66 t.body = body;
67
68 store::save(base, &t)?;
69 Ok(t)
70}
71
72pub fn list_tasks(
74 tasks: &HashMap<String, Task>,
75 status: Option<Status>,
76 priority: Option<Priority>,
77 tag: Option<&str>,
78 include_all: bool,
79 epic: Option<&str>,
80) -> Vec<Task> {
81 let mut filtered: Vec<Task> = tasks
82 .values()
83 .filter(|t| {
84 if status.is_some() || include_all {
85 true
86 } else {
87 task::is_active(t)
88 }
89 })
90 .filter(|t| status.as_ref().is_none_or(|s| t.status == *s))
91 .filter(|t| priority.as_ref().is_none_or(|p| t.priority == *p))
92 .filter(|t| task::matches_tag(t, tag))
93 .filter(|t| epic.is_none_or(|e| t.parent.as_deref() == Some(e)))
94 .cloned()
95 .collect();
96 task::sort_by_priority_owned(&mut filtered);
97 filtered
98}
99
100pub fn list_ready(
102 tasks: &HashMap<String, Task>,
103 tag: Option<&str>,
104 limit: Option<usize>,
105 epic: Option<&str>,
106) -> Vec<Task> {
107 let graph = Graph::build(tasks);
108 let ready = graph.ready(tasks, tag, limit, epic);
109 ready.into_iter().cloned().collect()
110}
111
112pub fn get_task(tasks: &HashMap<String, Task>, id_or_prefix: &str) -> Result<Task> {
114 let id = store::resolve_prefix(tasks, id_or_prefix)?;
115 Ok(tasks[&id].clone())
116}
117
118#[allow(clippy::too_many_arguments)]
125pub fn update_task(
126 base: &Path,
127 tasks: &HashMap<String, Task>,
128 id_or_prefix: &str,
129 status: Option<Status>,
130 priority: Option<Priority>,
131 tags: Option<Vec<String>>,
132 assignee: Option<String>,
133 body: Option<String>,
134 title: Option<String>,
135 parent: Option<Option<String>>,
136) -> Result<Task> {
137 let id = store::resolve_prefix(tasks, id_or_prefix)?;
138 let mut t = tasks[&id].clone();
139
140 let status_changed = status.as_ref().is_some_and(|s| *s != t.status);
141 if let Some(s) = status {
142 t.status = s;
143 }
144 if let Some(p) = priority {
145 t.priority = p;
146 }
147 if let Some(tags) = tags {
148 t.tags = tags;
149 }
150 if let Some(a) = assignee {
151 t.assignee = a;
152 }
153 if let Some(b) = body {
154 t.body = b;
155 }
156 if let Some(title) = title {
157 t.title = title;
158 }
159 if let Some(new_parent) = parent {
161 match new_parent {
162 None => t.parent = None,
163 Some(ref pid) => {
164 t.parent = Some(store::resolve_prefix(tasks, pid)?);
167 }
168 }
169 }
170 t.updated = Utc::now();
171
172 store::save(base, &t)?;
173
174 if status_changed {
176 on_status_changed(base, tasks, &t)?;
177 }
178
179 Ok(t)
180}
181
182pub fn set_status(
184 base: &Path,
185 tasks: &HashMap<String, Task>,
186 id_or_prefix: &str,
187 status: Status,
188) -> Result<Task> {
189 let id = store::resolve_prefix(tasks, id_or_prefix)?;
190 let mut t = tasks[&id].clone();
191 t.status = status;
192 t.updated = Utc::now();
193 store::save(base, &t)?;
194
195 on_status_changed(base, tasks, &t)?;
196
197 Ok(t)
198}
199
200fn on_status_changed(base: &Path, tasks: &HashMap<String, Task>, t: &Task) -> Result<()> {
205 let mut overrides: HashMap<String, Status> = HashMap::new();
209 overrides.insert(t.id.clone(), t.status);
210 maybe_close_parent_epic(base, tasks, t, &mut overrides)
211}
212
213fn effective_status<'a>(task: &'a Task, overrides: &'a HashMap<String, Status>) -> &'a Status {
215 overrides.get(&task.id).unwrap_or(&task.status)
216}
217
218fn maybe_close_parent_epic(
222 base: &Path,
223 tasks: &HashMap<String, Task>,
224 t: &Task,
225 overrides: &mut HashMap<String, Status>,
226) -> Result<()> {
227 let t_status = effective_status(t, overrides);
229 let is_resolved = *t_status == Status::Done || *t_status == Status::Cancelled;
230 if !is_resolved {
231 return Ok(());
232 }
233
234 let Some(ref parent_id) = t.parent else {
235 return Ok(());
236 };
237 let Some(parent) = tasks.get(parent_id) else {
238 return Ok(());
239 };
240 if !parent.task_type.is_epic() {
241 return Ok(());
242 }
243 if *effective_status(parent, overrides) == Status::Done {
245 return Ok(());
246 }
247
248 let children: Vec<_> = tasks
252 .values()
253 .filter(|c| c.parent.as_deref() == Some(parent_id))
254 .collect();
255 let has_children = !children.is_empty();
256 let all_resolved = children.iter().all(|c| {
257 let s = effective_status(c, overrides);
258 *s == Status::Done || *s == Status::Cancelled
259 });
260
261 if has_children && all_resolved {
262 let mut closed_parent = parent.clone();
263 closed_parent.status = Status::Done;
264 closed_parent.updated = Utc::now();
265 store::save(base, &closed_parent)?;
266 overrides.insert(parent_id.clone(), Status::Done);
267
268 maybe_close_parent_epic(base, tasks, parent, overrides)?;
270 }
271
272 Ok(())
273}
274
275pub fn add_dependency(
277 base: &Path,
278 tasks: &HashMap<String, Task>,
279 id_or_prefix: &str,
280 dep_or_prefix: &str,
281) -> Result<Task> {
282 let id = store::resolve_prefix(tasks, id_or_prefix)?;
283 let depends_on = store::resolve_prefix(tasks, dep_or_prefix)?;
284
285 let graph = Graph::build(tasks);
286 if graph.would_cycle(&id, &depends_on) {
287 return Err(Error::CycleDetected {
288 from: id,
289 to: depends_on,
290 });
291 }
292
293 let mut t = tasks[&id].clone();
294 if !t.depends_on.contains(&depends_on) {
295 t.depends_on.push(depends_on);
296 t.updated = Utc::now();
297 store::save(base, &t)?;
298 }
299
300 Ok(t)
301}
302
303pub fn remove_dependency(
305 base: &Path,
306 tasks: &HashMap<String, Task>,
307 id_or_prefix: &str,
308 dep_or_prefix: &str,
309) -> Result<Task> {
310 let id = store::resolve_prefix(tasks, id_or_prefix)?;
311 let depends_on = store::resolve_prefix(tasks, dep_or_prefix)?;
312 let mut t = tasks[&id].clone();
313 t.depends_on.retain(|d| d != &depends_on);
314 t.updated = Utc::now();
315 store::save(base, &t)?;
316 Ok(t)
317}
318
319pub fn search_tasks(tasks: &HashMap<String, Task>, query: &str, include_all: bool) -> Vec<Task> {
321 let query_lower = query.to_lowercase();
322 let mut results: Vec<Task> = tasks
323 .values()
324 .filter(|t| include_all || task::is_active(t))
325 .filter(|t| {
326 t.title.to_lowercase().contains(&query_lower)
327 || t.body.to_lowercase().contains(&query_lower)
328 || t.tags
329 .iter()
330 .any(|tag| tag.to_lowercase().contains(&query_lower))
331 || t.id.contains(&query_lower)
332 })
333 .cloned()
334 .collect();
335 task::sort_by_priority_owned(&mut results);
336 results
337}
338
339pub fn delete_task(base: &Path, tasks: &HashMap<String, Task>, id_or_prefix: &str) -> Result<Task> {
342 let id = store::resolve_prefix(tasks, id_or_prefix)?;
343 let t = tasks[&id].clone();
344 store::delete(base, &id)?;
345 scrub_references(base, tasks, &HashSet::from([id]))?;
346 Ok(t)
347}
348
349pub fn prune_tasks(
352 base: &Path,
353 tasks: &HashMap<String, Task>,
354 include_done: bool,
355) -> Result<Vec<Task>> {
356 let to_delete: Vec<Task> = tasks
357 .values()
358 .filter(|t| t.status == Status::Cancelled || (include_done && t.status == Status::Done))
359 .cloned()
360 .collect();
361
362 for t in &to_delete {
363 store::delete(base, &t.id)?;
364 }
365 let deleted_ids: HashSet<String> = to_delete.iter().map(|t| t.id.clone()).collect();
366 scrub_references(base, tasks, &deleted_ids)?;
367 Ok(to_delete)
368}
369
370fn scrub_references(
377 base: &Path,
378 tasks: &HashMap<String, Task>,
379 deleted: &HashSet<String>,
380) -> Result<()> {
381 for t in tasks.values() {
382 if deleted.contains(&t.id) {
383 continue;
384 }
385 let dangling_dep = t.depends_on.iter().any(|d| deleted.contains(d));
386 let dangling_parent = t.parent.as_ref().is_some_and(|p| deleted.contains(p));
387 if dangling_dep || dangling_parent {
388 let mut t = t.clone();
389 t.depends_on.retain(|d| !deleted.contains(d));
390 if dangling_parent {
391 t.parent = None;
392 }
393 t.updated = Utc::now();
394 store::save(base, &t)?;
395 }
396 }
397 Ok(())
398}
399
400pub fn build_graph(tasks: &HashMap<String, Task>) -> Graph {
402 Graph::build(tasks)
403}
404
405#[cfg_attr(not(test), allow(dead_code))]
419pub fn is_archivable(task: &Task, tasks: &HashMap<String, Task>) -> bool {
420 let settled = task.status == Status::Done || task.status == Status::Cancelled;
421 if !settled {
422 return false;
423 }
424 let graph = Graph::build(tasks);
426 active_blockers(&task.id, tasks, &graph).is_empty()
427}
428
429fn active_blockers(id: &str, tasks: &HashMap<String, Task>, graph: &Graph) -> Vec<String> {
431 graph
432 .reverse
433 .get(id)
434 .into_iter()
435 .flat_map(|s| s.iter())
436 .filter(|dep_id| {
437 tasks
438 .get(dep_id.as_str())
439 .is_some_and(|t| t.status != Status::Done && t.status != Status::Cancelled)
440 })
441 .cloned()
442 .collect()
443}
444
445pub fn archive_task(
457 base: &Path,
458 tasks: &HashMap<String, Task>,
459 id_or_prefix: &str,
460) -> Result<Vec<String>> {
461 let id = store::resolve_prefix(tasks, id_or_prefix)?;
462 let task = &tasks[&id];
463 let graph = Graph::build(tasks);
464
465 let blockers = active_blockers(&id, tasks, &graph);
467 if !blockers.is_empty() {
468 return Err(Error::NotArchivable {
469 id: id.clone(),
470 blockers,
471 });
472 }
473 if task.status != Status::Done && task.status != Status::Cancelled {
474 return Err(Error::NotArchivable {
475 id: id.clone(),
476 blockers: vec![],
477 });
478 }
479
480 let mut to_archive: Vec<String> = vec![id.clone()];
482
483 if task.task_type.is_epic() {
484 let settled_children: Vec<String> = tasks
485 .values()
486 .filter(|c| {
487 c.parent.as_deref() == Some(id.as_str())
488 && (c.status == Status::Done || c.status == Status::Cancelled)
489 })
490 .map(|c| c.id.clone())
491 .collect();
492 to_archive.extend(settled_children);
493 }
494
495 for tid in &to_archive {
497 store::move_to_archive(base, tid)?;
498 }
499
500 Ok(to_archive)
501}
502
503pub fn archive_all(base: &Path, tasks: &HashMap<String, Task>) -> Result<Vec<String>> {
513 let mut remaining: HashMap<String, Task> = tasks.clone();
514 let mut total_archived: Vec<String> = Vec::new();
515
516 loop {
517 let graph = Graph::build(&remaining);
518 let mut batch: Vec<String> = remaining
519 .values()
520 .filter(|t| {
521 (t.status == Status::Done || t.status == Status::Cancelled)
522 && active_blockers(&t.id, &remaining, &graph).is_empty()
523 })
524 .map(|t| t.id.clone())
525 .collect();
526
527 if batch.is_empty() {
528 break;
529 }
530
531 batch.sort(); for id in &batch {
533 store::move_to_archive(base, id)?;
534 remaining.remove(id);
535 }
536 total_archived.extend(batch);
537 }
538
539 Ok(total_archived)
540}
541
542pub async fn restore_task(base: &Path, id_or_prefix: &str) -> Result<Vec<String>> {
549 let archived = store::load_archived(base).await?;
550
551 let id = store::resolve_prefix(&archived, id_or_prefix)
552 .map_err(|_| Error::NotArchived(id_or_prefix.to_string()))?;
553
554 let mut to_restore: Vec<String> = Vec::new();
556 let mut visited: HashSet<String> = HashSet::new();
557 let mut queue: Vec<String> = vec![id.clone()];
558
559 while let Some(current) = queue.pop() {
560 if !visited.insert(current.clone()) {
561 continue;
562 }
563 to_restore.push(current.clone());
564
565 if let Some(task) = archived.get(¤t) {
566 if let Some(ref parent_id) = task.parent
568 && archived.contains_key(parent_id)
569 && !visited.contains(parent_id)
570 {
571 queue.push(parent_id.clone());
572 }
573 for dep_id in &task.depends_on {
575 if archived.contains_key(dep_id) && !visited.contains(dep_id) {
576 queue.push(dep_id.clone());
577 }
578 }
579 }
580 }
581
582 for tid in &to_restore {
583 store::move_from_archive(base, tid)?;
584 }
585
586 Ok(to_restore)
587}
588
589pub async fn get_archived_task(base: &Path, id_or_prefix: &str) -> Result<Task> {
591 let archived = store::load_archived(base).await?;
592 let id = store::resolve_prefix(&archived, id_or_prefix)
593 .map_err(|_| Error::NotArchived(id_or_prefix.to_string()))?;
594 Ok(archived[&id].clone())
595}
596
597pub async fn list_archive(base: &Path, limit: Option<usize>) -> Result<Vec<Task>> {
601 let archived = store::load_archived(base).await?;
602 let mut tasks: Vec<Task> = archived.into_values().collect();
603 tasks.sort_by(|a, b| b.updated.cmp(&a.updated).then(a.id.cmp(&b.id)));
605 if let Some(n) = limit {
606 tasks.truncate(n);
607 }
608 Ok(tasks)
609}
610
611pub fn effective_priorities(tasks: &HashMap<String, Task>) -> HashMap<String, Priority> {
613 Graph::build(tasks).effective_priorities_all(tasks)
614}
615
616#[derive(Debug, Clone, Serialize)]
618pub struct EpicProgress {
619 pub done: usize,
620 pub total: usize,
621}
622
623#[derive(Debug, Serialize)]
625pub struct EpicSummary {
626 pub id: String,
627 pub title: String,
628 pub status: Status,
629 pub priority: Priority,
630 pub tags: Vec<String>,
631 pub progress: EpicProgress,
632}
633
634pub fn epic_progress(tasks: &HashMap<String, Task>, epic_id: &str) -> EpicProgress {
643 let mut done = 0;
644 let mut total = 0;
645 for t in tasks.values() {
646 if t.parent.as_deref() == Some(epic_id) {
647 if t.status == Status::Cancelled {
648 continue;
650 }
651 total += 1;
652 if t.status == Status::Done {
653 done += 1;
654 }
655 }
656 }
657 EpicProgress { done, total }
658}
659
660pub struct EpicPlan<'a> {
662 pub tasks: Vec<&'a Task>,
664 pub cyclic: Vec<&'a Task>,
666}
667
668pub fn plan_epic<'a>(tasks: &'a HashMap<String, Task>, parent_id: &str) -> Result<EpicPlan<'a>> {
671 let resolved = store::resolve_prefix(tasks, parent_id)?;
673 let parent = tasks
674 .get(&resolved)
675 .ok_or_else(|| Error::TaskNotFound(parent_id.to_string()))?;
676 if !parent.task_type.is_epic() {
677 return Err(Error::NotAnEpic(resolved));
678 }
679
680 let child_ids: HashSet<String> = tasks
682 .values()
683 .filter(|t| t.parent.as_deref() == Some(resolved.as_str()))
684 .map(|t| t.id.clone())
685 .collect();
686
687 let graph = Graph::build(tasks);
688 let topo = graph.topo_sort_subset(&child_ids, tasks);
689 Ok(EpicPlan {
690 tasks: topo.sorted,
691 cyclic: topo.cyclic,
692 })
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 fn make_task(id: &str, status: Status) -> Task {
700 let mut t = Task::new(id.to_string(), format!("Task {id}"), Priority::P2);
701 t.status = status;
702 t
703 }
704
705 fn make_epic(id: &str) -> Task {
706 let mut t = Task::new(id.to_string(), format!("Epic {id}"), Priority::P1);
707 t.task_type = TaskType::Epic;
708 t
709 }
710
711 fn make_child(id: &str, parent: &str, status: Status) -> Task {
712 let mut t = make_task(id, status);
713 t.parent = Some(parent.to_string());
714 t
715 }
716
717 fn task_map(tasks: Vec<Task>) -> HashMap<String, Task> {
718 tasks.into_iter().map(|t| (t.id.clone(), t)).collect()
719 }
720
721 #[tokio::test]
722 async fn test_delete_scrubs_dangling_references() {
723 let tmp = tempfile::TempDir::new().unwrap();
724 store::init(tmp.path()).unwrap();
725
726 let mut a = make_epic("aaa");
728 a.id = "aaa".into();
729 store::save(tmp.path(), &a).unwrap();
730 let mut b = make_task("bbb", Status::Open);
731 b.depends_on = vec!["aaa".into()];
732 store::save(tmp.path(), &b).unwrap();
733 let c = make_child("ccc", "aaa", Status::Open);
734 store::save(tmp.path(), &c).unwrap();
735
736 let tasks = store::load_all(tmp.path()).await.unwrap();
737 delete_task(tmp.path(), &tasks, "aaa").unwrap();
738
739 let tasks = store::load_all(tmp.path()).await.unwrap();
740 assert!(tasks["bbb"].depends_on.is_empty(), "dep should be scrubbed");
741 assert_eq!(tasks["ccc"].parent, None, "parent should be cleared");
742 let ready = list_ready(&tasks, None, None, None);
744 assert!(ready.iter().any(|t| t.id == "bbb"));
745 }
746
747 #[tokio::test]
748 async fn test_prune_scrubs_dangling_references() {
749 let tmp = tempfile::TempDir::new().unwrap();
750 store::init(tmp.path()).unwrap();
751
752 let a = make_task("aaa", Status::Cancelled);
753 store::save(tmp.path(), &a).unwrap();
754 let mut b = make_task("bbb", Status::Open);
755 b.depends_on = vec!["aaa".into()];
756 store::save(tmp.path(), &b).unwrap();
757
758 let tasks = store::load_all(tmp.path()).await.unwrap();
759 prune_tasks(tmp.path(), &tasks, false).unwrap();
760
761 let tasks = store::load_all(tmp.path()).await.unwrap();
762 assert!(tasks["bbb"].depends_on.is_empty(), "dep should be scrubbed");
763 }
764
765 #[tokio::test]
766 async fn test_create_task_rejects_unknown_parent() {
767 let tmp = tempfile::TempDir::new().unwrap();
768 store::init(tmp.path()).unwrap();
769
770 let result = create_task(
771 tmp.path(),
772 &HashMap::new(),
773 "Orphan".into(),
774 Priority::P2,
775 vec![],
776 vec![],
777 Some("zzzz".into()),
778 String::new(),
779 TaskType::Task,
780 );
781 assert!(matches!(result, Err(Error::TaskNotFound(_))));
782 }
783
784 #[tokio::test]
785 async fn test_create_task_rejects_non_epic_parent() {
786 let tmp = tempfile::TempDir::new().unwrap();
787 store::init(tmp.path()).unwrap();
788
789 let plain = make_task("ppp", Status::Open);
790 store::save(tmp.path(), &plain).unwrap();
791
792 let tasks = store::load_all(tmp.path()).await.unwrap();
793 let result = create_task(
794 tmp.path(),
795 &tasks,
796 "Child".into(),
797 Priority::P2,
798 vec![],
799 vec![],
800 Some("ppp".into()),
801 String::new(),
802 TaskType::Task,
803 );
804 assert!(matches!(result, Err(Error::ParentNotEpic(_))));
805 }
806
807 #[tokio::test]
808 async fn test_create_task_resolves_dep_prefixes() {
809 let tmp = tempfile::TempDir::new().unwrap();
810 store::init(tmp.path()).unwrap();
811
812 let dep = Task::new("abcd".into(), "Dep".into(), Priority::P2);
813 store::save(tmp.path(), &dep).unwrap();
814
815 let tasks = store::load_all(tmp.path()).await.unwrap();
816 let t = create_task(
817 tmp.path(),
818 &tasks,
819 "Uses prefixes".into(),
820 Priority::P2,
821 vec![],
822 vec!["ab".into()],
823 None,
824 String::new(),
825 TaskType::Task,
826 )
827 .unwrap();
828 assert_eq!(t.depends_on, vec!["abcd"]);
829 }
830
831 #[test]
832 fn test_epic_progress_no_children() {
833 let tasks = task_map(vec![make_epic("e1")]);
834 let p = epic_progress(&tasks, "e1");
835 assert_eq!(p.done, 0);
836 assert_eq!(p.total, 0);
837 }
838
839 #[test]
840 fn test_epic_progress_mixed() {
841 let tasks = task_map(vec![
842 make_epic("e1"),
843 make_child("c1", "e1", Status::Done),
844 make_child("c2", "e1", Status::Open),
845 make_child("c3", "e1", Status::InProgress),
846 ]);
847 let p = epic_progress(&tasks, "e1");
848 assert_eq!(p.done, 1);
849 assert_eq!(p.total, 3);
850 }
851
852 #[test]
853 fn test_epic_progress_all_done() {
854 let tasks = task_map(vec![
855 make_epic("e1"),
856 make_child("c1", "e1", Status::Done),
857 make_child("c2", "e1", Status::Done),
858 ]);
859 let p = epic_progress(&tasks, "e1");
860 assert_eq!(p.done, 2);
861 assert_eq!(p.total, 2);
862 }
863
864 #[test]
865 fn test_epic_progress_cancelled_excluded_from_total() {
866 let tasks = task_map(vec![
869 make_epic("e1"),
870 make_child("c1", "e1", Status::Done),
871 make_child("c2", "e1", Status::Cancelled),
872 ]);
873 let p = epic_progress(&tasks, "e1");
874 assert_eq!(p.done, 1);
875 assert_eq!(p.total, 1); }
877
878 #[test]
879 fn test_epic_progress_mixed_with_cancelled() {
880 let tasks = task_map(vec![
881 make_epic("e1"),
882 make_child("c1", "e1", Status::Done),
883 make_child("c2", "e1", Status::Open),
884 make_child("c3", "e1", Status::Cancelled),
885 ]);
886 let p = epic_progress(&tasks, "e1");
887 assert_eq!(p.done, 1);
888 assert_eq!(p.total, 2); }
890
891 #[tokio::test]
892 async fn test_epic_auto_close_with_done_and_cancelled() {
893 let tmp = tempfile::TempDir::new().unwrap();
894 store::init(tmp.path()).unwrap();
895
896 let tasks = HashMap::new();
897 let epic = create_task(
898 tmp.path(),
899 &tasks,
900 "My Epic".into(),
901 Priority::P1,
902 vec![],
903 vec![],
904 None,
905 String::new(),
906 TaskType::Epic,
907 )
908 .unwrap();
909
910 let tasks = store::load_all(tmp.path()).await.unwrap();
911 let child1 = create_task(
912 tmp.path(),
913 &tasks,
914 "Child 1".into(),
915 Priority::P2,
916 vec![],
917 vec![],
918 Some(epic.id.clone()),
919 String::new(),
920 TaskType::Task,
921 )
922 .unwrap();
923
924 let tasks = store::load_all(tmp.path()).await.unwrap();
925 let child2 = create_task(
926 tmp.path(),
927 &tasks,
928 "Child 2".into(),
929 Priority::P2,
930 vec![],
931 vec![],
932 Some(epic.id.clone()),
933 String::new(),
934 TaskType::Task,
935 )
936 .unwrap();
937
938 let tasks = store::load_all(tmp.path()).await.unwrap();
940 set_status(tmp.path(), &tasks, &child1.id, Status::Done).unwrap();
941 let tasks = store::load_all(tmp.path()).await.unwrap();
942 assert_eq!(tasks[&epic.id].status, Status::Open);
943
944 set_status(tmp.path(), &tasks, &child2.id, Status::Cancelled).unwrap();
946 let tasks = store::load_all(tmp.path()).await.unwrap();
947 assert_eq!(
948 tasks[&epic.id].status,
949 Status::Done,
950 "epic should auto-close when children are [done, cancelled]"
951 );
952 }
953
954 #[tokio::test]
955 async fn test_epic_auto_close_cancel_last_open_child() {
956 let tmp = tempfile::TempDir::new().unwrap();
957 store::init(tmp.path()).unwrap();
958
959 let tasks = HashMap::new();
960 let epic = create_task(
961 tmp.path(),
962 &tasks,
963 "My Epic".into(),
964 Priority::P1,
965 vec![],
966 vec![],
967 None,
968 String::new(),
969 TaskType::Epic,
970 )
971 .unwrap();
972
973 let tasks = store::load_all(tmp.path()).await.unwrap();
974 let child1 = create_task(
975 tmp.path(),
976 &tasks,
977 "Child 1".into(),
978 Priority::P2,
979 vec![],
980 vec![],
981 Some(epic.id.clone()),
982 String::new(),
983 TaskType::Task,
984 )
985 .unwrap();
986
987 let tasks = store::load_all(tmp.path()).await.unwrap();
989 set_status(tmp.path(), &tasks, &child1.id, Status::Cancelled).unwrap();
990 let tasks = store::load_all(tmp.path()).await.unwrap();
991 assert_eq!(
992 tasks[&epic.id].status,
993 Status::Done,
994 "epic should auto-close when cancelling the last open child"
995 );
996 }
997
998 #[tokio::test]
999 async fn test_epic_auto_close() {
1000 let tmp = tempfile::TempDir::new().unwrap();
1001 store::init(tmp.path()).unwrap();
1002
1003 let tasks = HashMap::new();
1004 let epic = create_task(
1005 tmp.path(),
1006 &tasks,
1007 "My Epic".into(),
1008 Priority::P1,
1009 vec![],
1010 vec![],
1011 None,
1012 String::new(),
1013 TaskType::Epic,
1014 )
1015 .unwrap();
1016
1017 let tasks = store::load_all(tmp.path()).await.unwrap();
1018 let child1 = create_task(
1019 tmp.path(),
1020 &tasks,
1021 "Child 1".into(),
1022 Priority::P2,
1023 vec![],
1024 vec![],
1025 Some(epic.id.clone()),
1026 String::new(),
1027 TaskType::Task,
1028 )
1029 .unwrap();
1030
1031 let tasks = store::load_all(tmp.path()).await.unwrap();
1032 let child2 = create_task(
1033 tmp.path(),
1034 &tasks,
1035 "Child 2".into(),
1036 Priority::P2,
1037 vec![],
1038 vec![],
1039 Some(epic.id.clone()),
1040 String::new(),
1041 TaskType::Task,
1042 )
1043 .unwrap();
1044
1045 let tasks = store::load_all(tmp.path()).await.unwrap();
1047 set_status(tmp.path(), &tasks, &child1.id, Status::Done).unwrap();
1048 let tasks = store::load_all(tmp.path()).await.unwrap();
1049 assert_eq!(tasks[&epic.id].status, Status::Open);
1050
1051 set_status(tmp.path(), &tasks, &child2.id, Status::Done).unwrap();
1053 let tasks = store::load_all(tmp.path()).await.unwrap();
1054 assert_eq!(tasks[&epic.id].status, Status::Done);
1055 }
1056
1057 #[tokio::test]
1058 async fn test_epic_auto_close_via_update_task() {
1059 let tmp = tempfile::TempDir::new().unwrap();
1060 store::init(tmp.path()).unwrap();
1061
1062 let tasks = HashMap::new();
1063 let epic = create_task(
1064 tmp.path(),
1065 &tasks,
1066 "My Epic".into(),
1067 Priority::P1,
1068 vec![],
1069 vec![],
1070 None,
1071 String::new(),
1072 TaskType::Epic,
1073 )
1074 .unwrap();
1075
1076 let tasks = store::load_all(tmp.path()).await.unwrap();
1077 let child1 = create_task(
1078 tmp.path(),
1079 &tasks,
1080 "Child 1".into(),
1081 Priority::P2,
1082 vec![],
1083 vec![],
1084 Some(epic.id.clone()),
1085 String::new(),
1086 TaskType::Task,
1087 )
1088 .unwrap();
1089
1090 let tasks = store::load_all(tmp.path()).await.unwrap();
1091 let child2 = create_task(
1092 tmp.path(),
1093 &tasks,
1094 "Child 2".into(),
1095 Priority::P2,
1096 vec![],
1097 vec![],
1098 Some(epic.id.clone()),
1099 String::new(),
1100 TaskType::Task,
1101 )
1102 .unwrap();
1103
1104 let tasks = store::load_all(tmp.path()).await.unwrap();
1106 update_task(
1107 tmp.path(),
1108 &tasks,
1109 &child1.id,
1110 Some(Status::Done),
1111 None,
1112 None,
1113 None,
1114 None,
1115 None,
1116 None,
1117 )
1118 .unwrap();
1119 let tasks = store::load_all(tmp.path()).await.unwrap();
1120 assert_eq!(tasks[&epic.id].status, Status::Open);
1121
1122 update_task(
1124 tmp.path(),
1125 &tasks,
1126 &child2.id,
1127 Some(Status::Done),
1128 None,
1129 None,
1130 None,
1131 None,
1132 None,
1133 None,
1134 )
1135 .unwrap();
1136 let tasks = store::load_all(tmp.path()).await.unwrap();
1137 assert_eq!(tasks[&epic.id].status, Status::Done);
1138 }
1139
1140 #[tokio::test]
1141 async fn test_epic_no_over_close_on_re_complete() {
1142 let tmp = tempfile::TempDir::new().unwrap();
1145 store::init(tmp.path()).unwrap();
1146
1147 let tasks = HashMap::new();
1148 let epic = create_task(
1149 tmp.path(),
1150 &tasks,
1151 "My Epic".into(),
1152 Priority::P1,
1153 vec![],
1154 vec![],
1155 None,
1156 String::new(),
1157 TaskType::Epic,
1158 )
1159 .unwrap();
1160
1161 let tasks = store::load_all(tmp.path()).await.unwrap();
1162 let child1 = create_task(
1163 tmp.path(),
1164 &tasks,
1165 "Child 1".into(),
1166 Priority::P2,
1167 vec![],
1168 vec![],
1169 Some(epic.id.clone()),
1170 String::new(),
1171 TaskType::Task,
1172 )
1173 .unwrap();
1174
1175 let tasks = store::load_all(tmp.path()).await.unwrap();
1176 let _child2 = create_task(
1177 tmp.path(),
1178 &tasks,
1179 "Child 2".into(),
1180 Priority::P2,
1181 vec![],
1182 vec![],
1183 Some(epic.id.clone()),
1184 String::new(),
1185 TaskType::Task,
1186 )
1187 .unwrap();
1188
1189 let tasks = store::load_all(tmp.path()).await.unwrap();
1191 set_status(tmp.path(), &tasks, &child1.id, Status::Done).unwrap();
1192 let tasks = store::load_all(tmp.path()).await.unwrap();
1193 assert_eq!(
1194 tasks[&epic.id].status,
1195 Status::Open,
1196 "epic should stay open"
1197 );
1198
1199 set_status(tmp.path(), &tasks, &child1.id, Status::Done).unwrap();
1201 let tasks = store::load_all(tmp.path()).await.unwrap();
1202 assert_eq!(
1203 tasks[&epic.id].status,
1204 Status::Open,
1205 "epic must not close when re-completing an already-done child while another is open"
1206 );
1207 }
1208
1209 #[tokio::test]
1210 async fn test_epic_cascade_auto_close_nested() {
1211 let tmp = tempfile::TempDir::new().unwrap();
1219 store::init(tmp.path()).unwrap();
1220
1221 let tasks = HashMap::new();
1222 let outer = create_task(
1223 tmp.path(),
1224 &tasks,
1225 "Outer Epic".into(),
1226 Priority::P1,
1227 vec![],
1228 vec![],
1229 None,
1230 String::new(),
1231 TaskType::Epic,
1232 )
1233 .unwrap();
1234
1235 let tasks = store::load_all(tmp.path()).await.unwrap();
1236 let inner = create_task(
1237 tmp.path(),
1238 &tasks,
1239 "Inner Epic".into(),
1240 Priority::P1,
1241 vec![],
1242 vec![],
1243 Some(outer.id.clone()),
1244 String::new(),
1245 TaskType::Epic,
1246 )
1247 .unwrap();
1248
1249 let tasks = store::load_all(tmp.path()).await.unwrap();
1250 let leaf1 = create_task(
1251 tmp.path(),
1252 &tasks,
1253 "Leaf 1".into(),
1254 Priority::P2,
1255 vec![],
1256 vec![],
1257 Some(inner.id.clone()),
1258 String::new(),
1259 TaskType::Task,
1260 )
1261 .unwrap();
1262
1263 let tasks = store::load_all(tmp.path()).await.unwrap();
1264 let leaf2 = create_task(
1265 tmp.path(),
1266 &tasks,
1267 "Leaf 2".into(),
1268 Priority::P2,
1269 vec![],
1270 vec![],
1271 Some(inner.id.clone()),
1272 String::new(),
1273 TaskType::Task,
1274 )
1275 .unwrap();
1276
1277 let tasks = store::load_all(tmp.path()).await.unwrap();
1279 set_status(tmp.path(), &tasks, &leaf1.id, Status::Done).unwrap();
1280 let tasks = store::load_all(tmp.path()).await.unwrap();
1281 assert_eq!(
1282 tasks[&inner.id].status,
1283 Status::Open,
1284 "inner should stay open"
1285 );
1286 assert_eq!(
1287 tasks[&outer.id].status,
1288 Status::Open,
1289 "outer should stay open"
1290 );
1291
1292 set_status(tmp.path(), &tasks, &leaf2.id, Status::Done).unwrap();
1294 let tasks = store::load_all(tmp.path()).await.unwrap();
1295 assert_eq!(
1296 tasks[&inner.id].status,
1297 Status::Done,
1298 "inner epic should auto-close when all its children are done"
1299 );
1300 assert_eq!(
1301 tasks[&outer.id].status,
1302 Status::Done,
1303 "outer epic should cascade-close when inner epic closes"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_plan_epic_linear_chain() {
1309 let mut c1 = make_child("c1", "e1", Status::Open);
1310 c1.depends_on = vec![];
1311 let mut c2 = make_child("c2", "e1", Status::Open);
1312 c2.depends_on = vec!["c1".to_string()];
1313 let mut c3 = make_child("c3", "e1", Status::Open);
1314 c3.depends_on = vec!["c2".to_string()];
1315
1316 let tasks = task_map(vec![make_epic("e1"), c1, c2, c3]);
1317 let plan = plan_epic(&tasks, "e1").unwrap();
1318 let ids: Vec<&str> = plan.tasks.iter().map(|t| t.id.as_str()).collect();
1319 assert_eq!(ids, vec!["c1", "c2", "c3"]);
1320 assert!(plan.cyclic.is_empty());
1321 }
1322
1323 #[test]
1324 fn test_plan_epic_independent_children() {
1325 let tasks = task_map(vec![
1326 make_epic("e1"),
1327 make_child("c1", "e1", Status::Open),
1328 make_child("c2", "e1", Status::Open),
1329 ]);
1330 let plan = plan_epic(&tasks, "e1").unwrap();
1331 assert_eq!(plan.tasks.len(), 2);
1332 }
1333
1334 #[test]
1335 fn test_plan_epic_no_children() {
1336 let tasks = task_map(vec![make_epic("e1")]);
1337 let plan = plan_epic(&tasks, "e1").unwrap();
1338 assert!(plan.tasks.is_empty());
1339 assert!(plan.cyclic.is_empty());
1340 }
1341
1342 #[test]
1343 fn test_plan_epic_not_found() {
1344 let tasks = task_map(vec![]);
1345 let result = plan_epic(&tasks, "nonexistent");
1346 assert!(result.is_err());
1347 }
1348
1349 #[test]
1350 fn test_plan_epic_non_epic_parent() {
1351 let parent = make_task("p1", Status::Open);
1353 let tasks = task_map(vec![
1354 parent,
1355 make_child("c1", "p1", Status::Open),
1356 make_child("c2", "p1", Status::Done),
1357 ]);
1358 let result = plan_epic(&tasks, "p1");
1359 assert!(result.is_err());
1360 }
1361
1362 #[tokio::test]
1363 async fn test_parent_prefix_stored_as_canonical_id() {
1364 let tmp = tempfile::TempDir::new().unwrap();
1367 store::init(tmp.path()).unwrap();
1368
1369 let mut epic = Task::new("epicid".into(), "Epic".into(), Priority::P1);
1370 epic.task_type = TaskType::Epic;
1371 store::save(tmp.path(), &epic).unwrap();
1372 let child = Task::new("chld".into(), "Existing child".into(), Priority::P2);
1373 store::save(tmp.path(), &child).unwrap();
1374
1375 let tasks = store::load_all(tmp.path()).await.unwrap();
1377 let updated = update_task(
1378 tmp.path(),
1379 &tasks,
1380 "chld",
1381 None,
1382 None,
1383 None,
1384 None,
1385 None,
1386 None,
1387 Some(Some("epi".into())),
1388 )
1389 .unwrap();
1390 assert_eq!(
1391 updated.parent.as_deref(),
1392 Some("epicid"),
1393 "update_task should store the resolved full parent id, not the prefix"
1394 );
1395
1396 let tasks = store::load_all(tmp.path()).await.unwrap();
1398 let created = create_task(
1399 tmp.path(),
1400 &tasks,
1401 "New child".into(),
1402 Priority::P2,
1403 vec![],
1404 vec![],
1405 Some("epi".into()),
1406 String::new(),
1407 TaskType::Task,
1408 )
1409 .unwrap();
1410 assert_eq!(
1411 created.parent.as_deref(),
1412 Some("epicid"),
1413 "create_task should store the resolved full parent id, not the prefix"
1414 );
1415
1416 let tasks = store::load_all(tmp.path()).await.unwrap();
1418 assert_eq!(epic_progress(&tasks, "epicid").total, 2);
1419 }
1420
1421 #[test]
1424 fn test_is_archivable_done_no_dependents() {
1425 let t = make_task("t1", Status::Done);
1426 let tasks = task_map(vec![t.clone()]);
1427 assert!(is_archivable(&t, &tasks));
1428 }
1429
1430 #[test]
1431 fn test_is_archivable_cancelled_no_dependents() {
1432 let t = make_task("t1", Status::Cancelled);
1433 let tasks = task_map(vec![t.clone()]);
1434 assert!(is_archivable(&t, &tasks));
1435 }
1436
1437 #[test]
1438 fn test_is_archivable_open_is_false() {
1439 let t = make_task("t1", Status::Open);
1440 let tasks = task_map(vec![t.clone()]);
1441 assert!(!is_archivable(&t, &tasks));
1442 }
1443
1444 #[test]
1445 fn test_is_archivable_in_progress_is_false() {
1446 let mut t = make_task("t1", Status::Done);
1447 t.status = Status::InProgress;
1448 let tasks = task_map(vec![t.clone()]);
1449 assert!(!is_archivable(&t, &tasks));
1450 }
1451
1452 #[test]
1453 fn test_is_archivable_done_with_active_dependent_is_false() {
1454 let t1 = make_task("t1", Status::Done);
1456 let mut t2 = make_task("t2", Status::Open);
1457 t2.depends_on = vec!["t1".to_string()];
1458 let tasks = task_map(vec![t1.clone(), t2]);
1459 assert!(!is_archivable(&t1, &tasks));
1460 }
1461
1462 #[test]
1463 fn test_is_archivable_done_dependent_is_ok() {
1464 let t1 = make_task("t1", Status::Done);
1466 let mut t2 = make_task("t2", Status::Done);
1467 t2.depends_on = vec!["t1".to_string()];
1468 let tasks = task_map(vec![t1.clone(), t2]);
1469 assert!(is_archivable(&t1, &tasks));
1470 }
1471
1472 #[tokio::test]
1473 async fn test_archive_task_basic() {
1474 let tmp = tempfile::TempDir::new().unwrap();
1475 store::init(tmp.path()).unwrap();
1476
1477 let tasks = HashMap::new();
1478 let t = create_task(
1479 tmp.path(),
1480 &tasks,
1481 "Done task".into(),
1482 Priority::P2,
1483 vec![],
1484 vec![],
1485 None,
1486 String::new(),
1487 TaskType::Task,
1488 )
1489 .unwrap();
1490
1491 let tasks = store::load_all(tmp.path()).await.unwrap();
1492 set_status(tmp.path(), &tasks, &t.id, Status::Done).unwrap();
1493 let tasks = store::load_all(tmp.path()).await.unwrap();
1494
1495 let archived = archive_task(tmp.path(), &tasks, &t.id).unwrap();
1496 assert_eq!(archived.len(), 1);
1497 assert_eq!(archived[0], t.id);
1498
1499 let active = store::load_all(tmp.path()).await.unwrap();
1501 assert!(!active.contains_key(&t.id));
1502
1503 let arch = store::load_archived(tmp.path()).await.unwrap();
1505 assert!(arch.contains_key(&t.id));
1506 }
1507
1508 #[tokio::test]
1509 async fn test_archive_task_blocked_by_active_dependent() {
1510 let tmp = tempfile::TempDir::new().unwrap();
1511 store::init(tmp.path()).unwrap();
1512
1513 let tasks = HashMap::new();
1514 let dep = create_task(
1515 tmp.path(),
1516 &tasks,
1517 "Dep task".into(),
1518 Priority::P2,
1519 vec![],
1520 vec![],
1521 None,
1522 String::new(),
1523 TaskType::Task,
1524 )
1525 .unwrap();
1526
1527 let tasks = store::load_all(tmp.path()).await.unwrap();
1528 let _dependent = create_task(
1530 tmp.path(),
1531 &tasks,
1532 "Dependent".into(),
1533 Priority::P2,
1534 vec![],
1535 vec![dep.id.clone()],
1536 None,
1537 String::new(),
1538 TaskType::Task,
1539 )
1540 .unwrap();
1541
1542 let tasks = store::load_all(tmp.path()).await.unwrap();
1544 set_status(tmp.path(), &tasks, &dep.id, Status::Done).unwrap();
1545 let tasks = store::load_all(tmp.path()).await.unwrap();
1546
1547 let result = archive_task(tmp.path(), &tasks, &dep.id);
1548 assert!(
1549 matches!(result, Err(Error::NotArchivable { .. })),
1550 "should fail with NotArchivable"
1551 );
1552 }
1553
1554 #[tokio::test]
1555 async fn test_archive_task_open_is_rejected() {
1556 let tmp = tempfile::TempDir::new().unwrap();
1557 store::init(tmp.path()).unwrap();
1558
1559 let tasks = HashMap::new();
1560 let t = create_task(
1561 tmp.path(),
1562 &tasks,
1563 "Open task".into(),
1564 Priority::P2,
1565 vec![],
1566 vec![],
1567 None,
1568 String::new(),
1569 TaskType::Task,
1570 )
1571 .unwrap();
1572
1573 let tasks = store::load_all(tmp.path()).await.unwrap();
1574 let result = archive_task(tmp.path(), &tasks, &t.id);
1575 assert!(
1576 matches!(result, Err(Error::NotArchivable { .. })),
1577 "open task should not be archivable"
1578 );
1579 }
1580
1581 #[tokio::test]
1582 async fn test_archive_task_epic_cascades_to_settled_children() {
1583 let tmp = tempfile::TempDir::new().unwrap();
1584 store::init(tmp.path()).unwrap();
1585
1586 let tasks = HashMap::new();
1587 let epic = create_task(
1588 tmp.path(),
1589 &tasks,
1590 "Epic".into(),
1591 Priority::P1,
1592 vec![],
1593 vec![],
1594 None,
1595 String::new(),
1596 TaskType::Epic,
1597 )
1598 .unwrap();
1599
1600 let tasks = store::load_all(tmp.path()).await.unwrap();
1601 let c1 = create_task(
1602 tmp.path(),
1603 &tasks,
1604 "Child 1".into(),
1605 Priority::P2,
1606 vec![],
1607 vec![],
1608 Some(epic.id.clone()),
1609 String::new(),
1610 TaskType::Task,
1611 )
1612 .unwrap();
1613
1614 let tasks = store::load_all(tmp.path()).await.unwrap();
1615 let c2 = create_task(
1616 tmp.path(),
1617 &tasks,
1618 "Child 2".into(),
1619 Priority::P2,
1620 vec![],
1621 vec![],
1622 Some(epic.id.clone()),
1623 String::new(),
1624 TaskType::Task,
1625 )
1626 .unwrap();
1627
1628 let tasks = store::load_all(tmp.path()).await.unwrap();
1630 set_status(tmp.path(), &tasks, &c1.id, Status::Done).unwrap();
1631 let tasks = store::load_all(tmp.path()).await.unwrap();
1632 set_status(tmp.path(), &tasks, &c2.id, Status::Done).unwrap();
1633 let tasks = store::load_all(tmp.path()).await.unwrap();
1634 set_status(tmp.path(), &tasks, &epic.id, Status::Done).unwrap();
1636 let tasks = store::load_all(tmp.path()).await.unwrap();
1637
1638 let mut archived_ids = archive_task(tmp.path(), &tasks, &epic.id).unwrap();
1639 archived_ids.sort();
1640
1641 assert_eq!(archived_ids.len(), 3, "epic + 2 children");
1643 assert!(archived_ids.contains(&epic.id));
1644 assert!(archived_ids.contains(&c1.id));
1645 assert!(archived_ids.contains(&c2.id));
1646
1647 let active = store::load_all(tmp.path()).await.unwrap();
1648 assert!(active.is_empty());
1649 }
1650
1651 #[tokio::test]
1652 async fn test_archive_all_sweep() {
1653 let tmp = tempfile::TempDir::new().unwrap();
1654 store::init(tmp.path()).unwrap();
1655
1656 let tasks = HashMap::new();
1657 let t1 = create_task(
1658 tmp.path(),
1659 &tasks,
1660 "Done 1".into(),
1661 Priority::P2,
1662 vec![],
1663 vec![],
1664 None,
1665 String::new(),
1666 TaskType::Task,
1667 )
1668 .unwrap();
1669
1670 let tasks = store::load_all(tmp.path()).await.unwrap();
1671 let t2 = create_task(
1672 tmp.path(),
1673 &tasks,
1674 "Open".into(),
1675 Priority::P2,
1676 vec![],
1677 vec![],
1678 None,
1679 String::new(),
1680 TaskType::Task,
1681 )
1682 .unwrap();
1683
1684 let tasks = store::load_all(tmp.path()).await.unwrap();
1685 let t3 = create_task(
1686 tmp.path(),
1687 &tasks,
1688 "Done 2".into(),
1689 Priority::P2,
1690 vec![],
1691 vec![],
1692 None,
1693 String::new(),
1694 TaskType::Task,
1695 )
1696 .unwrap();
1697
1698 let tasks = store::load_all(tmp.path()).await.unwrap();
1699 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
1700 let tasks = store::load_all(tmp.path()).await.unwrap();
1701 set_status(tmp.path(), &tasks, &t3.id, Status::Done).unwrap();
1702 let tasks = store::load_all(tmp.path()).await.unwrap();
1703
1704 let archived_ids = archive_all(tmp.path(), &tasks).unwrap();
1705 assert_eq!(archived_ids.len(), 2);
1706 assert!(archived_ids.contains(&t1.id));
1707 assert!(archived_ids.contains(&t3.id));
1708
1709 let active = store::load_all(tmp.path()).await.unwrap();
1710 assert_eq!(active.len(), 1);
1711 assert!(active.contains_key(&t2.id));
1712 }
1713
1714 #[tokio::test]
1715 async fn test_archive_all_sweep_cascades_chain() {
1716 let tmp = tempfile::TempDir::new().unwrap();
1720 store::init(tmp.path()).unwrap();
1721
1722 let tasks = HashMap::new();
1723 let t1 = create_task(
1724 tmp.path(),
1725 &tasks,
1726 "Base done".into(),
1727 Priority::P2,
1728 vec![],
1729 vec![],
1730 None,
1731 String::new(),
1732 TaskType::Task,
1733 )
1734 .unwrap();
1735
1736 let tasks = store::load_all(tmp.path()).await.unwrap();
1737 let t2 = create_task(
1738 tmp.path(),
1739 &tasks,
1740 "Dependent done".into(),
1741 Priority::P2,
1742 vec![],
1743 vec![t1.id.clone()],
1744 None,
1745 String::new(),
1746 TaskType::Task,
1747 )
1748 .unwrap();
1749
1750 let tasks = store::load_all(tmp.path()).await.unwrap();
1751 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
1752 let tasks = store::load_all(tmp.path()).await.unwrap();
1753 set_status(tmp.path(), &tasks, &t2.id, Status::Done).unwrap();
1754 let tasks = store::load_all(tmp.path()).await.unwrap();
1755
1756 let archived_ids = archive_all(tmp.path(), &tasks).unwrap();
1757 assert_eq!(archived_ids.len(), 2, "both should be archived");
1758 assert!(archived_ids.contains(&t1.id));
1759 assert!(archived_ids.contains(&t2.id));
1760 }
1761
1762 #[tokio::test]
1763 async fn test_restore_task_basic() {
1764 let tmp = tempfile::TempDir::new().unwrap();
1765 store::init(tmp.path()).unwrap();
1766
1767 let tasks = HashMap::new();
1768 let t = create_task(
1769 tmp.path(),
1770 &tasks,
1771 "Task to restore".into(),
1772 Priority::P2,
1773 vec![],
1774 vec![],
1775 None,
1776 String::new(),
1777 TaskType::Task,
1778 )
1779 .unwrap();
1780
1781 let tasks = store::load_all(tmp.path()).await.unwrap();
1782 set_status(tmp.path(), &tasks, &t.id, Status::Done).unwrap();
1783 let tasks = store::load_all(tmp.path()).await.unwrap();
1784 archive_task(tmp.path(), &tasks, &t.id).unwrap();
1785
1786 let active = store::load_all(tmp.path()).await.unwrap();
1787 assert!(!active.contains_key(&t.id));
1788
1789 let restored = restore_task(tmp.path(), &t.id).await.unwrap();
1790 assert_eq!(restored.len(), 1);
1791
1792 let active = store::load_all(tmp.path()).await.unwrap();
1793 assert!(active.contains_key(&t.id));
1794 }
1795
1796 #[tokio::test]
1797 async fn test_restore_task_cascades_deps() {
1798 let tmp = tempfile::TempDir::new().unwrap();
1801 store::init(tmp.path()).unwrap();
1802
1803 let tasks = HashMap::new();
1804 let t1 = create_task(
1805 tmp.path(),
1806 &tasks,
1807 "Dep".into(),
1808 Priority::P2,
1809 vec![],
1810 vec![],
1811 None,
1812 String::new(),
1813 TaskType::Task,
1814 )
1815 .unwrap();
1816
1817 let tasks = store::load_all(tmp.path()).await.unwrap();
1818 let t2 = create_task(
1819 tmp.path(),
1820 &tasks,
1821 "Dependent".into(),
1822 Priority::P2,
1823 vec![],
1824 vec![t1.id.clone()],
1825 None,
1826 String::new(),
1827 TaskType::Task,
1828 )
1829 .unwrap();
1830
1831 let tasks = store::load_all(tmp.path()).await.unwrap();
1832 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
1833 let tasks = store::load_all(tmp.path()).await.unwrap();
1834 set_status(tmp.path(), &tasks, &t2.id, Status::Done).unwrap();
1835 let tasks = store::load_all(tmp.path()).await.unwrap();
1836
1837 archive_all(tmp.path(), &tasks).unwrap();
1839
1840 let active = store::load_all(tmp.path()).await.unwrap();
1841 assert!(active.is_empty());
1842
1843 let mut restored = restore_task(tmp.path(), &t2.id).await.unwrap();
1845 restored.sort();
1846
1847 assert_eq!(restored.len(), 2);
1848 assert!(restored.contains(&t1.id));
1849 assert!(restored.contains(&t2.id));
1850
1851 let active = store::load_all(tmp.path()).await.unwrap();
1852 assert!(active.contains_key(&t1.id));
1853 assert!(active.contains_key(&t2.id));
1854 }
1855
1856 #[tokio::test]
1857 async fn test_restore_task_cascades_parent_epic() {
1858 let tmp = tempfile::TempDir::new().unwrap();
1860 store::init(tmp.path()).unwrap();
1861
1862 let tasks = HashMap::new();
1863 let epic = create_task(
1864 tmp.path(),
1865 &tasks,
1866 "Epic".into(),
1867 Priority::P1,
1868 vec![],
1869 vec![],
1870 None,
1871 String::new(),
1872 TaskType::Epic,
1873 )
1874 .unwrap();
1875
1876 let tasks = store::load_all(tmp.path()).await.unwrap();
1877 let child = create_task(
1878 tmp.path(),
1879 &tasks,
1880 "Child".into(),
1881 Priority::P2,
1882 vec![],
1883 vec![],
1884 Some(epic.id.clone()),
1885 String::new(),
1886 TaskType::Task,
1887 )
1888 .unwrap();
1889
1890 let tasks = store::load_all(tmp.path()).await.unwrap();
1891 set_status(tmp.path(), &tasks, &child.id, Status::Done).unwrap();
1892 let tasks = store::load_all(tmp.path()).await.unwrap();
1893 set_status(tmp.path(), &tasks, &epic.id, Status::Done).unwrap();
1895 let tasks = store::load_all(tmp.path()).await.unwrap();
1896 archive_task(tmp.path(), &tasks, &epic.id).unwrap();
1897
1898 let active = store::load_all(tmp.path()).await.unwrap();
1899 assert!(active.is_empty());
1900
1901 let mut restored = restore_task(tmp.path(), &child.id).await.unwrap();
1903 restored.sort();
1904 assert!(restored.contains(&epic.id), "epic should be restored");
1905 assert!(restored.contains(&child.id), "child should be restored");
1906 }
1907
1908 #[tokio::test]
1909 async fn test_restore_not_archived_error() {
1910 let tmp = tempfile::TempDir::new().unwrap();
1911 store::init(tmp.path()).unwrap();
1912
1913 let result = restore_task(tmp.path(), "nonexistent").await;
1914 assert!(
1915 matches!(result, Err(Error::NotArchived(_))),
1916 "should get NotArchived error"
1917 );
1918 }
1919
1920 #[tokio::test]
1921 async fn test_get_archived_task() {
1922 let tmp = tempfile::TempDir::new().unwrap();
1923 store::init(tmp.path()).unwrap();
1924
1925 let tasks = HashMap::new();
1926 let t = create_task(
1927 tmp.path(),
1928 &tasks,
1929 "Archived task".into(),
1930 Priority::P2,
1931 vec![],
1932 vec![],
1933 None,
1934 String::new(),
1935 TaskType::Task,
1936 )
1937 .unwrap();
1938
1939 let tasks = store::load_all(tmp.path()).await.unwrap();
1940 set_status(tmp.path(), &tasks, &t.id, Status::Done).unwrap();
1941 let tasks = store::load_all(tmp.path()).await.unwrap();
1942 archive_task(tmp.path(), &tasks, &t.id).unwrap();
1943
1944 let fetched = get_archived_task(tmp.path(), &t.id).await.unwrap();
1945 assert_eq!(fetched.id, t.id);
1946 assert_eq!(fetched.title, "Archived task");
1947 }
1948
1949 #[tokio::test]
1950 async fn test_create_task_avoids_archived_id_collision() {
1951 let tmp = tempfile::TempDir::new().unwrap();
1956 store::init(tmp.path()).unwrap();
1957
1958 let tasks = HashMap::new();
1959 let t = create_task(
1960 tmp.path(),
1961 &tasks,
1962 "Task 1".into(),
1963 Priority::P2,
1964 vec![],
1965 vec![],
1966 None,
1967 String::new(),
1968 TaskType::Task,
1969 )
1970 .unwrap();
1971
1972 let tasks = store::load_all(tmp.path()).await.unwrap();
1973 set_status(tmp.path(), &tasks, &t.id, Status::Done).unwrap();
1974 let tasks = store::load_all(tmp.path()).await.unwrap();
1975 archive_task(tmp.path(), &tasks, &t.id).unwrap();
1976
1977 let tasks = store::load_all(tmp.path()).await.unwrap();
1979 let archived_ids = store::archived_id_set(tmp.path());
1981 assert!(archived_ids.contains(&t.id));
1982
1983 let t2 = create_task(
1987 tmp.path(),
1988 &tasks,
1989 "Task 2".into(),
1990 Priority::P2,
1991 vec![],
1992 vec![],
1993 None,
1994 String::new(),
1995 TaskType::Task,
1996 )
1997 .unwrap();
1998 assert_ne!(t2.id, t.id, "new task must not reuse archived ID");
1999 }
2000
2001 #[tokio::test]
2004 async fn test_list_archive_sorted_by_updated_desc() {
2005 let tmp = tempfile::TempDir::new().unwrap();
2006 store::init(tmp.path()).unwrap();
2007
2008 let tasks = HashMap::new();
2011 let t1 = create_task(
2012 tmp.path(),
2013 &tasks,
2014 "First".into(),
2015 Priority::P2,
2016 vec![],
2017 vec![],
2018 None,
2019 String::new(),
2020 TaskType::Task,
2021 )
2022 .unwrap();
2023
2024 let tasks = store::load_all(tmp.path()).await.unwrap();
2025 let t2 = create_task(
2026 tmp.path(),
2027 &tasks,
2028 "Second".into(),
2029 Priority::P2,
2030 vec![],
2031 vec![],
2032 None,
2033 String::new(),
2034 TaskType::Task,
2035 )
2036 .unwrap();
2037
2038 let tasks = store::load_all(tmp.path()).await.unwrap();
2039 let t3 = create_task(
2040 tmp.path(),
2041 &tasks,
2042 "Third".into(),
2043 Priority::P2,
2044 vec![],
2045 vec![],
2046 None,
2047 String::new(),
2048 TaskType::Task,
2049 )
2050 .unwrap();
2051
2052 let tasks = store::load_all(tmp.path()).await.unwrap();
2054 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
2055 let tasks = store::load_all(tmp.path()).await.unwrap();
2056 set_status(tmp.path(), &tasks, &t2.id, Status::Done).unwrap();
2057 let tasks = store::load_all(tmp.path()).await.unwrap();
2058 set_status(tmp.path(), &tasks, &t3.id, Status::Done).unwrap();
2059 let tasks = store::load_all(tmp.path()).await.unwrap();
2060 archive_all(tmp.path(), &tasks).unwrap();
2061
2062 let listed = list_archive(tmp.path(), None).await.unwrap();
2064 assert_eq!(listed.len(), 3);
2065 let listed_ids: Vec<&str> = listed.iter().map(|t| t.id.as_str()).collect();
2068 assert!(listed_ids.contains(&t1.id.as_str()));
2069 assert!(listed_ids.contains(&t2.id.as_str()));
2070 assert!(listed_ids.contains(&t3.id.as_str()));
2071 for w in listed.windows(2) {
2073 assert!(
2074 w[0].updated >= w[1].updated,
2075 "list_archive must be sorted updated desc"
2076 );
2077 }
2078 }
2079
2080 #[tokio::test]
2081 async fn test_list_archive_with_limit() {
2082 let tmp = tempfile::TempDir::new().unwrap();
2083 store::init(tmp.path()).unwrap();
2084
2085 let tasks = HashMap::new();
2086 let t1 = create_task(
2087 tmp.path(),
2088 &tasks,
2089 "T1".into(),
2090 Priority::P2,
2091 vec![],
2092 vec![],
2093 None,
2094 String::new(),
2095 TaskType::Task,
2096 )
2097 .unwrap();
2098
2099 let tasks = store::load_all(tmp.path()).await.unwrap();
2100 let t2 = create_task(
2101 tmp.path(),
2102 &tasks,
2103 "T2".into(),
2104 Priority::P2,
2105 vec![],
2106 vec![],
2107 None,
2108 String::new(),
2109 TaskType::Task,
2110 )
2111 .unwrap();
2112
2113 let tasks = store::load_all(tmp.path()).await.unwrap();
2114 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
2115 let tasks = store::load_all(tmp.path()).await.unwrap();
2116 set_status(tmp.path(), &tasks, &t2.id, Status::Done).unwrap();
2117 let tasks = store::load_all(tmp.path()).await.unwrap();
2118 archive_all(tmp.path(), &tasks).unwrap();
2119
2120 let listed = list_archive(tmp.path(), Some(1)).await.unwrap();
2121 assert_eq!(listed.len(), 1, "limit=1 should return exactly 1 task");
2122 }
2123
2124 #[tokio::test]
2125 async fn test_list_archive_empty() {
2126 let tmp = tempfile::TempDir::new().unwrap();
2127 store::init(tmp.path()).unwrap();
2128
2129 let listed = list_archive(tmp.path(), None).await.unwrap();
2130 assert!(listed.is_empty());
2131 }
2132}