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.
73/// List tasks with optional filters.
74///
75/// `include_all` also yields done/cancelled tasks. Proposals are separate:
76/// they stay out of every listing unless `include_proposed` is set or the
77/// caller filters for `status = proposed` explicitly, so an unvetted proposal
78/// queue cannot drown the accepted backlog.
79#[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
110/// Return tasks that are ready to work on.
111pub 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
122/// Return tasks waiting for review, in canonical priority order.
123///
124/// The review queue is deliberately separate from [`list_ready`]: reviewing
125/// someone else's finished work and starting fresh work are different jobs, so
126/// an orchestrator can dispatch them to different workers.
127pub 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
147/// Move a task along one edge of the review workflow.
148///
149/// Each verb accepts exactly one starting status so that a shortcut can never
150/// quietly undo unrelated state (submitting a `done` task for review, say).
151/// `set_status` / `update_task` remain the escape hatch for any other move.
152fn 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
173/// Submit in-progress work for review.
174pub 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
185/// Send a task under review back for changes.
186///
187/// The assignee is cleared: a rejected task returns to the pool, and whoever
188/// claims it next picks up the rework from the findings recorded in the task
189/// body.
190pub 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
210/// Demote an open task to a proposal awaiting acceptance.
211pub 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
226/// Accept a proposal into the backlog, making it eligible for `ready`.
227pub 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
238/// Get a single task by ID or prefix.
239pub 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/// Update task fields. Only `Some` fields are changed.
245///
246/// The `parent` parameter uses a double-Option to distinguish three states:
247/// - `None`           → leave parent unchanged
248/// - `Some(None)`     → clear parent (detach from any epic)
249/// - `Some(Some(id))` → set parent to the given epic ID (must exist)
250#[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    // Reparenting: None = leave unchanged, Some(None) = clear, Some(Some(id)) = set
288    if let Some(new_parent) = parent {
289        match new_parent {
290            None => t.parent = None,
291            Some(ref pid) => {
292                // Validate parent exists and store its canonical full id (not the
293                // typed prefix) so epic_progress lookups by full id match.
294                t.parent = Some(store::resolve_prefix(tasks, pid)?);
295            }
296        }
297    }
298    t.updated = Utc::now();
299
300    store::save(base, &t)?;
301
302    // Apply status-change side effects (e.g. epic auto-close) when status changed.
303    if status_changed {
304        on_status_changed(base, tasks, &t)?;
305    }
306
307    Ok(t)
308}
309
310/// Set task status by ID or prefix.
311pub 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
330/// Start a task: set its status to `in_progress` and optionally claim it for
331/// an assignee.
332///
333/// `assignee` of `None` leaves the current assignee untouched; `Some("")`
334/// clears it.
335pub 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
357/// Release an in-progress task back to the pool: status returns to `open` and
358/// the assignee is cleared, so another worker can pick it up.
359///
360/// Only `in_progress` tasks can be released — releasing anything else is an
361/// error, so a stuck worker cannot accidentally reopen finished work.
362pub 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
387// ---------------------------------------------------------------------------
388// Assignee fencing
389//
390// `assignee` doubles as a fencing token for orchestrators driving a shared
391// store: work is taken through the claiming primitives below, and every later
392// mutation on the claimed task goes through a `_fenced` variant carrying that
393// token. A writer whose task has since been released, reassigned, or reaped
394// fails with a distinct error instead of silently clobbering the new holder's
395// state. All checks run against a fresh read from disk, not the caller's
396// snapshot. The unfenced functions remain the human/CLI path.
397// ---------------------------------------------------------------------------
398
399/// Verify — against a fresh read from disk — that a task's assignee still
400/// matches the token the caller claimed it with.
401///
402/// Returns the resolved task ID so fenced wrappers can reuse it. The
403/// read-check-write is not a file lock: concurrent writers sharing one store
404/// are expected to serialize among themselves (typically one orchestrator
405/// process holding the store behind a mutex); the fence is what catches a
406/// *stale* writer inside that discipline.
407fn 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
425/// Claim an open task and start it: the contention-checked counterpart of
426/// [`start_task`].
427///
428/// The task must be `open` and unclaimed (or already claimed by this same
429/// `assignee`) on a fresh read from disk; otherwise the claim is refused with
430/// [`Error::AlreadyClaimed`] rather than silently stealing it. On success the
431/// task is `in_progress`, assigned, and the attempt is counted.
432pub 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
464/// Claim a task for an assignee without touching its status.
465///
466/// The review-queue counterpart of [`claim_task`]: a reviewer takes a task
467/// that stays in `review` while they work it. Refused with
468/// [`Error::AlreadyClaimed`] when someone else holds the task on a fresh
469/// read, and with [`Error::InvalidUsage`] on `proposed` or terminal tasks,
470/// which have no work to claim.
471pub 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
500/// Fenced [`release_task`]: refused with [`Error::FenceViolation`] unless the
501/// task is still assigned to `expected_assignee`.
502pub 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
512/// Fenced [`review_task`]: submit for review only while still holding the task.
513pub 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
523/// Fenced [`reject_task`]: send back for changes only while still holding the
524/// task (as its reviewer, via [`assign_task`]).
525pub 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
535/// Fenced [`set_status`]: the escape hatch, guarded by the fence.
536pub 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/// Fenced [`update_task`]: field updates (body, tags, …) guarded by the fence.
548#[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
568/// Fenced [`add_dependency`]: the fence applies to the task whose
569/// `depends_on` list changes, not to the dependency target.
570pub 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
581/// Fenced [`remove_dependency`]: the fence applies to the task whose
582/// `depends_on` list changes, not to the dependency target.
583pub 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
594/// Count a new attempt when a task transitions *into* `in_progress`.
595///
596/// An attempt starts when work is claimed, not when it is given up: that way
597/// the counter is already durable if the worker dies without releasing, and a
598/// task that succeeded on the third try reads `attempts: 3`. Re-starting a
599/// task that is already in progress (e.g. to hand it to another assignee) is
600/// the same attempt, so it does not bump the counter.
601fn 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
607/// Apply side effects after a task's status has been changed and saved.
608///
609/// Triggers epic auto-close check and cascades up through nested epics.
610/// `tasks` is the pre-change snapshot; `t` is the task with its NEW status.
611fn on_status_changed(base: &Path, tasks: &HashMap<String, Task>, t: &Task) -> Result<()> {
612    // `overrides` tracks tasks that have been auto-closed during this call so
613    // that recursive ancestor checks see the up-to-date statuses even though
614    // `tasks` is an immutable pre-change snapshot.
615    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
620/// Resolve the effective status of a task, preferring the `overrides` map.
621fn effective_status<'a>(task: &'a Task, overrides: &'a HashMap<String, Status>) -> &'a Status {
622    overrides.get(&task.id).unwrap_or(&task.status)
623}
624
625/// Check whether `t`'s parent epic should auto-close, and if so close it and
626/// recurse up through ancestor epics. `overrides` accumulates newly-written
627/// statuses so that each level sees the current state without re-reading disk.
628fn maybe_close_parent_epic(
629    base: &Path,
630    tasks: &HashMap<String, Task>,
631    t: &Task,
632    overrides: &mut HashMap<String, Status>,
633) -> Result<()> {
634    // Trigger auto-close check when the child transitions to Done or Cancelled.
635    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    // Skip if already (auto-)closed in this call chain.
651    if *effective_status(parent, overrides) == Status::Done {
652        return Ok(());
653    }
654
655    // An epic is fully resolved when every child is Done or Cancelled
656    // (cancelled = resolved and non-blocking). We consult `overrides` for
657    // up-to-date statuses written during this recursive call.
658    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        // Cascade: re-run the check for the newly-closed epic's own parent.
676        maybe_close_parent_epic(base, tasks, parent, overrides)?;
677    }
678
679    Ok(())
680}
681
682/// Add a dependency with cycle detection. Both IDs support prefix matching.
683pub 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
710/// Remove a dependency. Both IDs support prefix matching.
711pub 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
726/// Search tasks by text query.
727pub 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
746/// Delete a task by ID or prefix, returning the deleted task.
747/// References to the deleted task are removed from remaining tasks.
748pub 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
756/// Prune cancelled (and optionally done) tasks, returning deleted tasks.
757/// References to pruned tasks are removed from remaining tasks.
758pub 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
777/// Remove dangling references to deleted tasks: drop deleted IDs from
778/// `depends_on` lists and clear `parent` fields pointing at deleted tasks.
779/// Without this, dependents would silently never become ready.
780///
781/// Only applies to hard deletion (delete/prune) — archived tasks keep their
782/// IDs reserved and still resolve via the archive, so no scrubbing there.
783fn 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
807/// Build the dependency graph from tasks.
808pub fn build_graph(tasks: &HashMap<String, Task>) -> Graph {
809    Graph::build(tasks)
810}
811
812// ─── Archive helpers ──────────────────────────────────────────────────────────
813
814/// Check whether a task is archivable.
815///
816/// A task is archivable when:
817/// - its status is Done or Cancelled, AND
818/// - no ACTIVE (not Done/Cancelled) task in `tasks` depends on it.
819///
820/// For epics the check is the same — the caller is responsible for deciding
821/// whether to cascade to children before calling this predicate.
822//
823// Public predicate exercised by the unit tests; the CLI/MCP archive paths go
824// through `archive_task`/`archive_all` (which need the blocker list, not a bool).
825#[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    // Build reverse graph to find dependents
832    let graph = Graph::build(tasks);
833    active_blockers(&task.id, tasks, &graph).is_empty()
834}
835
836/// Return the IDs of active (non-done/cancelled) tasks that depend on `id`.
837fn 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
852/// Archive a single task (and its cascade) identified by `id_or_prefix`.
853///
854/// Cascade rules:
855/// - If the task is an epic, its Done/Cancelled children are also archived
856///   (children that are not settled block the archive if they themselves would
857///   block archiving, but epic children are just included when settled).
858/// - For any archived task, its settled `depends_on` tasks that are no longer
859///   depended on by any active task are NOT automatically cascaded here —
860///   the caller may sweep afterwards with `archive_all`.
861///
862/// On failure returns `Error::NotArchivable` listing active dependents.
863pub 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    // Check the target task itself
873    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    // Collect the set to archive: the target + settled epic children
888    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    // Move each to archive
903    for tid in &to_archive {
904        store::move_to_archive(base, tid)?;
905    }
906
907    Ok(to_archive)
908}
909
910/// Sweep: archive every currently-archivable task.
911///
912/// A task is archivable if it is Done/Cancelled AND has no active dependents
913/// (considering only active tasks — not those already archived in this sweep).
914///
915/// We do a fixed-point iteration: after each pass we remove archived tasks from
916/// the working set and retry, because archiving one task may make another
917/// archivable (e.g. a chain where the head depends on a now-archived task that
918/// was its only active dependent).
919pub 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(); // deterministic order
939        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
949/// Restore a task from the archive back to the active store.
950///
951/// Cascade: also restores any archived `depends_on` tasks (transitively) and
952/// the parent epic (if archived) so the restored task has no missing deps.
953///
954/// The `id_or_prefix` is matched against the archive (not the active task map).
955pub 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    // Collect what must be restored: the target + its archived depends_on (transitive) + parent epic
962    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(&current) {
973            // Restore parent epic if archived
974            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            // Restore depends_on that are archived
981            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
996/// Get an archived task by ID or prefix (read-only, for show/inspect).
997pub 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
1004/// List archived tasks sorted by `updated` descending (most recently updated first).
1005///
1006/// If `limit` is `Some(n)`, at most `n` tasks are returned.
1007pub 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    // Sort by updated descending (most recent first), then id for stability
1011    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
1018/// Compute effective priorities for all tasks in a single O(V+E) pass.
1019pub fn effective_priorities(tasks: &HashMap<String, Task>) -> HashMap<String, Priority> {
1020    Graph::build(tasks).effective_priorities_all(tasks)
1021}
1022
1023/// Progress of an epic: how many children are done vs total.
1024#[derive(Debug, Clone, Serialize)]
1025pub struct EpicProgress {
1026    pub done: usize,
1027    pub total: usize,
1028}
1029
1030/// Compact epic projection used by the epics command.
1031#[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
1041/// Compute progress for an epic by counting children (tasks with parent == epic_id).
1042///
1043/// Semantics: cancelled children are treated as resolved and non-blocking.
1044/// - `total` = non-cancelled children (active workload)
1045/// - `done`  = Done children
1046///
1047/// A fully-resolved epic (all children Done or Cancelled) satisfies `done == total`
1048/// because cancelled children contribute to neither count.
1049pub 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                // Cancelled = resolved but not counted in the active workload.
1056                continue;
1057            }
1058            total += 1;
1059            if t.status == Status::Done {
1060                done += 1;
1061            }
1062        }
1063    }
1064    EpicProgress { done, total }
1065}
1066
1067/// Execution plan for an epic's children.
1068pub struct EpicPlan<'a> {
1069    /// Children in topological execution order.
1070    pub tasks: Vec<&'a Task>,
1071    /// Children that cannot be ordered because they are in a dependency cycle.
1072    pub cyclic: Vec<&'a Task>,
1073}
1074
1075/// Return children of an epic in topological execution order.
1076/// Children caught in a dependency cycle are reported separately.
1077pub fn plan_epic<'a>(tasks: &'a HashMap<String, Task>, parent_id: &str) -> Result<EpicPlan<'a>> {
1078    // Validate parent exists and is an epic
1079    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    // Collect child IDs
1088    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        // The refused claim must not have touched the file.
1156        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        // Snapshot taken while the task is unclaimed…
1167        let stale = store::load_all(tmp.path()).await.unwrap();
1168        // …then someone else claims it on disk.
1169        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        // Terminal and proposed tasks have no work to claim.
1210        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        // worker-1 was reaped and the task reassigned on disk…
1226        let mut a = tasks["aaa"].clone();
1227        a.assignee = "worker-2".into();
1228        store::save(tmp.path(), &a).unwrap();
1229
1230        // …so every fenced write from worker-1, even via a stale snapshot, fails.
1231        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        // The body write never happened.
1258        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        // The task no longer belongs to worker-1, so its fence now refuses it.
1303        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        // An epic so it can serve as both a dependency target and a parent.
1314        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        // And the dependent is now ready instead of silently blocked.
1330        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        // Cancelled children are non-blocking: excluded from total, not counted in done.
1454        // A fully-resolved epic (done + cancelled) shows done == total.
1455        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); // cancelled child excluded
1463    }
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); // cancelled child excluded
1476    }
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        // Done + Cancelled = all resolved → epic should auto-close
1526        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        // Cancel the last child — should trigger auto-close
1532        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        // Cancelling the only/last open child must trigger auto-close
1575        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        // Complete first child — epic stays open
1633        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        // Complete second child — epic auto-closes
1639        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        // Complete first child via update_task — epic stays open
1692        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        // Complete second child via update_task — epic auto-closes
1710        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        // Regression: re-completing an already-done child must NOT auto-close the epic
1730        // when another child is still open.
1731        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        // Complete child1 for the first time
1777        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        // Re-complete child1 (already done) — child2 is still open, epic must NOT close
1787        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        // Verify that closing the last leaf cascades up through ≥2 epic levels.
1799        //
1800        // Structure:
1801        //   outer_epic
1802        //     └─ inner_epic
1803        //          ├─ leaf1 (will be Done first)
1804        //          └─ leaf2 (completing this triggers the cascade)
1805        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        // Complete leaf1 — nothing should close yet
1865        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        // Complete leaf2 — inner_epic should auto-close, then outer_epic should cascade-close
1880        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        // plan_epic rejects non-epic parents
1939        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        // A parent passed as a prefix must be stored as the resolved full id, so
1952        // epic_progress (which matches children on the full id) counts them.
1953        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        // update_task reparenting with a prefix ("epi" → "epicid").
1963        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        // create_task with a prefix parent ("epi" → "epicid").
1984        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        // Both children are now visible to the epic via its full id.
2004        let tasks = store::load_all(tmp.path()).await.unwrap();
2005        assert_eq!(epic_progress(&tasks, "epicid").total, 2);
2006    }
2007
2008    // ─── Archive service tests ────────────────────────────────────────────────
2009
2010    #[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        // t1 is done, but t2 (open) depends on t1 → t1 is NOT archivable
2042        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        // t1 is done, t2 (also done) depends on t1 → t1 IS archivable
2052        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        // Task should no longer be active
2087        let active = store::load_all(tmp.path()).await.unwrap();
2088        assert!(!active.contains_key(&t.id));
2089
2090        // Task should be in archive
2091        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        // Create dependent that depends on dep
2116        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        // Mark dep as done but dependent is still open
2130        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        // Mark epic and both children as done
2216        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        // Epic should auto-close; set it explicitly just in case
2222        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        // Epic + 2 children should all be archived
2229        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        // t1 done, t2 done and depends on t1 — both should be swept
2304        // because after archiving t2 (no active dependents), t1 (depended on by done t2)
2305        // becomes archivable in next iteration.
2306        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        // t1 archived, t2 archived and depends on t1
2386        // Restoring t2 should also restore t1 (its archived dep)
2387        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 both
2425        archive_all(tmp.path(), &tasks).unwrap();
2426
2427        let active = store::load_all(tmp.path()).await.unwrap();
2428        assert!(active.is_empty());
2429
2430        // Restore t2 — t1 (its dep) should also come back
2431        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        // Epic archived, child archived → restoring child should also restore epic
2446        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        // Epic should have auto-closed; archive manually if needed
2481        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        // Restore child → epic should also be restored
2489        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        // Verify that create_task doesn't reuse archived IDs.
2539        // We can't easily force a collision with random short IDs in a unit test,
2540        // but we can verify that archived_id_set is called by checking the function
2541        // doesn't panic and creates a new task with a different ID than the archived one.
2542        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        // Now archived. New task creation should succeed and not reuse the archived ID.
2565        let tasks = store::load_all(tmp.path()).await.unwrap();
2566        // archived_id_set is consulted during ID generation
2567        let archived_ids = store::archived_id_set(tmp.path());
2568        assert!(archived_ids.contains(&t.id));
2569
2570        // If we create another task, it shouldn't collide with the archived ID
2571        // (with a 3-char ID space of 36^3=46656 IDs, collision is unlikely but
2572        // the code path is exercised)
2573        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    // ─── list_archive tests ───────────────────────────────────────────────────
2589
2590    #[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        // Create and archive three tasks; each is created slightly after the previous
2596        // so they have distinct updated timestamps.
2597        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        // Mark all done and archive — update times set by set_status calls
2640        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        // list_archive returns all 3, most recently updated first
2650        let listed = list_archive(tmp.path(), None).await.unwrap();
2651        assert_eq!(listed.len(), 3);
2652        // All should be present (exact order may vary if timestamps are equal
2653        // since IDs are random, but at minimum all three must appear)
2654        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        // Verify sorted descending
2659        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    // ── start / release ────────────────────────────────────────────
2721
2722    #[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            // The task itself must be untouched.
2810            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    // ── attempts ───────────────────────────────────────────────────
2825
2826    #[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        // Failed attempt handed back, then picked up by another worker.
2847        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        // Handing the in-progress task to another assignee is not a new attempt.
2869        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        // `set_status` (bea status / TUI) counts.
2882        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        // `update_task` with a status change counts too.
2890        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        // A non-status update leaves the counter alone.
2907        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    // ── review & proposal workflow ─────────────────────────────────
2939
2940    #[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        // Finished work goes to review instead of straight to done.
2950        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        // Reviewer wants changes: back to the pool — open, assignee cleared.
2956        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        // Second pass through, approved this time.
2966        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        // review needs in_progress, reject needs review, accept needs proposed.
2984        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        // Nothing was written.
2998        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        // Once approved, the dependent frees up.
3051        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        // Default: no proposals, no done tasks.
3071        assert_eq!(
3072            ids(list_tasks(&tasks, None, None, None, false, false, None)),
3073            vec!["bbb"]
3074        );
3075        // include_all alone still hides proposals (the MCP default).
3076        assert_eq!(
3077            ids(list_tasks(&tasks, None, None, None, true, false, None)),
3078            vec!["bbb", "ccc"]
3079        );
3080        // Everything, the way `bea list --all` asks for it.
3081        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        // An explicit status filter always wins.
3085        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}