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
72#[allow(clippy::too_many_arguments)]
80pub fn list_tasks(
81 tasks: &HashMap<String, Task>,
82 status: Option<Status>,
83 priority: Option<Priority>,
84 tag: Option<&str>,
85 include_all: bool,
86 include_proposed: bool,
87 epic: Option<&str>,
88) -> Vec<Task> {
89 let mut filtered: Vec<Task> = tasks
90 .values()
91 .filter(|t| {
92 if status.is_some() {
93 true
94 } else if t.status == Status::Proposed {
95 include_proposed
96 } else {
97 include_all || task::is_active(t)
98 }
99 })
100 .filter(|t| status.as_ref().is_none_or(|s| t.status == *s))
101 .filter(|t| priority.as_ref().is_none_or(|p| t.priority == *p))
102 .filter(|t| task::matches_tag(t, tag))
103 .filter(|t| epic.is_none_or(|e| t.parent.as_deref() == Some(e)))
104 .cloned()
105 .collect();
106 task::sort_by_priority_owned(&mut filtered);
107 filtered
108}
109
110pub fn list_ready(
112 tasks: &HashMap<String, Task>,
113 tag: Option<&str>,
114 limit: Option<usize>,
115 epic: Option<&str>,
116) -> Vec<Task> {
117 let graph = Graph::build(tasks);
118 let ready = graph.ready(tasks, tag, limit, epic);
119 ready.into_iter().cloned().collect()
120}
121
122pub fn list_review(
128 tasks: &HashMap<String, Task>,
129 tag: Option<&str>,
130 limit: Option<usize>,
131 epic: Option<&str>,
132) -> Vec<Task> {
133 let mut queue: Vec<Task> = tasks
134 .values()
135 .filter(|t| t.status == Status::Review)
136 .filter(|t| task::matches_tag(t, tag))
137 .filter(|t| epic.is_none_or(|e| t.parent.as_deref() == Some(e)))
138 .cloned()
139 .collect();
140 task::sort_by_priority_owned(&mut queue);
141 if let Some(limit) = limit {
142 queue.truncate(limit);
143 }
144 queue
145}
146
147fn transition(
153 base: &Path,
154 tasks: &HashMap<String, Task>,
155 id_or_prefix: &str,
156 action: &'static str,
157 expected: Status,
158 to: Status,
159) -> Result<Task> {
160 let id = store::resolve_prefix(tasks, id_or_prefix)?;
161 let t = &tasks[&id];
162 if t.status != expected {
163 return Err(Error::InvalidStatus {
164 id: t.id.clone(),
165 action,
166 expected,
167 actual: t.status,
168 });
169 }
170 set_status(base, tasks, &id, to)
171}
172
173pub fn review_task(base: &Path, tasks: &HashMap<String, Task>, id_or_prefix: &str) -> Result<Task> {
175 transition(
176 base,
177 tasks,
178 id_or_prefix,
179 "review",
180 Status::InProgress,
181 Status::Review,
182 )
183}
184
185pub fn reject_task(base: &Path, tasks: &HashMap<String, Task>, id_or_prefix: &str) -> Result<Task> {
191 let id = store::resolve_prefix(tasks, id_or_prefix)?;
192 let t = &tasks[&id];
193 if t.status != Status::Review {
194 return Err(Error::InvalidStatus {
195 id: t.id.clone(),
196 action: "reject",
197 expected: Status::Review,
198 actual: t.status,
199 });
200 }
201 let mut t = t.clone();
202 t.status = Status::Open;
203 t.assignee = String::new();
204 t.updated = Utc::now();
205 store::save(base, &t)?;
206 on_status_changed(base, tasks, &t)?;
207 Ok(t)
208}
209
210pub fn propose_task(
212 base: &Path,
213 tasks: &HashMap<String, Task>,
214 id_or_prefix: &str,
215) -> Result<Task> {
216 transition(
217 base,
218 tasks,
219 id_or_prefix,
220 "propose",
221 Status::Open,
222 Status::Proposed,
223 )
224}
225
226pub fn accept_task(base: &Path, tasks: &HashMap<String, Task>, id_or_prefix: &str) -> Result<Task> {
228 transition(
229 base,
230 tasks,
231 id_or_prefix,
232 "accept",
233 Status::Proposed,
234 Status::Open,
235 )
236}
237
238pub fn get_task(tasks: &HashMap<String, Task>, id_or_prefix: &str) -> Result<Task> {
240 let id = store::resolve_prefix(tasks, id_or_prefix)?;
241 Ok(tasks[&id].clone())
242}
243
244#[allow(clippy::too_many_arguments)]
251pub fn update_task(
252 base: &Path,
253 tasks: &HashMap<String, Task>,
254 id_or_prefix: &str,
255 status: Option<Status>,
256 priority: Option<Priority>,
257 tags: Option<Vec<String>>,
258 assignee: Option<String>,
259 body: Option<String>,
260 title: Option<String>,
261 parent: Option<Option<String>>,
262) -> Result<Task> {
263 let id = store::resolve_prefix(tasks, id_or_prefix)?;
264 let mut t = tasks[&id].clone();
265
266 let previous_status = t.status;
267 let status_changed = status.as_ref().is_some_and(|s| *s != t.status);
268 if let Some(s) = status {
269 t.status = s;
270 record_attempt(&mut t, previous_status);
271 }
272 if let Some(p) = priority {
273 t.priority = p;
274 }
275 if let Some(tags) = tags {
276 t.tags = tags;
277 }
278 if let Some(a) = assignee {
279 t.assignee = a;
280 }
281 if let Some(b) = body {
282 t.body = b;
283 }
284 if let Some(title) = title {
285 t.title = title;
286 }
287 if let Some(new_parent) = parent {
289 match new_parent {
290 None => t.parent = None,
291 Some(ref pid) => {
292 t.parent = Some(store::resolve_prefix(tasks, pid)?);
295 }
296 }
297 }
298 t.updated = Utc::now();
299
300 store::save(base, &t)?;
301
302 if status_changed {
304 on_status_changed(base, tasks, &t)?;
305 }
306
307 Ok(t)
308}
309
310pub fn set_status(
312 base: &Path,
313 tasks: &HashMap<String, Task>,
314 id_or_prefix: &str,
315 status: Status,
316) -> Result<Task> {
317 let id = store::resolve_prefix(tasks, id_or_prefix)?;
318 let mut t = tasks[&id].clone();
319 let previous = t.status;
320 t.status = status;
321 record_attempt(&mut t, previous);
322 t.updated = Utc::now();
323 store::save(base, &t)?;
324
325 on_status_changed(base, tasks, &t)?;
326
327 Ok(t)
328}
329
330pub fn start_task(
336 base: &Path,
337 tasks: &HashMap<String, Task>,
338 id_or_prefix: &str,
339 assignee: Option<String>,
340) -> Result<Task> {
341 let id = store::resolve_prefix(tasks, id_or_prefix)?;
342 let mut t = tasks[&id].clone();
343 let previous = t.status;
344 t.status = Status::InProgress;
345 record_attempt(&mut t, previous);
346 if let Some(a) = assignee {
347 t.assignee = a;
348 }
349 t.updated = Utc::now();
350 store::save(base, &t)?;
351
352 on_status_changed(base, tasks, &t)?;
353
354 Ok(t)
355}
356
357pub fn release_task(
363 base: &Path,
364 tasks: &HashMap<String, Task>,
365 id_or_prefix: &str,
366) -> Result<Task> {
367 let id = store::resolve_prefix(tasks, id_or_prefix)?;
368 let mut t = tasks[&id].clone();
369 if t.status != Status::InProgress {
370 return Err(Error::InvalidStatus {
371 id: t.id.clone(),
372 action: "release",
373 expected: Status::InProgress,
374 actual: t.status,
375 });
376 }
377 t.status = Status::Open;
378 t.assignee = String::new();
379 t.updated = Utc::now();
380 store::save(base, &t)?;
381
382 on_status_changed(base, tasks, &t)?;
383
384 Ok(t)
385}
386
387fn assert_fence(
408 base: &Path,
409 tasks: &HashMap<String, Task>,
410 id_or_prefix: &str,
411 expected_assignee: &str,
412) -> Result<String> {
413 let id = store::resolve_prefix(tasks, id_or_prefix)?;
414 let current = store::load_one(base, &id)?;
415 if current.assignee != expected_assignee {
416 return Err(Error::FenceViolation {
417 id,
418 expected: expected_assignee.to_string(),
419 actual: current.assignee,
420 });
421 }
422 Ok(id)
423}
424
425pub fn claim_task(
433 base: &Path,
434 tasks: &HashMap<String, Task>,
435 id_or_prefix: &str,
436 assignee: &str,
437) -> Result<Task> {
438 let id = store::resolve_prefix(tasks, id_or_prefix)?;
439 let mut t = store::load_one(base, &id)?;
440 if t.status != Status::Open {
441 return Err(Error::InvalidStatus {
442 id,
443 action: "claim",
444 expected: Status::Open,
445 actual: t.status,
446 });
447 }
448 if !t.assignee.is_empty() && t.assignee != assignee {
449 return Err(Error::AlreadyClaimed {
450 id,
451 assignee: t.assignee,
452 });
453 }
454 let previous = t.status;
455 t.status = Status::InProgress;
456 record_attempt(&mut t, previous);
457 t.assignee = assignee.to_string();
458 t.updated = Utc::now();
459 store::save(base, &t)?;
460 on_status_changed(base, tasks, &t)?;
461 Ok(t)
462}
463
464pub fn assign_task(
472 base: &Path,
473 tasks: &HashMap<String, Task>,
474 id_or_prefix: &str,
475 assignee: &str,
476) -> Result<Task> {
477 let id = store::resolve_prefix(tasks, id_or_prefix)?;
478 let mut t = store::load_one(base, &id)?;
479 if matches!(
480 t.status,
481 Status::Done | Status::Cancelled | Status::Proposed
482 ) {
483 return Err(Error::InvalidUsage(format!(
484 "cannot assign task {id}: a {} task has no work to claim",
485 t.status
486 )));
487 }
488 if !t.assignee.is_empty() && t.assignee != assignee {
489 return Err(Error::AlreadyClaimed {
490 id,
491 assignee: t.assignee,
492 });
493 }
494 t.assignee = assignee.to_string();
495 t.updated = Utc::now();
496 store::save(base, &t)?;
497 Ok(t)
498}
499
500pub fn release_task_fenced(
503 base: &Path,
504 tasks: &HashMap<String, Task>,
505 id_or_prefix: &str,
506 expected_assignee: &str,
507) -> Result<Task> {
508 let id = assert_fence(base, tasks, id_or_prefix, expected_assignee)?;
509 release_task(base, tasks, &id)
510}
511
512pub fn review_task_fenced(
514 base: &Path,
515 tasks: &HashMap<String, Task>,
516 id_or_prefix: &str,
517 expected_assignee: &str,
518) -> Result<Task> {
519 let id = assert_fence(base, tasks, id_or_prefix, expected_assignee)?;
520 review_task(base, tasks, &id)
521}
522
523pub fn reject_task_fenced(
526 base: &Path,
527 tasks: &HashMap<String, Task>,
528 id_or_prefix: &str,
529 expected_assignee: &str,
530) -> Result<Task> {
531 let id = assert_fence(base, tasks, id_or_prefix, expected_assignee)?;
532 reject_task(base, tasks, &id)
533}
534
535pub fn set_status_fenced(
537 base: &Path,
538 tasks: &HashMap<String, Task>,
539 id_or_prefix: &str,
540 status: Status,
541 expected_assignee: &str,
542) -> Result<Task> {
543 let id = assert_fence(base, tasks, id_or_prefix, expected_assignee)?;
544 set_status(base, tasks, &id, status)
545}
546
547#[allow(clippy::too_many_arguments)]
549pub fn update_task_fenced(
550 base: &Path,
551 tasks: &HashMap<String, Task>,
552 id_or_prefix: &str,
553 status: Option<Status>,
554 priority: Option<Priority>,
555 tags: Option<Vec<String>>,
556 assignee: Option<String>,
557 body: Option<String>,
558 title: Option<String>,
559 parent: Option<Option<String>>,
560 expected_assignee: &str,
561) -> Result<Task> {
562 let id = assert_fence(base, tasks, id_or_prefix, expected_assignee)?;
563 update_task(
564 base, tasks, &id, status, priority, tags, assignee, body, title, parent,
565 )
566}
567
568pub fn add_dependency_fenced(
571 base: &Path,
572 tasks: &HashMap<String, Task>,
573 id_or_prefix: &str,
574 dep_or_prefix: &str,
575 expected_assignee: &str,
576) -> Result<Task> {
577 let id = assert_fence(base, tasks, id_or_prefix, expected_assignee)?;
578 add_dependency(base, tasks, &id, dep_or_prefix)
579}
580
581pub fn remove_dependency_fenced(
584 base: &Path,
585 tasks: &HashMap<String, Task>,
586 id_or_prefix: &str,
587 dep_or_prefix: &str,
588 expected_assignee: &str,
589) -> Result<Task> {
590 let id = assert_fence(base, tasks, id_or_prefix, expected_assignee)?;
591 remove_dependency(base, tasks, &id, dep_or_prefix)
592}
593
594fn record_attempt(t: &mut Task, previous: Status) {
602 if t.status == Status::InProgress && previous != Status::InProgress {
603 t.attempts = Some(t.attempt_count().saturating_add(1));
604 }
605}
606
607fn on_status_changed(base: &Path, tasks: &HashMap<String, Task>, t: &Task) -> Result<()> {
612 let mut overrides: HashMap<String, Status> = HashMap::new();
616 overrides.insert(t.id.clone(), t.status);
617 maybe_close_parent_epic(base, tasks, t, &mut overrides)
618}
619
620fn effective_status<'a>(task: &'a Task, overrides: &'a HashMap<String, Status>) -> &'a Status {
622 overrides.get(&task.id).unwrap_or(&task.status)
623}
624
625fn maybe_close_parent_epic(
629 base: &Path,
630 tasks: &HashMap<String, Task>,
631 t: &Task,
632 overrides: &mut HashMap<String, Status>,
633) -> Result<()> {
634 let t_status = effective_status(t, overrides);
636 let is_resolved = *t_status == Status::Done || *t_status == Status::Cancelled;
637 if !is_resolved {
638 return Ok(());
639 }
640
641 let Some(ref parent_id) = t.parent else {
642 return Ok(());
643 };
644 let Some(parent) = tasks.get(parent_id) else {
645 return Ok(());
646 };
647 if !parent.task_type.is_epic() {
648 return Ok(());
649 }
650 if *effective_status(parent, overrides) == Status::Done {
652 return Ok(());
653 }
654
655 let children: Vec<_> = tasks
659 .values()
660 .filter(|c| c.parent.as_deref() == Some(parent_id))
661 .collect();
662 let has_children = !children.is_empty();
663 let all_resolved = children.iter().all(|c| {
664 let s = effective_status(c, overrides);
665 *s == Status::Done || *s == Status::Cancelled
666 });
667
668 if has_children && all_resolved {
669 let mut closed_parent = parent.clone();
670 closed_parent.status = Status::Done;
671 closed_parent.updated = Utc::now();
672 store::save(base, &closed_parent)?;
673 overrides.insert(parent_id.clone(), Status::Done);
674
675 maybe_close_parent_epic(base, tasks, parent, overrides)?;
677 }
678
679 Ok(())
680}
681
682pub fn add_dependency(
684 base: &Path,
685 tasks: &HashMap<String, Task>,
686 id_or_prefix: &str,
687 dep_or_prefix: &str,
688) -> Result<Task> {
689 let id = store::resolve_prefix(tasks, id_or_prefix)?;
690 let depends_on = store::resolve_prefix(tasks, dep_or_prefix)?;
691
692 let graph = Graph::build(tasks);
693 if graph.would_cycle(&id, &depends_on) {
694 return Err(Error::CycleDetected {
695 from: id,
696 to: depends_on,
697 });
698 }
699
700 let mut t = tasks[&id].clone();
701 if !t.depends_on.contains(&depends_on) {
702 t.depends_on.push(depends_on);
703 t.updated = Utc::now();
704 store::save(base, &t)?;
705 }
706
707 Ok(t)
708}
709
710pub fn remove_dependency(
712 base: &Path,
713 tasks: &HashMap<String, Task>,
714 id_or_prefix: &str,
715 dep_or_prefix: &str,
716) -> Result<Task> {
717 let id = store::resolve_prefix(tasks, id_or_prefix)?;
718 let depends_on = store::resolve_prefix(tasks, dep_or_prefix)?;
719 let mut t = tasks[&id].clone();
720 t.depends_on.retain(|d| d != &depends_on);
721 t.updated = Utc::now();
722 store::save(base, &t)?;
723 Ok(t)
724}
725
726pub fn search_tasks(tasks: &HashMap<String, Task>, query: &str, include_all: bool) -> Vec<Task> {
728 let query_lower = query.to_lowercase();
729 let mut results: Vec<Task> = tasks
730 .values()
731 .filter(|t| include_all || task::is_active(t))
732 .filter(|t| {
733 t.title.to_lowercase().contains(&query_lower)
734 || t.body.to_lowercase().contains(&query_lower)
735 || t.tags
736 .iter()
737 .any(|tag| tag.to_lowercase().contains(&query_lower))
738 || t.id.contains(&query_lower)
739 })
740 .cloned()
741 .collect();
742 task::sort_by_priority_owned(&mut results);
743 results
744}
745
746pub fn delete_task(base: &Path, tasks: &HashMap<String, Task>, id_or_prefix: &str) -> Result<Task> {
749 let id = store::resolve_prefix(tasks, id_or_prefix)?;
750 let t = tasks[&id].clone();
751 store::delete(base, &id)?;
752 scrub_references(base, tasks, &HashSet::from([id]))?;
753 Ok(t)
754}
755
756pub fn prune_tasks(
759 base: &Path,
760 tasks: &HashMap<String, Task>,
761 include_done: bool,
762) -> Result<Vec<Task>> {
763 let to_delete: Vec<Task> = tasks
764 .values()
765 .filter(|t| t.status == Status::Cancelled || (include_done && t.status == Status::Done))
766 .cloned()
767 .collect();
768
769 for t in &to_delete {
770 store::delete(base, &t.id)?;
771 }
772 let deleted_ids: HashSet<String> = to_delete.iter().map(|t| t.id.clone()).collect();
773 scrub_references(base, tasks, &deleted_ids)?;
774 Ok(to_delete)
775}
776
777fn scrub_references(
784 base: &Path,
785 tasks: &HashMap<String, Task>,
786 deleted: &HashSet<String>,
787) -> Result<()> {
788 for t in tasks.values() {
789 if deleted.contains(&t.id) {
790 continue;
791 }
792 let dangling_dep = t.depends_on.iter().any(|d| deleted.contains(d));
793 let dangling_parent = t.parent.as_ref().is_some_and(|p| deleted.contains(p));
794 if dangling_dep || dangling_parent {
795 let mut t = t.clone();
796 t.depends_on.retain(|d| !deleted.contains(d));
797 if dangling_parent {
798 t.parent = None;
799 }
800 t.updated = Utc::now();
801 store::save(base, &t)?;
802 }
803 }
804 Ok(())
805}
806
807pub fn build_graph(tasks: &HashMap<String, Task>) -> Graph {
809 Graph::build(tasks)
810}
811
812#[cfg_attr(not(test), allow(dead_code))]
826pub fn is_archivable(task: &Task, tasks: &HashMap<String, Task>) -> bool {
827 let settled = task.status == Status::Done || task.status == Status::Cancelled;
828 if !settled {
829 return false;
830 }
831 let graph = Graph::build(tasks);
833 active_blockers(&task.id, tasks, &graph).is_empty()
834}
835
836fn active_blockers(id: &str, tasks: &HashMap<String, Task>, graph: &Graph) -> Vec<String> {
838 graph
839 .reverse
840 .get(id)
841 .into_iter()
842 .flat_map(|s| s.iter())
843 .filter(|dep_id| {
844 tasks
845 .get(dep_id.as_str())
846 .is_some_and(|t| t.status != Status::Done && t.status != Status::Cancelled)
847 })
848 .cloned()
849 .collect()
850}
851
852pub fn archive_task(
864 base: &Path,
865 tasks: &HashMap<String, Task>,
866 id_or_prefix: &str,
867) -> Result<Vec<String>> {
868 let id = store::resolve_prefix(tasks, id_or_prefix)?;
869 let task = &tasks[&id];
870 let graph = Graph::build(tasks);
871
872 let blockers = active_blockers(&id, tasks, &graph);
874 if !blockers.is_empty() {
875 return Err(Error::NotArchivable {
876 id: id.clone(),
877 blockers,
878 });
879 }
880 if task.status != Status::Done && task.status != Status::Cancelled {
881 return Err(Error::NotArchivable {
882 id: id.clone(),
883 blockers: vec![],
884 });
885 }
886
887 let mut to_archive: Vec<String> = vec![id.clone()];
889
890 if task.task_type.is_epic() {
891 let settled_children: Vec<String> = tasks
892 .values()
893 .filter(|c| {
894 c.parent.as_deref() == Some(id.as_str())
895 && (c.status == Status::Done || c.status == Status::Cancelled)
896 })
897 .map(|c| c.id.clone())
898 .collect();
899 to_archive.extend(settled_children);
900 }
901
902 for tid in &to_archive {
904 store::move_to_archive(base, tid)?;
905 }
906
907 Ok(to_archive)
908}
909
910pub fn archive_all(base: &Path, tasks: &HashMap<String, Task>) -> Result<Vec<String>> {
920 let mut remaining: HashMap<String, Task> = tasks.clone();
921 let mut total_archived: Vec<String> = Vec::new();
922
923 loop {
924 let graph = Graph::build(&remaining);
925 let mut batch: Vec<String> = remaining
926 .values()
927 .filter(|t| {
928 (t.status == Status::Done || t.status == Status::Cancelled)
929 && active_blockers(&t.id, &remaining, &graph).is_empty()
930 })
931 .map(|t| t.id.clone())
932 .collect();
933
934 if batch.is_empty() {
935 break;
936 }
937
938 batch.sort(); for id in &batch {
940 store::move_to_archive(base, id)?;
941 remaining.remove(id);
942 }
943 total_archived.extend(batch);
944 }
945
946 Ok(total_archived)
947}
948
949pub async fn restore_task(base: &Path, id_or_prefix: &str) -> Result<Vec<String>> {
956 let archived = store::load_archived(base).await?;
957
958 let id = store::resolve_prefix(&archived, id_or_prefix)
959 .map_err(|_| Error::NotArchived(id_or_prefix.to_string()))?;
960
961 let mut to_restore: Vec<String> = Vec::new();
963 let mut visited: HashSet<String> = HashSet::new();
964 let mut queue: Vec<String> = vec![id.clone()];
965
966 while let Some(current) = queue.pop() {
967 if !visited.insert(current.clone()) {
968 continue;
969 }
970 to_restore.push(current.clone());
971
972 if let Some(task) = archived.get(¤t) {
973 if let Some(ref parent_id) = task.parent
975 && archived.contains_key(parent_id)
976 && !visited.contains(parent_id)
977 {
978 queue.push(parent_id.clone());
979 }
980 for dep_id in &task.depends_on {
982 if archived.contains_key(dep_id) && !visited.contains(dep_id) {
983 queue.push(dep_id.clone());
984 }
985 }
986 }
987 }
988
989 for tid in &to_restore {
990 store::move_from_archive(base, tid)?;
991 }
992
993 Ok(to_restore)
994}
995
996pub async fn get_archived_task(base: &Path, id_or_prefix: &str) -> Result<Task> {
998 let archived = store::load_archived(base).await?;
999 let id = store::resolve_prefix(&archived, id_or_prefix)
1000 .map_err(|_| Error::NotArchived(id_or_prefix.to_string()))?;
1001 Ok(archived[&id].clone())
1002}
1003
1004pub async fn list_archive(base: &Path, limit: Option<usize>) -> Result<Vec<Task>> {
1008 let archived = store::load_archived(base).await?;
1009 let mut tasks: Vec<Task> = archived.into_values().collect();
1010 tasks.sort_by(|a, b| b.updated.cmp(&a.updated).then(a.id.cmp(&b.id)));
1012 if let Some(n) = limit {
1013 tasks.truncate(n);
1014 }
1015 Ok(tasks)
1016}
1017
1018pub fn effective_priorities(tasks: &HashMap<String, Task>) -> HashMap<String, Priority> {
1020 Graph::build(tasks).effective_priorities_all(tasks)
1021}
1022
1023#[derive(Debug, Clone, Serialize)]
1025pub struct EpicProgress {
1026 pub done: usize,
1027 pub total: usize,
1028}
1029
1030#[derive(Debug, Serialize)]
1032pub struct EpicSummary {
1033 pub id: String,
1034 pub title: String,
1035 pub status: Status,
1036 pub priority: Priority,
1037 pub tags: Vec<String>,
1038 pub progress: EpicProgress,
1039}
1040
1041pub fn epic_progress(tasks: &HashMap<String, Task>, epic_id: &str) -> EpicProgress {
1050 let mut done = 0;
1051 let mut total = 0;
1052 for t in tasks.values() {
1053 if t.parent.as_deref() == Some(epic_id) {
1054 if t.status == Status::Cancelled {
1055 continue;
1057 }
1058 total += 1;
1059 if t.status == Status::Done {
1060 done += 1;
1061 }
1062 }
1063 }
1064 EpicProgress { done, total }
1065}
1066
1067pub struct EpicPlan<'a> {
1069 pub tasks: Vec<&'a Task>,
1071 pub cyclic: Vec<&'a Task>,
1073}
1074
1075pub fn plan_epic<'a>(tasks: &'a HashMap<String, Task>, parent_id: &str) -> Result<EpicPlan<'a>> {
1078 let resolved = store::resolve_prefix(tasks, parent_id)?;
1080 let parent = tasks
1081 .get(&resolved)
1082 .ok_or_else(|| Error::TaskNotFound(parent_id.to_string()))?;
1083 if !parent.task_type.is_epic() {
1084 return Err(Error::NotAnEpic(resolved));
1085 }
1086
1087 let child_ids: HashSet<String> = tasks
1089 .values()
1090 .filter(|t| t.parent.as_deref() == Some(resolved.as_str()))
1091 .map(|t| t.id.clone())
1092 .collect();
1093
1094 let graph = Graph::build(tasks);
1095 let topo = graph.topo_sort_subset(&child_ids, tasks);
1096 Ok(EpicPlan {
1097 tasks: topo.sorted,
1098 cyclic: topo.cyclic,
1099 })
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105
1106 fn make_task(id: &str, status: Status) -> Task {
1107 let mut t = Task::new(id.to_string(), format!("Task {id}"), Priority::P2);
1108 t.status = status;
1109 t
1110 }
1111
1112 fn make_epic(id: &str) -> Task {
1113 let mut t = Task::new(id.to_string(), format!("Epic {id}"), Priority::P1);
1114 t.task_type = TaskType::Epic;
1115 t
1116 }
1117
1118 fn make_child(id: &str, parent: &str, status: Status) -> Task {
1119 let mut t = make_task(id, status);
1120 t.parent = Some(parent.to_string());
1121 t
1122 }
1123
1124 fn task_map(tasks: Vec<Task>) -> HashMap<String, Task> {
1125 tasks.into_iter().map(|t| (t.id.clone(), t)).collect()
1126 }
1127
1128 #[tokio::test]
1129 async fn test_claim_task_claims_and_counts_attempt() {
1130 let tmp = tempfile::TempDir::new().unwrap();
1131 store::init(tmp.path()).unwrap();
1132 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
1133 let tasks = store::load_all(tmp.path()).await.unwrap();
1134
1135 let t = claim_task(tmp.path(), &tasks, "aaa", "worker-1").unwrap();
1136 assert_eq!(t.status, Status::InProgress);
1137 assert_eq!(t.assignee, "worker-1");
1138 assert_eq!(t.attempt_count(), 1);
1139 }
1140
1141 #[tokio::test]
1142 async fn test_claim_task_refuses_contended_claim() {
1143 let tmp = tempfile::TempDir::new().unwrap();
1144 store::init(tmp.path()).unwrap();
1145 let mut a = make_task("aaa", Status::Open);
1146 a.assignee = "worker-1".into();
1147 store::save(tmp.path(), &a).unwrap();
1148 let tasks = store::load_all(tmp.path()).await.unwrap();
1149
1150 let err = claim_task(tmp.path(), &tasks, "aaa", "worker-2").unwrap_err();
1151 assert!(matches!(
1152 err,
1153 Error::AlreadyClaimed { ref assignee, .. } if assignee == "worker-1"
1154 ));
1155 let on_disk = store::load_one(tmp.path(), "aaa").unwrap();
1157 assert_eq!(on_disk.assignee, "worker-1");
1158 assert_eq!(on_disk.status, Status::Open);
1159 }
1160
1161 #[tokio::test]
1162 async fn test_claim_task_checks_disk_not_snapshot() {
1163 let tmp = tempfile::TempDir::new().unwrap();
1164 store::init(tmp.path()).unwrap();
1165 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
1166 let stale = store::load_all(tmp.path()).await.unwrap();
1168 let mut a = stale["aaa"].clone();
1170 a.assignee = "worker-1".into();
1171 store::save(tmp.path(), &a).unwrap();
1172
1173 let err = claim_task(tmp.path(), &stale, "aaa", "worker-2").unwrap_err();
1174 assert!(matches!(err, Error::AlreadyClaimed { .. }));
1175 }
1176
1177 #[tokio::test]
1178 async fn test_claim_task_requires_open() {
1179 let tmp = tempfile::TempDir::new().unwrap();
1180 store::init(tmp.path()).unwrap();
1181 store::save(tmp.path(), &make_task("aaa", Status::Review)).unwrap();
1182 let tasks = store::load_all(tmp.path()).await.unwrap();
1183
1184 let err = claim_task(tmp.path(), &tasks, "aaa", "worker-1").unwrap_err();
1185 assert!(matches!(
1186 err,
1187 Error::InvalidStatus {
1188 action: "claim",
1189 ..
1190 }
1191 ));
1192 }
1193
1194 #[tokio::test]
1195 async fn test_assign_task_keeps_status_and_refuses_contention() {
1196 let tmp = tempfile::TempDir::new().unwrap();
1197 store::init(tmp.path()).unwrap();
1198 store::save(tmp.path(), &make_task("aaa", Status::Review)).unwrap();
1199 let tasks = store::load_all(tmp.path()).await.unwrap();
1200
1201 let t = assign_task(tmp.path(), &tasks, "aaa", "reviewer-1").unwrap();
1202 assert_eq!(t.status, Status::Review, "status must not change");
1203 assert_eq!(t.assignee, "reviewer-1");
1204
1205 let tasks = store::load_all(tmp.path()).await.unwrap();
1206 let err = assign_task(tmp.path(), &tasks, "aaa", "reviewer-2").unwrap_err();
1207 assert!(matches!(err, Error::AlreadyClaimed { .. }));
1208
1209 store::save(tmp.path(), &make_task("bbb", Status::Done)).unwrap();
1211 let tasks = store::load_all(tmp.path()).await.unwrap();
1212 let err = assign_task(tmp.path(), &tasks, "bbb", "reviewer-1").unwrap_err();
1213 assert!(matches!(err, Error::InvalidUsage(_)));
1214 }
1215
1216 #[tokio::test]
1217 async fn test_fenced_mutations_reject_stale_writer() {
1218 let tmp = tempfile::TempDir::new().unwrap();
1219 store::init(tmp.path()).unwrap();
1220 let mut a = make_task("aaa", Status::InProgress);
1221 a.assignee = "worker-1".into();
1222 store::save(tmp.path(), &a).unwrap();
1223 let tasks = store::load_all(tmp.path()).await.unwrap();
1224
1225 let mut a = tasks["aaa"].clone();
1227 a.assignee = "worker-2".into();
1228 store::save(tmp.path(), &a).unwrap();
1229
1230 let err = update_task_fenced(
1232 tmp.path(),
1233 &tasks,
1234 "aaa",
1235 None,
1236 None,
1237 None,
1238 None,
1239 Some("stale findings".into()),
1240 None,
1241 None,
1242 "worker-1",
1243 )
1244 .unwrap_err();
1245 assert!(matches!(
1246 err,
1247 Error::FenceViolation { ref actual, .. } if actual == "worker-2"
1248 ));
1249 let err = release_task_fenced(tmp.path(), &tasks, "aaa", "worker-1").unwrap_err();
1250 assert!(matches!(err, Error::FenceViolation { .. }));
1251 let err = review_task_fenced(tmp.path(), &tasks, "aaa", "worker-1").unwrap_err();
1252 assert!(matches!(err, Error::FenceViolation { .. }));
1253 let err =
1254 set_status_fenced(tmp.path(), &tasks, "aaa", Status::Done, "worker-1").unwrap_err();
1255 assert!(matches!(err, Error::FenceViolation { .. }));
1256
1257 let on_disk = store::load_one(tmp.path(), "aaa").unwrap();
1259 assert_eq!(on_disk.body, "");
1260 }
1261
1262 #[tokio::test]
1263 async fn test_fenced_mutations_pass_for_current_holder() {
1264 let tmp = tempfile::TempDir::new().unwrap();
1265 store::init(tmp.path()).unwrap();
1266 let mut a = make_task("aaa", Status::InProgress);
1267 a.assignee = "worker-1".into();
1268 store::save(tmp.path(), &a).unwrap();
1269 store::save(tmp.path(), &make_task("bbb", Status::Open)).unwrap();
1270 let tasks = store::load_all(tmp.path()).await.unwrap();
1271
1272 let t = update_task_fenced(
1273 tmp.path(),
1274 &tasks,
1275 "aaa",
1276 None,
1277 None,
1278 None,
1279 None,
1280 Some("findings".into()),
1281 None,
1282 None,
1283 "worker-1",
1284 )
1285 .unwrap();
1286 assert_eq!(t.body, "findings");
1287
1288 let t = add_dependency_fenced(tmp.path(), &tasks, "aaa", "bbb", "worker-1").unwrap();
1289 assert_eq!(t.depends_on, vec!["bbb".to_string()]);
1290 let t = remove_dependency_fenced(tmp.path(), &tasks, "aaa", "bbb", "worker-1").unwrap();
1291 assert!(t.depends_on.is_empty());
1292
1293 let t = review_task_fenced(tmp.path(), &tasks, "aaa", "worker-1").unwrap();
1294 assert_eq!(t.status, Status::Review);
1295 let tasks = store::load_all(tmp.path()).await.unwrap();
1296 let t = reject_task_fenced(tmp.path(), &tasks, "aaa", "worker-1").unwrap();
1297 assert_eq!(t.status, Status::Open);
1298 assert!(
1299 t.assignee.is_empty(),
1300 "reject hands the task back to the pool"
1301 );
1302 let tasks = store::load_all(tmp.path()).await.unwrap();
1304 let err = release_task_fenced(tmp.path(), &tasks, "aaa", "worker-1").unwrap_err();
1305 assert!(matches!(err, Error::FenceViolation { .. }));
1306 }
1307
1308 #[tokio::test]
1309 async fn test_delete_scrubs_dangling_references() {
1310 let tmp = tempfile::TempDir::new().unwrap();
1311 store::init(tmp.path()).unwrap();
1312
1313 let mut a = make_epic("aaa");
1315 a.id = "aaa".into();
1316 store::save(tmp.path(), &a).unwrap();
1317 let mut b = make_task("bbb", Status::Open);
1318 b.depends_on = vec!["aaa".into()];
1319 store::save(tmp.path(), &b).unwrap();
1320 let c = make_child("ccc", "aaa", Status::Open);
1321 store::save(tmp.path(), &c).unwrap();
1322
1323 let tasks = store::load_all(tmp.path()).await.unwrap();
1324 delete_task(tmp.path(), &tasks, "aaa").unwrap();
1325
1326 let tasks = store::load_all(tmp.path()).await.unwrap();
1327 assert!(tasks["bbb"].depends_on.is_empty(), "dep should be scrubbed");
1328 assert_eq!(tasks["ccc"].parent, None, "parent should be cleared");
1329 let ready = list_ready(&tasks, None, None, None);
1331 assert!(ready.iter().any(|t| t.id == "bbb"));
1332 }
1333
1334 #[tokio::test]
1335 async fn test_prune_scrubs_dangling_references() {
1336 let tmp = tempfile::TempDir::new().unwrap();
1337 store::init(tmp.path()).unwrap();
1338
1339 let a = make_task("aaa", Status::Cancelled);
1340 store::save(tmp.path(), &a).unwrap();
1341 let mut b = make_task("bbb", Status::Open);
1342 b.depends_on = vec!["aaa".into()];
1343 store::save(tmp.path(), &b).unwrap();
1344
1345 let tasks = store::load_all(tmp.path()).await.unwrap();
1346 prune_tasks(tmp.path(), &tasks, false).unwrap();
1347
1348 let tasks = store::load_all(tmp.path()).await.unwrap();
1349 assert!(tasks["bbb"].depends_on.is_empty(), "dep should be scrubbed");
1350 }
1351
1352 #[tokio::test]
1353 async fn test_create_task_rejects_unknown_parent() {
1354 let tmp = tempfile::TempDir::new().unwrap();
1355 store::init(tmp.path()).unwrap();
1356
1357 let result = create_task(
1358 tmp.path(),
1359 &HashMap::new(),
1360 "Orphan".into(),
1361 Priority::P2,
1362 vec![],
1363 vec![],
1364 Some("zzzz".into()),
1365 String::new(),
1366 TaskType::Task,
1367 );
1368 assert!(matches!(result, Err(Error::TaskNotFound(_))));
1369 }
1370
1371 #[tokio::test]
1372 async fn test_create_task_rejects_non_epic_parent() {
1373 let tmp = tempfile::TempDir::new().unwrap();
1374 store::init(tmp.path()).unwrap();
1375
1376 let plain = make_task("ppp", Status::Open);
1377 store::save(tmp.path(), &plain).unwrap();
1378
1379 let tasks = store::load_all(tmp.path()).await.unwrap();
1380 let result = create_task(
1381 tmp.path(),
1382 &tasks,
1383 "Child".into(),
1384 Priority::P2,
1385 vec![],
1386 vec![],
1387 Some("ppp".into()),
1388 String::new(),
1389 TaskType::Task,
1390 );
1391 assert!(matches!(result, Err(Error::ParentNotEpic(_))));
1392 }
1393
1394 #[tokio::test]
1395 async fn test_create_task_resolves_dep_prefixes() {
1396 let tmp = tempfile::TempDir::new().unwrap();
1397 store::init(tmp.path()).unwrap();
1398
1399 let dep = Task::new("abcd".into(), "Dep".into(), Priority::P2);
1400 store::save(tmp.path(), &dep).unwrap();
1401
1402 let tasks = store::load_all(tmp.path()).await.unwrap();
1403 let t = create_task(
1404 tmp.path(),
1405 &tasks,
1406 "Uses prefixes".into(),
1407 Priority::P2,
1408 vec![],
1409 vec!["ab".into()],
1410 None,
1411 String::new(),
1412 TaskType::Task,
1413 )
1414 .unwrap();
1415 assert_eq!(t.depends_on, vec!["abcd"]);
1416 }
1417
1418 #[test]
1419 fn test_epic_progress_no_children() {
1420 let tasks = task_map(vec![make_epic("e1")]);
1421 let p = epic_progress(&tasks, "e1");
1422 assert_eq!(p.done, 0);
1423 assert_eq!(p.total, 0);
1424 }
1425
1426 #[test]
1427 fn test_epic_progress_mixed() {
1428 let tasks = task_map(vec![
1429 make_epic("e1"),
1430 make_child("c1", "e1", Status::Done),
1431 make_child("c2", "e1", Status::Open),
1432 make_child("c3", "e1", Status::InProgress),
1433 ]);
1434 let p = epic_progress(&tasks, "e1");
1435 assert_eq!(p.done, 1);
1436 assert_eq!(p.total, 3);
1437 }
1438
1439 #[test]
1440 fn test_epic_progress_all_done() {
1441 let tasks = task_map(vec![
1442 make_epic("e1"),
1443 make_child("c1", "e1", Status::Done),
1444 make_child("c2", "e1", Status::Done),
1445 ]);
1446 let p = epic_progress(&tasks, "e1");
1447 assert_eq!(p.done, 2);
1448 assert_eq!(p.total, 2);
1449 }
1450
1451 #[test]
1452 fn test_epic_progress_cancelled_excluded_from_total() {
1453 let tasks = task_map(vec![
1456 make_epic("e1"),
1457 make_child("c1", "e1", Status::Done),
1458 make_child("c2", "e1", Status::Cancelled),
1459 ]);
1460 let p = epic_progress(&tasks, "e1");
1461 assert_eq!(p.done, 1);
1462 assert_eq!(p.total, 1); }
1464
1465 #[test]
1466 fn test_epic_progress_mixed_with_cancelled() {
1467 let tasks = task_map(vec![
1468 make_epic("e1"),
1469 make_child("c1", "e1", Status::Done),
1470 make_child("c2", "e1", Status::Open),
1471 make_child("c3", "e1", Status::Cancelled),
1472 ]);
1473 let p = epic_progress(&tasks, "e1");
1474 assert_eq!(p.done, 1);
1475 assert_eq!(p.total, 2); }
1477
1478 #[tokio::test]
1479 async fn test_epic_auto_close_with_done_and_cancelled() {
1480 let tmp = tempfile::TempDir::new().unwrap();
1481 store::init(tmp.path()).unwrap();
1482
1483 let tasks = HashMap::new();
1484 let epic = create_task(
1485 tmp.path(),
1486 &tasks,
1487 "My Epic".into(),
1488 Priority::P1,
1489 vec![],
1490 vec![],
1491 None,
1492 String::new(),
1493 TaskType::Epic,
1494 )
1495 .unwrap();
1496
1497 let tasks = store::load_all(tmp.path()).await.unwrap();
1498 let child1 = create_task(
1499 tmp.path(),
1500 &tasks,
1501 "Child 1".into(),
1502 Priority::P2,
1503 vec![],
1504 vec![],
1505 Some(epic.id.clone()),
1506 String::new(),
1507 TaskType::Task,
1508 )
1509 .unwrap();
1510
1511 let tasks = store::load_all(tmp.path()).await.unwrap();
1512 let child2 = create_task(
1513 tmp.path(),
1514 &tasks,
1515 "Child 2".into(),
1516 Priority::P2,
1517 vec![],
1518 vec![],
1519 Some(epic.id.clone()),
1520 String::new(),
1521 TaskType::Task,
1522 )
1523 .unwrap();
1524
1525 let tasks = store::load_all(tmp.path()).await.unwrap();
1527 set_status(tmp.path(), &tasks, &child1.id, Status::Done).unwrap();
1528 let tasks = store::load_all(tmp.path()).await.unwrap();
1529 assert_eq!(tasks[&epic.id].status, Status::Open);
1530
1531 set_status(tmp.path(), &tasks, &child2.id, Status::Cancelled).unwrap();
1533 let tasks = store::load_all(tmp.path()).await.unwrap();
1534 assert_eq!(
1535 tasks[&epic.id].status,
1536 Status::Done,
1537 "epic should auto-close when children are [done, cancelled]"
1538 );
1539 }
1540
1541 #[tokio::test]
1542 async fn test_epic_auto_close_cancel_last_open_child() {
1543 let tmp = tempfile::TempDir::new().unwrap();
1544 store::init(tmp.path()).unwrap();
1545
1546 let tasks = HashMap::new();
1547 let epic = create_task(
1548 tmp.path(),
1549 &tasks,
1550 "My Epic".into(),
1551 Priority::P1,
1552 vec![],
1553 vec![],
1554 None,
1555 String::new(),
1556 TaskType::Epic,
1557 )
1558 .unwrap();
1559
1560 let tasks = store::load_all(tmp.path()).await.unwrap();
1561 let child1 = create_task(
1562 tmp.path(),
1563 &tasks,
1564 "Child 1".into(),
1565 Priority::P2,
1566 vec![],
1567 vec![],
1568 Some(epic.id.clone()),
1569 String::new(),
1570 TaskType::Task,
1571 )
1572 .unwrap();
1573
1574 let tasks = store::load_all(tmp.path()).await.unwrap();
1576 set_status(tmp.path(), &tasks, &child1.id, Status::Cancelled).unwrap();
1577 let tasks = store::load_all(tmp.path()).await.unwrap();
1578 assert_eq!(
1579 tasks[&epic.id].status,
1580 Status::Done,
1581 "epic should auto-close when cancelling the last open child"
1582 );
1583 }
1584
1585 #[tokio::test]
1586 async fn test_epic_auto_close() {
1587 let tmp = tempfile::TempDir::new().unwrap();
1588 store::init(tmp.path()).unwrap();
1589
1590 let tasks = HashMap::new();
1591 let epic = create_task(
1592 tmp.path(),
1593 &tasks,
1594 "My Epic".into(),
1595 Priority::P1,
1596 vec![],
1597 vec![],
1598 None,
1599 String::new(),
1600 TaskType::Epic,
1601 )
1602 .unwrap();
1603
1604 let tasks = store::load_all(tmp.path()).await.unwrap();
1605 let child1 = create_task(
1606 tmp.path(),
1607 &tasks,
1608 "Child 1".into(),
1609 Priority::P2,
1610 vec![],
1611 vec![],
1612 Some(epic.id.clone()),
1613 String::new(),
1614 TaskType::Task,
1615 )
1616 .unwrap();
1617
1618 let tasks = store::load_all(tmp.path()).await.unwrap();
1619 let child2 = create_task(
1620 tmp.path(),
1621 &tasks,
1622 "Child 2".into(),
1623 Priority::P2,
1624 vec![],
1625 vec![],
1626 Some(epic.id.clone()),
1627 String::new(),
1628 TaskType::Task,
1629 )
1630 .unwrap();
1631
1632 let tasks = store::load_all(tmp.path()).await.unwrap();
1634 set_status(tmp.path(), &tasks, &child1.id, Status::Done).unwrap();
1635 let tasks = store::load_all(tmp.path()).await.unwrap();
1636 assert_eq!(tasks[&epic.id].status, Status::Open);
1637
1638 set_status(tmp.path(), &tasks, &child2.id, Status::Done).unwrap();
1640 let tasks = store::load_all(tmp.path()).await.unwrap();
1641 assert_eq!(tasks[&epic.id].status, Status::Done);
1642 }
1643
1644 #[tokio::test]
1645 async fn test_epic_auto_close_via_update_task() {
1646 let tmp = tempfile::TempDir::new().unwrap();
1647 store::init(tmp.path()).unwrap();
1648
1649 let tasks = HashMap::new();
1650 let epic = create_task(
1651 tmp.path(),
1652 &tasks,
1653 "My Epic".into(),
1654 Priority::P1,
1655 vec![],
1656 vec![],
1657 None,
1658 String::new(),
1659 TaskType::Epic,
1660 )
1661 .unwrap();
1662
1663 let tasks = store::load_all(tmp.path()).await.unwrap();
1664 let child1 = create_task(
1665 tmp.path(),
1666 &tasks,
1667 "Child 1".into(),
1668 Priority::P2,
1669 vec![],
1670 vec![],
1671 Some(epic.id.clone()),
1672 String::new(),
1673 TaskType::Task,
1674 )
1675 .unwrap();
1676
1677 let tasks = store::load_all(tmp.path()).await.unwrap();
1678 let child2 = create_task(
1679 tmp.path(),
1680 &tasks,
1681 "Child 2".into(),
1682 Priority::P2,
1683 vec![],
1684 vec![],
1685 Some(epic.id.clone()),
1686 String::new(),
1687 TaskType::Task,
1688 )
1689 .unwrap();
1690
1691 let tasks = store::load_all(tmp.path()).await.unwrap();
1693 update_task(
1694 tmp.path(),
1695 &tasks,
1696 &child1.id,
1697 Some(Status::Done),
1698 None,
1699 None,
1700 None,
1701 None,
1702 None,
1703 None,
1704 )
1705 .unwrap();
1706 let tasks = store::load_all(tmp.path()).await.unwrap();
1707 assert_eq!(tasks[&epic.id].status, Status::Open);
1708
1709 update_task(
1711 tmp.path(),
1712 &tasks,
1713 &child2.id,
1714 Some(Status::Done),
1715 None,
1716 None,
1717 None,
1718 None,
1719 None,
1720 None,
1721 )
1722 .unwrap();
1723 let tasks = store::load_all(tmp.path()).await.unwrap();
1724 assert_eq!(tasks[&epic.id].status, Status::Done);
1725 }
1726
1727 #[tokio::test]
1728 async fn test_epic_no_over_close_on_re_complete() {
1729 let tmp = tempfile::TempDir::new().unwrap();
1732 store::init(tmp.path()).unwrap();
1733
1734 let tasks = HashMap::new();
1735 let epic = create_task(
1736 tmp.path(),
1737 &tasks,
1738 "My Epic".into(),
1739 Priority::P1,
1740 vec![],
1741 vec![],
1742 None,
1743 String::new(),
1744 TaskType::Epic,
1745 )
1746 .unwrap();
1747
1748 let tasks = store::load_all(tmp.path()).await.unwrap();
1749 let child1 = create_task(
1750 tmp.path(),
1751 &tasks,
1752 "Child 1".into(),
1753 Priority::P2,
1754 vec![],
1755 vec![],
1756 Some(epic.id.clone()),
1757 String::new(),
1758 TaskType::Task,
1759 )
1760 .unwrap();
1761
1762 let tasks = store::load_all(tmp.path()).await.unwrap();
1763 let _child2 = create_task(
1764 tmp.path(),
1765 &tasks,
1766 "Child 2".into(),
1767 Priority::P2,
1768 vec![],
1769 vec![],
1770 Some(epic.id.clone()),
1771 String::new(),
1772 TaskType::Task,
1773 )
1774 .unwrap();
1775
1776 let tasks = store::load_all(tmp.path()).await.unwrap();
1778 set_status(tmp.path(), &tasks, &child1.id, Status::Done).unwrap();
1779 let tasks = store::load_all(tmp.path()).await.unwrap();
1780 assert_eq!(
1781 tasks[&epic.id].status,
1782 Status::Open,
1783 "epic should stay open"
1784 );
1785
1786 set_status(tmp.path(), &tasks, &child1.id, Status::Done).unwrap();
1788 let tasks = store::load_all(tmp.path()).await.unwrap();
1789 assert_eq!(
1790 tasks[&epic.id].status,
1791 Status::Open,
1792 "epic must not close when re-completing an already-done child while another is open"
1793 );
1794 }
1795
1796 #[tokio::test]
1797 async fn test_epic_cascade_auto_close_nested() {
1798 let tmp = tempfile::TempDir::new().unwrap();
1806 store::init(tmp.path()).unwrap();
1807
1808 let tasks = HashMap::new();
1809 let outer = create_task(
1810 tmp.path(),
1811 &tasks,
1812 "Outer Epic".into(),
1813 Priority::P1,
1814 vec![],
1815 vec![],
1816 None,
1817 String::new(),
1818 TaskType::Epic,
1819 )
1820 .unwrap();
1821
1822 let tasks = store::load_all(tmp.path()).await.unwrap();
1823 let inner = create_task(
1824 tmp.path(),
1825 &tasks,
1826 "Inner Epic".into(),
1827 Priority::P1,
1828 vec![],
1829 vec![],
1830 Some(outer.id.clone()),
1831 String::new(),
1832 TaskType::Epic,
1833 )
1834 .unwrap();
1835
1836 let tasks = store::load_all(tmp.path()).await.unwrap();
1837 let leaf1 = create_task(
1838 tmp.path(),
1839 &tasks,
1840 "Leaf 1".into(),
1841 Priority::P2,
1842 vec![],
1843 vec![],
1844 Some(inner.id.clone()),
1845 String::new(),
1846 TaskType::Task,
1847 )
1848 .unwrap();
1849
1850 let tasks = store::load_all(tmp.path()).await.unwrap();
1851 let leaf2 = create_task(
1852 tmp.path(),
1853 &tasks,
1854 "Leaf 2".into(),
1855 Priority::P2,
1856 vec![],
1857 vec![],
1858 Some(inner.id.clone()),
1859 String::new(),
1860 TaskType::Task,
1861 )
1862 .unwrap();
1863
1864 let tasks = store::load_all(tmp.path()).await.unwrap();
1866 set_status(tmp.path(), &tasks, &leaf1.id, Status::Done).unwrap();
1867 let tasks = store::load_all(tmp.path()).await.unwrap();
1868 assert_eq!(
1869 tasks[&inner.id].status,
1870 Status::Open,
1871 "inner should stay open"
1872 );
1873 assert_eq!(
1874 tasks[&outer.id].status,
1875 Status::Open,
1876 "outer should stay open"
1877 );
1878
1879 set_status(tmp.path(), &tasks, &leaf2.id, Status::Done).unwrap();
1881 let tasks = store::load_all(tmp.path()).await.unwrap();
1882 assert_eq!(
1883 tasks[&inner.id].status,
1884 Status::Done,
1885 "inner epic should auto-close when all its children are done"
1886 );
1887 assert_eq!(
1888 tasks[&outer.id].status,
1889 Status::Done,
1890 "outer epic should cascade-close when inner epic closes"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_plan_epic_linear_chain() {
1896 let mut c1 = make_child("c1", "e1", Status::Open);
1897 c1.depends_on = vec![];
1898 let mut c2 = make_child("c2", "e1", Status::Open);
1899 c2.depends_on = vec!["c1".to_string()];
1900 let mut c3 = make_child("c3", "e1", Status::Open);
1901 c3.depends_on = vec!["c2".to_string()];
1902
1903 let tasks = task_map(vec![make_epic("e1"), c1, c2, c3]);
1904 let plan = plan_epic(&tasks, "e1").unwrap();
1905 let ids: Vec<&str> = plan.tasks.iter().map(|t| t.id.as_str()).collect();
1906 assert_eq!(ids, vec!["c1", "c2", "c3"]);
1907 assert!(plan.cyclic.is_empty());
1908 }
1909
1910 #[test]
1911 fn test_plan_epic_independent_children() {
1912 let tasks = task_map(vec![
1913 make_epic("e1"),
1914 make_child("c1", "e1", Status::Open),
1915 make_child("c2", "e1", Status::Open),
1916 ]);
1917 let plan = plan_epic(&tasks, "e1").unwrap();
1918 assert_eq!(plan.tasks.len(), 2);
1919 }
1920
1921 #[test]
1922 fn test_plan_epic_no_children() {
1923 let tasks = task_map(vec![make_epic("e1")]);
1924 let plan = plan_epic(&tasks, "e1").unwrap();
1925 assert!(plan.tasks.is_empty());
1926 assert!(plan.cyclic.is_empty());
1927 }
1928
1929 #[test]
1930 fn test_plan_epic_not_found() {
1931 let tasks = task_map(vec![]);
1932 let result = plan_epic(&tasks, "nonexistent");
1933 assert!(result.is_err());
1934 }
1935
1936 #[test]
1937 fn test_plan_epic_non_epic_parent() {
1938 let parent = make_task("p1", Status::Open);
1940 let tasks = task_map(vec![
1941 parent,
1942 make_child("c1", "p1", Status::Open),
1943 make_child("c2", "p1", Status::Done),
1944 ]);
1945 let result = plan_epic(&tasks, "p1");
1946 assert!(result.is_err());
1947 }
1948
1949 #[tokio::test]
1950 async fn test_parent_prefix_stored_as_canonical_id() {
1951 let tmp = tempfile::TempDir::new().unwrap();
1954 store::init(tmp.path()).unwrap();
1955
1956 let mut epic = Task::new("epicid".into(), "Epic".into(), Priority::P1);
1957 epic.task_type = TaskType::Epic;
1958 store::save(tmp.path(), &epic).unwrap();
1959 let child = Task::new("chld".into(), "Existing child".into(), Priority::P2);
1960 store::save(tmp.path(), &child).unwrap();
1961
1962 let tasks = store::load_all(tmp.path()).await.unwrap();
1964 let updated = update_task(
1965 tmp.path(),
1966 &tasks,
1967 "chld",
1968 None,
1969 None,
1970 None,
1971 None,
1972 None,
1973 None,
1974 Some(Some("epi".into())),
1975 )
1976 .unwrap();
1977 assert_eq!(
1978 updated.parent.as_deref(),
1979 Some("epicid"),
1980 "update_task should store the resolved full parent id, not the prefix"
1981 );
1982
1983 let tasks = store::load_all(tmp.path()).await.unwrap();
1985 let created = create_task(
1986 tmp.path(),
1987 &tasks,
1988 "New child".into(),
1989 Priority::P2,
1990 vec![],
1991 vec![],
1992 Some("epi".into()),
1993 String::new(),
1994 TaskType::Task,
1995 )
1996 .unwrap();
1997 assert_eq!(
1998 created.parent.as_deref(),
1999 Some("epicid"),
2000 "create_task should store the resolved full parent id, not the prefix"
2001 );
2002
2003 let tasks = store::load_all(tmp.path()).await.unwrap();
2005 assert_eq!(epic_progress(&tasks, "epicid").total, 2);
2006 }
2007
2008 #[test]
2011 fn test_is_archivable_done_no_dependents() {
2012 let t = make_task("t1", Status::Done);
2013 let tasks = task_map(vec![t.clone()]);
2014 assert!(is_archivable(&t, &tasks));
2015 }
2016
2017 #[test]
2018 fn test_is_archivable_cancelled_no_dependents() {
2019 let t = make_task("t1", Status::Cancelled);
2020 let tasks = task_map(vec![t.clone()]);
2021 assert!(is_archivable(&t, &tasks));
2022 }
2023
2024 #[test]
2025 fn test_is_archivable_open_is_false() {
2026 let t = make_task("t1", Status::Open);
2027 let tasks = task_map(vec![t.clone()]);
2028 assert!(!is_archivable(&t, &tasks));
2029 }
2030
2031 #[test]
2032 fn test_is_archivable_in_progress_is_false() {
2033 let mut t = make_task("t1", Status::Done);
2034 t.status = Status::InProgress;
2035 let tasks = task_map(vec![t.clone()]);
2036 assert!(!is_archivable(&t, &tasks));
2037 }
2038
2039 #[test]
2040 fn test_is_archivable_done_with_active_dependent_is_false() {
2041 let t1 = make_task("t1", Status::Done);
2043 let mut t2 = make_task("t2", Status::Open);
2044 t2.depends_on = vec!["t1".to_string()];
2045 let tasks = task_map(vec![t1.clone(), t2]);
2046 assert!(!is_archivable(&t1, &tasks));
2047 }
2048
2049 #[test]
2050 fn test_is_archivable_done_dependent_is_ok() {
2051 let t1 = make_task("t1", Status::Done);
2053 let mut t2 = make_task("t2", Status::Done);
2054 t2.depends_on = vec!["t1".to_string()];
2055 let tasks = task_map(vec![t1.clone(), t2]);
2056 assert!(is_archivable(&t1, &tasks));
2057 }
2058
2059 #[tokio::test]
2060 async fn test_archive_task_basic() {
2061 let tmp = tempfile::TempDir::new().unwrap();
2062 store::init(tmp.path()).unwrap();
2063
2064 let tasks = HashMap::new();
2065 let t = create_task(
2066 tmp.path(),
2067 &tasks,
2068 "Done task".into(),
2069 Priority::P2,
2070 vec![],
2071 vec![],
2072 None,
2073 String::new(),
2074 TaskType::Task,
2075 )
2076 .unwrap();
2077
2078 let tasks = store::load_all(tmp.path()).await.unwrap();
2079 set_status(tmp.path(), &tasks, &t.id, Status::Done).unwrap();
2080 let tasks = store::load_all(tmp.path()).await.unwrap();
2081
2082 let archived = archive_task(tmp.path(), &tasks, &t.id).unwrap();
2083 assert_eq!(archived.len(), 1);
2084 assert_eq!(archived[0], t.id);
2085
2086 let active = store::load_all(tmp.path()).await.unwrap();
2088 assert!(!active.contains_key(&t.id));
2089
2090 let arch = store::load_archived(tmp.path()).await.unwrap();
2092 assert!(arch.contains_key(&t.id));
2093 }
2094
2095 #[tokio::test]
2096 async fn test_archive_task_blocked_by_active_dependent() {
2097 let tmp = tempfile::TempDir::new().unwrap();
2098 store::init(tmp.path()).unwrap();
2099
2100 let tasks = HashMap::new();
2101 let dep = create_task(
2102 tmp.path(),
2103 &tasks,
2104 "Dep task".into(),
2105 Priority::P2,
2106 vec![],
2107 vec![],
2108 None,
2109 String::new(),
2110 TaskType::Task,
2111 )
2112 .unwrap();
2113
2114 let tasks = store::load_all(tmp.path()).await.unwrap();
2115 let _dependent = create_task(
2117 tmp.path(),
2118 &tasks,
2119 "Dependent".into(),
2120 Priority::P2,
2121 vec![],
2122 vec![dep.id.clone()],
2123 None,
2124 String::new(),
2125 TaskType::Task,
2126 )
2127 .unwrap();
2128
2129 let tasks = store::load_all(tmp.path()).await.unwrap();
2131 set_status(tmp.path(), &tasks, &dep.id, Status::Done).unwrap();
2132 let tasks = store::load_all(tmp.path()).await.unwrap();
2133
2134 let result = archive_task(tmp.path(), &tasks, &dep.id);
2135 assert!(
2136 matches!(result, Err(Error::NotArchivable { .. })),
2137 "should fail with NotArchivable"
2138 );
2139 }
2140
2141 #[tokio::test]
2142 async fn test_archive_task_open_is_rejected() {
2143 let tmp = tempfile::TempDir::new().unwrap();
2144 store::init(tmp.path()).unwrap();
2145
2146 let tasks = HashMap::new();
2147 let t = create_task(
2148 tmp.path(),
2149 &tasks,
2150 "Open task".into(),
2151 Priority::P2,
2152 vec![],
2153 vec![],
2154 None,
2155 String::new(),
2156 TaskType::Task,
2157 )
2158 .unwrap();
2159
2160 let tasks = store::load_all(tmp.path()).await.unwrap();
2161 let result = archive_task(tmp.path(), &tasks, &t.id);
2162 assert!(
2163 matches!(result, Err(Error::NotArchivable { .. })),
2164 "open task should not be archivable"
2165 );
2166 }
2167
2168 #[tokio::test]
2169 async fn test_archive_task_epic_cascades_to_settled_children() {
2170 let tmp = tempfile::TempDir::new().unwrap();
2171 store::init(tmp.path()).unwrap();
2172
2173 let tasks = HashMap::new();
2174 let epic = create_task(
2175 tmp.path(),
2176 &tasks,
2177 "Epic".into(),
2178 Priority::P1,
2179 vec![],
2180 vec![],
2181 None,
2182 String::new(),
2183 TaskType::Epic,
2184 )
2185 .unwrap();
2186
2187 let tasks = store::load_all(tmp.path()).await.unwrap();
2188 let c1 = create_task(
2189 tmp.path(),
2190 &tasks,
2191 "Child 1".into(),
2192 Priority::P2,
2193 vec![],
2194 vec![],
2195 Some(epic.id.clone()),
2196 String::new(),
2197 TaskType::Task,
2198 )
2199 .unwrap();
2200
2201 let tasks = store::load_all(tmp.path()).await.unwrap();
2202 let c2 = create_task(
2203 tmp.path(),
2204 &tasks,
2205 "Child 2".into(),
2206 Priority::P2,
2207 vec![],
2208 vec![],
2209 Some(epic.id.clone()),
2210 String::new(),
2211 TaskType::Task,
2212 )
2213 .unwrap();
2214
2215 let tasks = store::load_all(tmp.path()).await.unwrap();
2217 set_status(tmp.path(), &tasks, &c1.id, Status::Done).unwrap();
2218 let tasks = store::load_all(tmp.path()).await.unwrap();
2219 set_status(tmp.path(), &tasks, &c2.id, Status::Done).unwrap();
2220 let tasks = store::load_all(tmp.path()).await.unwrap();
2221 set_status(tmp.path(), &tasks, &epic.id, Status::Done).unwrap();
2223 let tasks = store::load_all(tmp.path()).await.unwrap();
2224
2225 let mut archived_ids = archive_task(tmp.path(), &tasks, &epic.id).unwrap();
2226 archived_ids.sort();
2227
2228 assert_eq!(archived_ids.len(), 3, "epic + 2 children");
2230 assert!(archived_ids.contains(&epic.id));
2231 assert!(archived_ids.contains(&c1.id));
2232 assert!(archived_ids.contains(&c2.id));
2233
2234 let active = store::load_all(tmp.path()).await.unwrap();
2235 assert!(active.is_empty());
2236 }
2237
2238 #[tokio::test]
2239 async fn test_archive_all_sweep() {
2240 let tmp = tempfile::TempDir::new().unwrap();
2241 store::init(tmp.path()).unwrap();
2242
2243 let tasks = HashMap::new();
2244 let t1 = create_task(
2245 tmp.path(),
2246 &tasks,
2247 "Done 1".into(),
2248 Priority::P2,
2249 vec![],
2250 vec![],
2251 None,
2252 String::new(),
2253 TaskType::Task,
2254 )
2255 .unwrap();
2256
2257 let tasks = store::load_all(tmp.path()).await.unwrap();
2258 let t2 = create_task(
2259 tmp.path(),
2260 &tasks,
2261 "Open".into(),
2262 Priority::P2,
2263 vec![],
2264 vec![],
2265 None,
2266 String::new(),
2267 TaskType::Task,
2268 )
2269 .unwrap();
2270
2271 let tasks = store::load_all(tmp.path()).await.unwrap();
2272 let t3 = create_task(
2273 tmp.path(),
2274 &tasks,
2275 "Done 2".into(),
2276 Priority::P2,
2277 vec![],
2278 vec![],
2279 None,
2280 String::new(),
2281 TaskType::Task,
2282 )
2283 .unwrap();
2284
2285 let tasks = store::load_all(tmp.path()).await.unwrap();
2286 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
2287 let tasks = store::load_all(tmp.path()).await.unwrap();
2288 set_status(tmp.path(), &tasks, &t3.id, Status::Done).unwrap();
2289 let tasks = store::load_all(tmp.path()).await.unwrap();
2290
2291 let archived_ids = archive_all(tmp.path(), &tasks).unwrap();
2292 assert_eq!(archived_ids.len(), 2);
2293 assert!(archived_ids.contains(&t1.id));
2294 assert!(archived_ids.contains(&t3.id));
2295
2296 let active = store::load_all(tmp.path()).await.unwrap();
2297 assert_eq!(active.len(), 1);
2298 assert!(active.contains_key(&t2.id));
2299 }
2300
2301 #[tokio::test]
2302 async fn test_archive_all_sweep_cascades_chain() {
2303 let tmp = tempfile::TempDir::new().unwrap();
2307 store::init(tmp.path()).unwrap();
2308
2309 let tasks = HashMap::new();
2310 let t1 = create_task(
2311 tmp.path(),
2312 &tasks,
2313 "Base done".into(),
2314 Priority::P2,
2315 vec![],
2316 vec![],
2317 None,
2318 String::new(),
2319 TaskType::Task,
2320 )
2321 .unwrap();
2322
2323 let tasks = store::load_all(tmp.path()).await.unwrap();
2324 let t2 = create_task(
2325 tmp.path(),
2326 &tasks,
2327 "Dependent done".into(),
2328 Priority::P2,
2329 vec![],
2330 vec![t1.id.clone()],
2331 None,
2332 String::new(),
2333 TaskType::Task,
2334 )
2335 .unwrap();
2336
2337 let tasks = store::load_all(tmp.path()).await.unwrap();
2338 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
2339 let tasks = store::load_all(tmp.path()).await.unwrap();
2340 set_status(tmp.path(), &tasks, &t2.id, Status::Done).unwrap();
2341 let tasks = store::load_all(tmp.path()).await.unwrap();
2342
2343 let archived_ids = archive_all(tmp.path(), &tasks).unwrap();
2344 assert_eq!(archived_ids.len(), 2, "both should be archived");
2345 assert!(archived_ids.contains(&t1.id));
2346 assert!(archived_ids.contains(&t2.id));
2347 }
2348
2349 #[tokio::test]
2350 async fn test_restore_task_basic() {
2351 let tmp = tempfile::TempDir::new().unwrap();
2352 store::init(tmp.path()).unwrap();
2353
2354 let tasks = HashMap::new();
2355 let t = create_task(
2356 tmp.path(),
2357 &tasks,
2358 "Task to restore".into(),
2359 Priority::P2,
2360 vec![],
2361 vec![],
2362 None,
2363 String::new(),
2364 TaskType::Task,
2365 )
2366 .unwrap();
2367
2368 let tasks = store::load_all(tmp.path()).await.unwrap();
2369 set_status(tmp.path(), &tasks, &t.id, Status::Done).unwrap();
2370 let tasks = store::load_all(tmp.path()).await.unwrap();
2371 archive_task(tmp.path(), &tasks, &t.id).unwrap();
2372
2373 let active = store::load_all(tmp.path()).await.unwrap();
2374 assert!(!active.contains_key(&t.id));
2375
2376 let restored = restore_task(tmp.path(), &t.id).await.unwrap();
2377 assert_eq!(restored.len(), 1);
2378
2379 let active = store::load_all(tmp.path()).await.unwrap();
2380 assert!(active.contains_key(&t.id));
2381 }
2382
2383 #[tokio::test]
2384 async fn test_restore_task_cascades_deps() {
2385 let tmp = tempfile::TempDir::new().unwrap();
2388 store::init(tmp.path()).unwrap();
2389
2390 let tasks = HashMap::new();
2391 let t1 = create_task(
2392 tmp.path(),
2393 &tasks,
2394 "Dep".into(),
2395 Priority::P2,
2396 vec![],
2397 vec![],
2398 None,
2399 String::new(),
2400 TaskType::Task,
2401 )
2402 .unwrap();
2403
2404 let tasks = store::load_all(tmp.path()).await.unwrap();
2405 let t2 = create_task(
2406 tmp.path(),
2407 &tasks,
2408 "Dependent".into(),
2409 Priority::P2,
2410 vec![],
2411 vec![t1.id.clone()],
2412 None,
2413 String::new(),
2414 TaskType::Task,
2415 )
2416 .unwrap();
2417
2418 let tasks = store::load_all(tmp.path()).await.unwrap();
2419 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
2420 let tasks = store::load_all(tmp.path()).await.unwrap();
2421 set_status(tmp.path(), &tasks, &t2.id, Status::Done).unwrap();
2422 let tasks = store::load_all(tmp.path()).await.unwrap();
2423
2424 archive_all(tmp.path(), &tasks).unwrap();
2426
2427 let active = store::load_all(tmp.path()).await.unwrap();
2428 assert!(active.is_empty());
2429
2430 let mut restored = restore_task(tmp.path(), &t2.id).await.unwrap();
2432 restored.sort();
2433
2434 assert_eq!(restored.len(), 2);
2435 assert!(restored.contains(&t1.id));
2436 assert!(restored.contains(&t2.id));
2437
2438 let active = store::load_all(tmp.path()).await.unwrap();
2439 assert!(active.contains_key(&t1.id));
2440 assert!(active.contains_key(&t2.id));
2441 }
2442
2443 #[tokio::test]
2444 async fn test_restore_task_cascades_parent_epic() {
2445 let tmp = tempfile::TempDir::new().unwrap();
2447 store::init(tmp.path()).unwrap();
2448
2449 let tasks = HashMap::new();
2450 let epic = create_task(
2451 tmp.path(),
2452 &tasks,
2453 "Epic".into(),
2454 Priority::P1,
2455 vec![],
2456 vec![],
2457 None,
2458 String::new(),
2459 TaskType::Epic,
2460 )
2461 .unwrap();
2462
2463 let tasks = store::load_all(tmp.path()).await.unwrap();
2464 let child = create_task(
2465 tmp.path(),
2466 &tasks,
2467 "Child".into(),
2468 Priority::P2,
2469 vec![],
2470 vec![],
2471 Some(epic.id.clone()),
2472 String::new(),
2473 TaskType::Task,
2474 )
2475 .unwrap();
2476
2477 let tasks = store::load_all(tmp.path()).await.unwrap();
2478 set_status(tmp.path(), &tasks, &child.id, Status::Done).unwrap();
2479 let tasks = store::load_all(tmp.path()).await.unwrap();
2480 set_status(tmp.path(), &tasks, &epic.id, Status::Done).unwrap();
2482 let tasks = store::load_all(tmp.path()).await.unwrap();
2483 archive_task(tmp.path(), &tasks, &epic.id).unwrap();
2484
2485 let active = store::load_all(tmp.path()).await.unwrap();
2486 assert!(active.is_empty());
2487
2488 let mut restored = restore_task(tmp.path(), &child.id).await.unwrap();
2490 restored.sort();
2491 assert!(restored.contains(&epic.id), "epic should be restored");
2492 assert!(restored.contains(&child.id), "child should be restored");
2493 }
2494
2495 #[tokio::test]
2496 async fn test_restore_not_archived_error() {
2497 let tmp = tempfile::TempDir::new().unwrap();
2498 store::init(tmp.path()).unwrap();
2499
2500 let result = restore_task(tmp.path(), "nonexistent").await;
2501 assert!(
2502 matches!(result, Err(Error::NotArchived(_))),
2503 "should get NotArchived error"
2504 );
2505 }
2506
2507 #[tokio::test]
2508 async fn test_get_archived_task() {
2509 let tmp = tempfile::TempDir::new().unwrap();
2510 store::init(tmp.path()).unwrap();
2511
2512 let tasks = HashMap::new();
2513 let t = create_task(
2514 tmp.path(),
2515 &tasks,
2516 "Archived task".into(),
2517 Priority::P2,
2518 vec![],
2519 vec![],
2520 None,
2521 String::new(),
2522 TaskType::Task,
2523 )
2524 .unwrap();
2525
2526 let tasks = store::load_all(tmp.path()).await.unwrap();
2527 set_status(tmp.path(), &tasks, &t.id, Status::Done).unwrap();
2528 let tasks = store::load_all(tmp.path()).await.unwrap();
2529 archive_task(tmp.path(), &tasks, &t.id).unwrap();
2530
2531 let fetched = get_archived_task(tmp.path(), &t.id).await.unwrap();
2532 assert_eq!(fetched.id, t.id);
2533 assert_eq!(fetched.title, "Archived task");
2534 }
2535
2536 #[tokio::test]
2537 async fn test_create_task_avoids_archived_id_collision() {
2538 let tmp = tempfile::TempDir::new().unwrap();
2543 store::init(tmp.path()).unwrap();
2544
2545 let tasks = HashMap::new();
2546 let t = create_task(
2547 tmp.path(),
2548 &tasks,
2549 "Task 1".into(),
2550 Priority::P2,
2551 vec![],
2552 vec![],
2553 None,
2554 String::new(),
2555 TaskType::Task,
2556 )
2557 .unwrap();
2558
2559 let tasks = store::load_all(tmp.path()).await.unwrap();
2560 set_status(tmp.path(), &tasks, &t.id, Status::Done).unwrap();
2561 let tasks = store::load_all(tmp.path()).await.unwrap();
2562 archive_task(tmp.path(), &tasks, &t.id).unwrap();
2563
2564 let tasks = store::load_all(tmp.path()).await.unwrap();
2566 let archived_ids = store::archived_id_set(tmp.path());
2568 assert!(archived_ids.contains(&t.id));
2569
2570 let t2 = create_task(
2574 tmp.path(),
2575 &tasks,
2576 "Task 2".into(),
2577 Priority::P2,
2578 vec![],
2579 vec![],
2580 None,
2581 String::new(),
2582 TaskType::Task,
2583 )
2584 .unwrap();
2585 assert_ne!(t2.id, t.id, "new task must not reuse archived ID");
2586 }
2587
2588 #[tokio::test]
2591 async fn test_list_archive_sorted_by_updated_desc() {
2592 let tmp = tempfile::TempDir::new().unwrap();
2593 store::init(tmp.path()).unwrap();
2594
2595 let tasks = HashMap::new();
2598 let t1 = create_task(
2599 tmp.path(),
2600 &tasks,
2601 "First".into(),
2602 Priority::P2,
2603 vec![],
2604 vec![],
2605 None,
2606 String::new(),
2607 TaskType::Task,
2608 )
2609 .unwrap();
2610
2611 let tasks = store::load_all(tmp.path()).await.unwrap();
2612 let t2 = create_task(
2613 tmp.path(),
2614 &tasks,
2615 "Second".into(),
2616 Priority::P2,
2617 vec![],
2618 vec![],
2619 None,
2620 String::new(),
2621 TaskType::Task,
2622 )
2623 .unwrap();
2624
2625 let tasks = store::load_all(tmp.path()).await.unwrap();
2626 let t3 = create_task(
2627 tmp.path(),
2628 &tasks,
2629 "Third".into(),
2630 Priority::P2,
2631 vec![],
2632 vec![],
2633 None,
2634 String::new(),
2635 TaskType::Task,
2636 )
2637 .unwrap();
2638
2639 let tasks = store::load_all(tmp.path()).await.unwrap();
2641 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
2642 let tasks = store::load_all(tmp.path()).await.unwrap();
2643 set_status(tmp.path(), &tasks, &t2.id, Status::Done).unwrap();
2644 let tasks = store::load_all(tmp.path()).await.unwrap();
2645 set_status(tmp.path(), &tasks, &t3.id, Status::Done).unwrap();
2646 let tasks = store::load_all(tmp.path()).await.unwrap();
2647 archive_all(tmp.path(), &tasks).unwrap();
2648
2649 let listed = list_archive(tmp.path(), None).await.unwrap();
2651 assert_eq!(listed.len(), 3);
2652 let listed_ids: Vec<&str> = listed.iter().map(|t| t.id.as_str()).collect();
2655 assert!(listed_ids.contains(&t1.id.as_str()));
2656 assert!(listed_ids.contains(&t2.id.as_str()));
2657 assert!(listed_ids.contains(&t3.id.as_str()));
2658 for w in listed.windows(2) {
2660 assert!(
2661 w[0].updated >= w[1].updated,
2662 "list_archive must be sorted updated desc"
2663 );
2664 }
2665 }
2666
2667 #[tokio::test]
2668 async fn test_list_archive_with_limit() {
2669 let tmp = tempfile::TempDir::new().unwrap();
2670 store::init(tmp.path()).unwrap();
2671
2672 let tasks = HashMap::new();
2673 let t1 = create_task(
2674 tmp.path(),
2675 &tasks,
2676 "T1".into(),
2677 Priority::P2,
2678 vec![],
2679 vec![],
2680 None,
2681 String::new(),
2682 TaskType::Task,
2683 )
2684 .unwrap();
2685
2686 let tasks = store::load_all(tmp.path()).await.unwrap();
2687 let t2 = create_task(
2688 tmp.path(),
2689 &tasks,
2690 "T2".into(),
2691 Priority::P2,
2692 vec![],
2693 vec![],
2694 None,
2695 String::new(),
2696 TaskType::Task,
2697 )
2698 .unwrap();
2699
2700 let tasks = store::load_all(tmp.path()).await.unwrap();
2701 set_status(tmp.path(), &tasks, &t1.id, Status::Done).unwrap();
2702 let tasks = store::load_all(tmp.path()).await.unwrap();
2703 set_status(tmp.path(), &tasks, &t2.id, Status::Done).unwrap();
2704 let tasks = store::load_all(tmp.path()).await.unwrap();
2705 archive_all(tmp.path(), &tasks).unwrap();
2706
2707 let listed = list_archive(tmp.path(), Some(1)).await.unwrap();
2708 assert_eq!(listed.len(), 1, "limit=1 should return exactly 1 task");
2709 }
2710
2711 #[tokio::test]
2712 async fn test_list_archive_empty() {
2713 let tmp = tempfile::TempDir::new().unwrap();
2714 store::init(tmp.path()).unwrap();
2715
2716 let listed = list_archive(tmp.path(), None).await.unwrap();
2717 assert!(listed.is_empty());
2718 }
2719
2720 #[tokio::test]
2723 async fn test_start_task_with_assignee() {
2724 let tmp = tempfile::TempDir::new().unwrap();
2725 store::init(tmp.path()).unwrap();
2726 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
2727
2728 let tasks = store::load_all(tmp.path()).await.unwrap();
2729 let t = start_task(tmp.path(), &tasks, "aaa", Some("agent-1".into())).unwrap();
2730 assert_eq!(t.status, Status::InProgress);
2731 assert_eq!(t.assignee, "agent-1");
2732
2733 let tasks = store::load_all(tmp.path()).await.unwrap();
2734 assert_eq!(tasks["aaa"].assignee, "agent-1", "assignee should persist");
2735 }
2736
2737 #[tokio::test]
2738 async fn test_start_task_without_assignee_leaves_it_unchanged() {
2739 let tmp = tempfile::TempDir::new().unwrap();
2740 store::init(tmp.path()).unwrap();
2741 let mut a = make_task("aaa", Status::Open);
2742 a.assignee = "agent-1".into();
2743 store::save(tmp.path(), &a).unwrap();
2744
2745 let tasks = store::load_all(tmp.path()).await.unwrap();
2746 let t = start_task(tmp.path(), &tasks, "aaa", None).unwrap();
2747 assert_eq!(t.status, Status::InProgress);
2748 assert_eq!(t.assignee, "agent-1", "omitted assignee must not clear it");
2749 }
2750
2751 #[tokio::test]
2752 async fn test_start_task_empty_assignee_clears_it() {
2753 let tmp = tempfile::TempDir::new().unwrap();
2754 store::init(tmp.path()).unwrap();
2755 let mut a = make_task("aaa", Status::Open);
2756 a.assignee = "agent-1".into();
2757 store::save(tmp.path(), &a).unwrap();
2758
2759 let tasks = store::load_all(tmp.path()).await.unwrap();
2760 let t = start_task(tmp.path(), &tasks, "aaa", Some(String::new())).unwrap();
2761 assert_eq!(t.assignee, "");
2762 }
2763
2764 #[tokio::test]
2765 async fn test_release_resets_status_and_assignee() {
2766 let tmp = tempfile::TempDir::new().unwrap();
2767 store::init(tmp.path()).unwrap();
2768 let mut a = make_task("aaa", Status::InProgress);
2769 a.assignee = "agent-1".into();
2770 store::save(tmp.path(), &a).unwrap();
2771
2772 let tasks = store::load_all(tmp.path()).await.unwrap();
2773 let t = release_task(tmp.path(), &tasks, "aaa").unwrap();
2774 assert_eq!(t.status, Status::Open);
2775 assert_eq!(t.assignee, "");
2776
2777 let tasks = store::load_all(tmp.path()).await.unwrap();
2778 assert_eq!(tasks["aaa"].status, Status::Open);
2779 assert_eq!(tasks["aaa"].assignee, "");
2780 }
2781
2782 #[tokio::test]
2783 async fn test_release_rejects_non_in_progress_tasks() {
2784 let tmp = tempfile::TempDir::new().unwrap();
2785 store::init(tmp.path()).unwrap();
2786 for status in [
2787 Status::Open,
2788 Status::Done,
2789 Status::Cancelled,
2790 Status::Blocked,
2791 ] {
2792 let mut a = make_task("aaa", status);
2793 a.assignee = "agent-1".into();
2794 store::save(tmp.path(), &a).unwrap();
2795
2796 let tasks = store::load_all(tmp.path()).await.unwrap();
2797 let err = release_task(tmp.path(), &tasks, "aaa").unwrap_err();
2798 assert!(
2799 matches!(
2800 err,
2801 Error::InvalidStatus {
2802 action: "release",
2803 ..
2804 }
2805 ),
2806 "releasing a {status} task should fail, got {err:?}"
2807 );
2808
2809 let tasks = store::load_all(tmp.path()).await.unwrap();
2811 assert_eq!(tasks["aaa"].status, status);
2812 assert_eq!(tasks["aaa"].assignee, "agent-1");
2813 }
2814 }
2815
2816 #[tokio::test]
2817 async fn test_release_unknown_task_is_an_error() {
2818 let tmp = tempfile::TempDir::new().unwrap();
2819 store::init(tmp.path()).unwrap();
2820 let tasks = store::load_all(tmp.path()).await.unwrap();
2821 assert!(release_task(tmp.path(), &tasks, "zzz").is_err());
2822 }
2823
2824 #[tokio::test]
2827 async fn test_attempts_counts_each_start() {
2828 let tmp = tempfile::TempDir::new().unwrap();
2829 store::init(tmp.path()).unwrap();
2830 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
2831
2832 let tasks = store::load_all(tmp.path()).await.unwrap();
2833 assert_eq!(
2834 tasks["aaa"].attempt_count(),
2835 0,
2836 "missing counter reads as 0"
2837 );
2838 assert_eq!(
2839 tasks["aaa"].attempts, None,
2840 "no counter is written up front"
2841 );
2842
2843 let t = start_task(tmp.path(), &tasks, "aaa", Some("agent-1".into())).unwrap();
2844 assert_eq!(t.attempt_count(), 1);
2845
2846 let tasks = store::load_all(tmp.path()).await.unwrap();
2848 let t = release_task(tmp.path(), &tasks, "aaa").unwrap();
2849 assert_eq!(t.attempt_count(), 1, "release must not count as an attempt");
2850
2851 let tasks = store::load_all(tmp.path()).await.unwrap();
2852 let t = start_task(tmp.path(), &tasks, "aaa", Some("agent-2".into())).unwrap();
2853 assert_eq!(t.attempt_count(), 2);
2854
2855 let tasks = store::load_all(tmp.path()).await.unwrap();
2856 assert_eq!(tasks["aaa"].attempts, Some(2), "counter persists");
2857 }
2858
2859 #[tokio::test]
2860 async fn test_restarting_an_in_progress_task_is_the_same_attempt() {
2861 let tmp = tempfile::TempDir::new().unwrap();
2862 store::init(tmp.path()).unwrap();
2863 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
2864
2865 let tasks = store::load_all(tmp.path()).await.unwrap();
2866 start_task(tmp.path(), &tasks, "aaa", Some("agent-1".into())).unwrap();
2867
2868 let tasks = store::load_all(tmp.path()).await.unwrap();
2870 let t = start_task(tmp.path(), &tasks, "aaa", Some("agent-2".into())).unwrap();
2871 assert_eq!(t.attempt_count(), 1);
2872 assert_eq!(t.assignee, "agent-2");
2873 }
2874
2875 #[tokio::test]
2876 async fn test_attempts_counted_on_every_path_into_in_progress() {
2877 let tmp = tempfile::TempDir::new().unwrap();
2878 store::init(tmp.path()).unwrap();
2879 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
2880
2881 let tasks = store::load_all(tmp.path()).await.unwrap();
2883 let t = set_status(tmp.path(), &tasks, "aaa", Status::InProgress).unwrap();
2884 assert_eq!(t.attempt_count(), 1);
2885
2886 let tasks = store::load_all(tmp.path()).await.unwrap();
2887 set_status(tmp.path(), &tasks, "aaa", Status::Open).unwrap();
2888
2889 let tasks = store::load_all(tmp.path()).await.unwrap();
2891 let t = update_task(
2892 tmp.path(),
2893 &tasks,
2894 "aaa",
2895 Some(Status::InProgress),
2896 None,
2897 None,
2898 None,
2899 None,
2900 None,
2901 None,
2902 )
2903 .unwrap();
2904 assert_eq!(t.attempt_count(), 2);
2905
2906 let tasks = store::load_all(tmp.path()).await.unwrap();
2908 let t = update_task(
2909 tmp.path(),
2910 &tasks,
2911 "aaa",
2912 None,
2913 Some(Priority::P0),
2914 None,
2915 None,
2916 None,
2917 None,
2918 None,
2919 )
2920 .unwrap();
2921 assert_eq!(t.attempt_count(), 2);
2922 }
2923
2924 #[tokio::test]
2925 async fn test_other_status_changes_do_not_count_attempts() {
2926 let tmp = tempfile::TempDir::new().unwrap();
2927 store::init(tmp.path()).unwrap();
2928 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
2929
2930 let tasks = store::load_all(tmp.path()).await.unwrap();
2931 set_status(tmp.path(), &tasks, "aaa", Status::Blocked).unwrap();
2932 let tasks = store::load_all(tmp.path()).await.unwrap();
2933 let t = set_status(tmp.path(), &tasks, "aaa", Status::Done).unwrap();
2934 assert_eq!(t.attempt_count(), 0);
2935 assert_eq!(t.attempts, None);
2936 }
2937
2938 #[tokio::test]
2941 async fn test_review_workflow_round_trip() {
2942 let tmp = tempfile::TempDir::new().unwrap();
2943 store::init(tmp.path()).unwrap();
2944 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
2945
2946 let tasks = store::load_all(tmp.path()).await.unwrap();
2947 start_task(tmp.path(), &tasks, "aaa", Some("agent-1".into())).unwrap();
2948
2949 let tasks = store::load_all(tmp.path()).await.unwrap();
2951 let t = review_task(tmp.path(), &tasks, "aaa").unwrap();
2952 assert_eq!(t.status, Status::Review);
2953 assert_eq!(t.assignee, "agent-1", "review keeps the author");
2954
2955 let tasks = store::load_all(tmp.path()).await.unwrap();
2957 let t = reject_task(tmp.path(), &tasks, "aaa").unwrap();
2958 assert_eq!(t.status, Status::Open);
2959 assert!(
2960 t.assignee.is_empty(),
2961 "reject hands the task back to the pool"
2962 );
2963 assert_eq!(t.attempt_count(), 1, "a rejection is not a new attempt");
2964
2965 let tasks = store::load_all(tmp.path()).await.unwrap();
2967 start_task(tmp.path(), &tasks, "aaa", None).unwrap();
2968 let tasks = store::load_all(tmp.path()).await.unwrap();
2969 review_task(tmp.path(), &tasks, "aaa").unwrap();
2970 let tasks = store::load_all(tmp.path()).await.unwrap();
2971 let t = set_status(tmp.path(), &tasks, "aaa", Status::Done).unwrap();
2972 assert_eq!(t.status, Status::Done);
2973 assert_eq!(t.attempt_count(), 2);
2974 }
2975
2976 #[tokio::test]
2977 async fn test_workflow_verbs_reject_wrong_starting_status() {
2978 let tmp = tempfile::TempDir::new().unwrap();
2979 store::init(tmp.path()).unwrap();
2980 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
2981 let tasks = store::load_all(tmp.path()).await.unwrap();
2982
2983 for (action, result) in [
2985 ("review", review_task(tmp.path(), &tasks, "aaa")),
2986 ("reject", reject_task(tmp.path(), &tasks, "aaa")),
2987 ("accept", accept_task(tmp.path(), &tasks, "aaa")),
2988 ] {
2989 let err = result.unwrap_err();
2990 assert!(
2991 matches!(&err, Error::InvalidStatus { action: a, actual, .. }
2992 if *a == action && *actual == Status::Open),
2993 "{action} on an open task should fail, got {err:?}"
2994 );
2995 }
2996
2997 let tasks = store::load_all(tmp.path()).await.unwrap();
2999 assert_eq!(tasks["aaa"].status, Status::Open);
3000 }
3001
3002 #[tokio::test]
3003 async fn test_propose_accept_round_trip() {
3004 let tmp = tempfile::TempDir::new().unwrap();
3005 store::init(tmp.path()).unwrap();
3006 store::save(tmp.path(), &make_task("aaa", Status::Open)).unwrap();
3007
3008 let tasks = store::load_all(tmp.path()).await.unwrap();
3009 let t = propose_task(tmp.path(), &tasks, "aaa").unwrap();
3010 assert_eq!(t.status, Status::Proposed);
3011
3012 let tasks = store::load_all(tmp.path()).await.unwrap();
3013 let t = accept_task(tmp.path(), &tasks, "aaa").unwrap();
3014 assert_eq!(t.status, Status::Open);
3015 }
3016
3017 #[tokio::test]
3018 async fn test_proposed_and_review_are_never_ready() {
3019 let tmp = tempfile::TempDir::new().unwrap();
3020 store::init(tmp.path()).unwrap();
3021 store::save(tmp.path(), &make_task("aaa", Status::Proposed)).unwrap();
3022 store::save(tmp.path(), &make_task("bbb", Status::Review)).unwrap();
3023 store::save(tmp.path(), &make_task("ccc", Status::Open)).unwrap();
3024
3025 let tasks = store::load_all(tmp.path()).await.unwrap();
3026 let ready = list_ready(&tasks, None, None, None);
3027 let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
3028 assert_eq!(ready_ids, vec!["ccc"]);
3029
3030 let queue = list_review(&tasks, None, None, None);
3031 assert_eq!(queue.len(), 1);
3032 assert_eq!(queue[0].id, "bbb");
3033 }
3034
3035 #[tokio::test]
3036 async fn test_review_does_not_unblock_dependents() {
3037 let tmp = tempfile::TempDir::new().unwrap();
3038 store::init(tmp.path()).unwrap();
3039 store::save(tmp.path(), &make_task("aaa", Status::Review)).unwrap();
3040 let mut b = make_task("bbb", Status::Open);
3041 b.depends_on = vec!["aaa".into()];
3042 store::save(tmp.path(), &b).unwrap();
3043
3044 let tasks = store::load_all(tmp.path()).await.unwrap();
3045 assert!(
3046 list_ready(&tasks, None, None, None).is_empty(),
3047 "unreviewed work must not unblock its dependents"
3048 );
3049
3050 let tasks = store::load_all(tmp.path()).await.unwrap();
3052 set_status(tmp.path(), &tasks, "aaa", Status::Done).unwrap();
3053 let tasks = store::load_all(tmp.path()).await.unwrap();
3054 let ready = list_ready(&tasks, None, None, None);
3055 assert_eq!(ready.len(), 1);
3056 assert_eq!(ready[0].id, "bbb");
3057 }
3058
3059 #[tokio::test]
3060 async fn test_proposals_hidden_from_default_listing() {
3061 let tmp = tempfile::TempDir::new().unwrap();
3062 store::init(tmp.path()).unwrap();
3063 store::save(tmp.path(), &make_task("aaa", Status::Proposed)).unwrap();
3064 store::save(tmp.path(), &make_task("bbb", Status::Open)).unwrap();
3065 store::save(tmp.path(), &make_task("ccc", Status::Done)).unwrap();
3066 let tasks = store::load_all(tmp.path()).await.unwrap();
3067
3068 let ids = |v: Vec<Task>| -> Vec<String> { v.into_iter().map(|t| t.id).collect() };
3069
3070 assert_eq!(
3072 ids(list_tasks(&tasks, None, None, None, false, false, None)),
3073 vec!["bbb"]
3074 );
3075 assert_eq!(
3077 ids(list_tasks(&tasks, None, None, None, true, false, None)),
3078 vec!["bbb", "ccc"]
3079 );
3080 let mut all = ids(list_tasks(&tasks, None, None, None, true, true, None));
3082 all.sort();
3083 assert_eq!(all, vec!["aaa", "bbb", "ccc"]);
3084 assert_eq!(
3086 ids(list_tasks(
3087 &tasks,
3088 Some(Status::Proposed),
3089 None,
3090 None,
3091 false,
3092 false,
3093 None
3094 )),
3095 vec!["aaa"]
3096 );
3097 }
3098
3099 #[tokio::test]
3100 async fn test_review_queue_filters_and_order() {
3101 let tmp = tempfile::TempDir::new().unwrap();
3102 store::init(tmp.path()).unwrap();
3103 let mut low = make_task("aaa", Status::Review);
3104 low.priority = Priority::P3;
3105 low.tags = vec!["backend".into()];
3106 store::save(tmp.path(), &low).unwrap();
3107 let mut high = make_task("bbb", Status::Review);
3108 high.priority = Priority::P0;
3109 store::save(tmp.path(), &high).unwrap();
3110 store::save(tmp.path(), &make_task("ccc", Status::Open)).unwrap();
3111
3112 let tasks = store::load_all(tmp.path()).await.unwrap();
3113 let queue = list_review(&tasks, None, None, None);
3114 let ids: Vec<&str> = queue.iter().map(|t| t.id.as_str()).collect();
3115 assert_eq!(ids, vec!["bbb", "aaa"], "highest priority reviewed first");
3116
3117 let tagged = list_review(&tasks, Some("backend"), None, None);
3118 assert_eq!(tagged.len(), 1);
3119 assert_eq!(tagged[0].id, "aaa");
3120
3121 assert_eq!(list_review(&tasks, None, Some(1), None).len(), 1);
3122 }
3123
3124 #[tokio::test]
3125 async fn test_epic_does_not_close_while_a_child_is_in_review() {
3126 let tmp = tempfile::TempDir::new().unwrap();
3127 store::init(tmp.path()).unwrap();
3128 let epic = make_epic("eee");
3129 store::save(tmp.path(), &epic).unwrap();
3130 store::save(tmp.path(), &make_child("aaa", "eee", Status::Done)).unwrap();
3131 store::save(tmp.path(), &make_child("bbb", "eee", Status::InProgress)).unwrap();
3132
3133 let tasks = store::load_all(tmp.path()).await.unwrap();
3134 review_task(tmp.path(), &tasks, "bbb").unwrap();
3135
3136 let tasks = store::load_all(tmp.path()).await.unwrap();
3137 assert_eq!(
3138 tasks["eee"].status,
3139 Status::Open,
3140 "an epic must not auto-close on unreviewed work"
3141 );
3142 }
3143}