Skip to main content

bears/
service.rs

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/// Create a new task with validation.
14#[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    // Also exclude archived IDs so new tasks never reuse an archived ID.
29    existing_ids.extend(store::archived_id_set(base));
30    let id = task::generate_id(&existing_ids, config.id_length as usize);
31
32    // Resolve dependency IDs (prefixes allowed, like every other command)
33    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    // Treat empty-string parent as "no parent", and resolve a prefix to the
47    // full id so the stored parent matches the canonical task id (otherwise
48    // epic_progress / reparenting lookups by full id would miss it).
49    // The parent must exist and be an epic.
50    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/// List tasks with optional filters, sorted by priority then creation date.
73pub 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
100/// Return tasks that are ready to work on.
101pub 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
112/// Get a single task by ID or prefix.
113pub 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/// Update task fields. Only `Some` fields are changed.
119///
120/// The `parent` parameter uses a double-Option to distinguish three states:
121/// - `None`           → leave parent unchanged
122/// - `Some(None)`     → clear parent (detach from any epic)
123/// - `Some(Some(id))` → set parent to the given epic ID (must exist)
124#[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    // Reparenting: None = leave unchanged, Some(None) = clear, Some(Some(id)) = set
160    if let Some(new_parent) = parent {
161        match new_parent {
162            None => t.parent = None,
163            Some(ref pid) => {
164                // Validate parent exists and store its canonical full id (not the
165                // typed prefix) so epic_progress lookups by full id match.
166                t.parent = Some(store::resolve_prefix(tasks, pid)?);
167            }
168        }
169    }
170    t.updated = Utc::now();
171
172    store::save(base, &t)?;
173
174    // Apply status-change side effects (e.g. epic auto-close) when status changed.
175    if status_changed {
176        on_status_changed(base, tasks, &t)?;
177    }
178
179    Ok(t)
180}
181
182/// Set task status by ID or prefix.
183pub 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
200/// Apply side effects after a task's status has been changed and saved.
201///
202/// Triggers epic auto-close check and cascades up through nested epics.
203/// `tasks` is the pre-change snapshot; `t` is the task with its NEW status.
204fn on_status_changed(base: &Path, tasks: &HashMap<String, Task>, t: &Task) -> Result<()> {
205    // `overrides` tracks tasks that have been auto-closed during this call so
206    // that recursive ancestor checks see the up-to-date statuses even though
207    // `tasks` is an immutable pre-change snapshot.
208    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
213/// Resolve the effective status of a task, preferring the `overrides` map.
214fn effective_status<'a>(task: &'a Task, overrides: &'a HashMap<String, Status>) -> &'a Status {
215    overrides.get(&task.id).unwrap_or(&task.status)
216}
217
218/// Check whether `t`'s parent epic should auto-close, and if so close it and
219/// recurse up through ancestor epics. `overrides` accumulates newly-written
220/// statuses so that each level sees the current state without re-reading disk.
221fn maybe_close_parent_epic(
222    base: &Path,
223    tasks: &HashMap<String, Task>,
224    t: &Task,
225    overrides: &mut HashMap<String, Status>,
226) -> Result<()> {
227    // Trigger auto-close check when the child transitions to Done or Cancelled.
228    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    // Skip if already (auto-)closed in this call chain.
244    if *effective_status(parent, overrides) == Status::Done {
245        return Ok(());
246    }
247
248    // An epic is fully resolved when every child is Done or Cancelled
249    // (cancelled = resolved and non-blocking). We consult `overrides` for
250    // up-to-date statuses written during this recursive call.
251    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        // Cascade: re-run the check for the newly-closed epic's own parent.
269        maybe_close_parent_epic(base, tasks, parent, overrides)?;
270    }
271
272    Ok(())
273}
274
275/// Add a dependency with cycle detection. Both IDs support prefix matching.
276pub 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
303/// Remove a dependency. Both IDs support prefix matching.
304pub 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
319/// Search tasks by text query.
320pub 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
339/// Delete a task by ID or prefix, returning the deleted task.
340/// References to the deleted task are removed from remaining tasks.
341pub 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
349/// Prune cancelled (and optionally done) tasks, returning deleted tasks.
350/// References to pruned tasks are removed from remaining tasks.
351pub 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
370/// Remove dangling references to deleted tasks: drop deleted IDs from
371/// `depends_on` lists and clear `parent` fields pointing at deleted tasks.
372/// Without this, dependents would silently never become ready.
373///
374/// Only applies to hard deletion (delete/prune) — archived tasks keep their
375/// IDs reserved and still resolve via the archive, so no scrubbing there.
376fn 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
400/// Build the dependency graph from tasks.
401pub fn build_graph(tasks: &HashMap<String, Task>) -> Graph {
402    Graph::build(tasks)
403}
404
405// ─── Archive helpers ──────────────────────────────────────────────────────────
406
407/// Check whether a task is archivable.
408///
409/// A task is archivable when:
410/// - its status is Done or Cancelled, AND
411/// - no ACTIVE (not Done/Cancelled) task in `tasks` depends on it.
412///
413/// For epics the check is the same — the caller is responsible for deciding
414/// whether to cascade to children before calling this predicate.
415//
416// Public predicate exercised by the unit tests; the CLI/MCP archive paths go
417// through `archive_task`/`archive_all` (which need the blocker list, not a bool).
418#[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    // Build reverse graph to find dependents
425    let graph = Graph::build(tasks);
426    active_blockers(&task.id, tasks, &graph).is_empty()
427}
428
429/// Return the IDs of active (non-done/cancelled) tasks that depend on `id`.
430fn 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
445/// Archive a single task (and its cascade) identified by `id_or_prefix`.
446///
447/// Cascade rules:
448/// - If the task is an epic, its Done/Cancelled children are also archived
449///   (children that are not settled block the archive if they themselves would
450///   block archiving, but epic children are just included when settled).
451/// - For any archived task, its settled `depends_on` tasks that are no longer
452///   depended on by any active task are NOT automatically cascaded here —
453///   the caller may sweep afterwards with `archive_all`.
454///
455/// On failure returns `Error::NotArchivable` listing active dependents.
456pub 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    // Check the target task itself
466    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    // Collect the set to archive: the target + settled epic children
481    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    // Move each to archive
496    for tid in &to_archive {
497        store::move_to_archive(base, tid)?;
498    }
499
500    Ok(to_archive)
501}
502
503/// Sweep: archive every currently-archivable task.
504///
505/// A task is archivable if it is Done/Cancelled AND has no active dependents
506/// (considering only active tasks — not those already archived in this sweep).
507///
508/// We do a fixed-point iteration: after each pass we remove archived tasks from
509/// the working set and retry, because archiving one task may make another
510/// archivable (e.g. a chain where the head depends on a now-archived task that
511/// was its only active dependent).
512pub 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(); // deterministic order
532        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
542/// Restore a task from the archive back to the active store.
543///
544/// Cascade: also restores any archived `depends_on` tasks (transitively) and
545/// the parent epic (if archived) so the restored task has no missing deps.
546///
547/// The `id_or_prefix` is matched against the archive (not the active task map).
548pub 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    // Collect what must be restored: the target + its archived depends_on (transitive) + parent epic
555    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(&current) {
566            // Restore parent epic if archived
567            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            // Restore depends_on that are archived
574            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
589/// Get an archived task by ID or prefix (read-only, for show/inspect).
590pub 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
597/// List archived tasks sorted by `updated` descending (most recently updated first).
598///
599/// If `limit` is `Some(n)`, at most `n` tasks are returned.
600pub 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    // Sort by updated descending (most recent first), then id for stability
604    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
611/// Compute effective priorities for all tasks in a single O(V+E) pass.
612pub fn effective_priorities(tasks: &HashMap<String, Task>) -> HashMap<String, Priority> {
613    Graph::build(tasks).effective_priorities_all(tasks)
614}
615
616/// Progress of an epic: how many children are done vs total.
617#[derive(Debug, Clone, Serialize)]
618pub struct EpicProgress {
619    pub done: usize,
620    pub total: usize,
621}
622
623/// Compact epic projection used by the epics command.
624#[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
634/// Compute progress for an epic by counting children (tasks with parent == epic_id).
635///
636/// Semantics: cancelled children are treated as resolved and non-blocking.
637/// - `total` = non-cancelled children (active workload)
638/// - `done`  = Done children
639///
640/// A fully-resolved epic (all children Done or Cancelled) satisfies `done == total`
641/// because cancelled children contribute to neither count.
642pub 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                // Cancelled = resolved but not counted in the active workload.
649                continue;
650            }
651            total += 1;
652            if t.status == Status::Done {
653                done += 1;
654            }
655        }
656    }
657    EpicProgress { done, total }
658}
659
660/// Execution plan for an epic's children.
661pub struct EpicPlan<'a> {
662    /// Children in topological execution order.
663    pub tasks: Vec<&'a Task>,
664    /// Children that cannot be ordered because they are in a dependency cycle.
665    pub cyclic: Vec<&'a Task>,
666}
667
668/// Return children of an epic in topological execution order.
669/// Children caught in a dependency cycle are reported separately.
670pub fn plan_epic<'a>(tasks: &'a HashMap<String, Task>, parent_id: &str) -> Result<EpicPlan<'a>> {
671    // Validate parent exists and is an epic
672    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    // Collect child IDs
681    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        // An epic so it can serve as both a dependency target and a parent.
727        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        // And the dependent is now ready instead of silently blocked.
743        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        // Cancelled children are non-blocking: excluded from total, not counted in done.
867        // A fully-resolved epic (done + cancelled) shows done == total.
868        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); // cancelled child excluded
876    }
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); // cancelled child excluded
889    }
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        // Done + Cancelled = all resolved → epic should auto-close
939        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        // Cancel the last child — should trigger auto-close
945        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        // Cancelling the only/last open child must trigger auto-close
988        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        // Complete first child — epic stays open
1046        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        // Complete second child — epic auto-closes
1052        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        // Complete first child via update_task — epic stays open
1105        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        // Complete second child via update_task — epic auto-closes
1123        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        // Regression: re-completing an already-done child must NOT auto-close the epic
1143        // when another child is still open.
1144        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        // Complete child1 for the first time
1190        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        // Re-complete child1 (already done) — child2 is still open, epic must NOT close
1200        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        // Verify that closing the last leaf cascades up through ≥2 epic levels.
1212        //
1213        // Structure:
1214        //   outer_epic
1215        //     └─ inner_epic
1216        //          ├─ leaf1 (will be Done first)
1217        //          └─ leaf2 (completing this triggers the cascade)
1218        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        // Complete leaf1 — nothing should close yet
1278        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        // Complete leaf2 — inner_epic should auto-close, then outer_epic should cascade-close
1293        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        // plan_epic rejects non-epic parents
1352        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        // A parent passed as a prefix must be stored as the resolved full id, so
1365        // epic_progress (which matches children on the full id) counts them.
1366        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        // update_task reparenting with a prefix ("epi" → "epicid").
1376        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        // create_task with a prefix parent ("epi" → "epicid").
1397        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        // Both children are now visible to the epic via its full id.
1417        let tasks = store::load_all(tmp.path()).await.unwrap();
1418        assert_eq!(epic_progress(&tasks, "epicid").total, 2);
1419    }
1420
1421    // ─── Archive service tests ────────────────────────────────────────────────
1422
1423    #[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        // t1 is done, but t2 (open) depends on t1 → t1 is NOT archivable
1455        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        // t1 is done, t2 (also done) depends on t1 → t1 IS archivable
1465        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        // Task should no longer be active
1500        let active = store::load_all(tmp.path()).await.unwrap();
1501        assert!(!active.contains_key(&t.id));
1502
1503        // Task should be in archive
1504        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        // Create dependent that depends on dep
1529        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        // Mark dep as done but dependent is still open
1543        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        // Mark epic and both children as done
1629        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        // Epic should auto-close; set it explicitly just in case
1635        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        // Epic + 2 children should all be archived
1642        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        // t1 done, t2 done and depends on t1 — both should be swept
1717        // because after archiving t2 (no active dependents), t1 (depended on by done t2)
1718        // becomes archivable in next iteration.
1719        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        // t1 archived, t2 archived and depends on t1
1799        // Restoring t2 should also restore t1 (its archived dep)
1800        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 both
1838        archive_all(tmp.path(), &tasks).unwrap();
1839
1840        let active = store::load_all(tmp.path()).await.unwrap();
1841        assert!(active.is_empty());
1842
1843        // Restore t2 — t1 (its dep) should also come back
1844        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        // Epic archived, child archived → restoring child should also restore epic
1859        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        // Epic should have auto-closed; archive manually if needed
1894        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        // Restore child → epic should also be restored
1902        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        // Verify that create_task doesn't reuse archived IDs.
1952        // We can't easily force a collision with random short IDs in a unit test,
1953        // but we can verify that archived_id_set is called by checking the function
1954        // doesn't panic and creates a new task with a different ID than the archived one.
1955        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        // Now archived. New task creation should succeed and not reuse the archived ID.
1978        let tasks = store::load_all(tmp.path()).await.unwrap();
1979        // archived_id_set is consulted during ID generation
1980        let archived_ids = store::archived_id_set(tmp.path());
1981        assert!(archived_ids.contains(&t.id));
1982
1983        // If we create another task, it shouldn't collide with the archived ID
1984        // (with a 3-char ID space of 36^3=46656 IDs, collision is unlikely but
1985        // the code path is exercised)
1986        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    // ─── list_archive tests ───────────────────────────────────────────────────
2002
2003    #[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        // Create and archive three tasks; each is created slightly after the previous
2009        // so they have distinct updated timestamps.
2010        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        // Mark all done and archive — update times set by set_status calls
2053        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        // list_archive returns all 3, most recently updated first
2063        let listed = list_archive(tmp.path(), None).await.unwrap();
2064        assert_eq!(listed.len(), 3);
2065        // All should be present (exact order may vary if timestamps are equal
2066        // since IDs are random, but at minimum all three must appear)
2067        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        // Verify sorted descending
2072        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}