tli 0.0.5

Fast file-backed task tracker for humans, hooks, and AI agents.
Documentation
use std::collections::BTreeMap;

use anyhow::Result;
use chrono::Utc;

use crate::model::{
    ReadyTask, StateSnapshot, StateTask, StoreIndex, TaskContinuation, TaskDetail, TaskEvent,
    TaskStatus, TaskSummary,
};

use super::{
    ListFilter, TaskStore,
    helpers::{
        compare_summary_for_list, is_pending_dependency_summary, is_ready_summary, normalize_query,
        task_matches_query,
    },
};

struct QueryContext<'a> {
    index: &'a StoreIndex,
    now: chrono::DateTime<Utc>,
    children_by_parent: BTreeMap<String, Vec<TaskSummary>>,
    ready_top_level: Vec<TaskSummary>,
}

impl<'a> QueryContext<'a> {
    fn new(index: &'a StoreIndex, now: chrono::DateTime<Utc>) -> Self {
        let mut children_by_parent: BTreeMap<String, Vec<TaskSummary>> = BTreeMap::new();
        let mut ready_top_level = Vec::new();
        for task in index.tasks.values() {
            if let Some(parent) = task.parent.as_ref() {
                children_by_parent
                    .entry(parent.clone())
                    .or_default()
                    .push(task.clone());
            } else if is_ready_summary(task, index, now) {
                ready_top_level.push(task.clone());
            }
        }
        for children in children_by_parent.values_mut() {
            children.sort_by(compare_summary_for_list);
        }
        ready_top_level.sort_by(compare_summary_for_list);
        Self {
            index,
            now,
            children_by_parent,
            ready_top_level,
        }
    }

    fn child_count(&self, parent_id: &str) -> usize {
        self.children_by_parent.get(parent_id).map_or(0, Vec::len)
    }

    fn resolve_continuation(&self, task: &TaskSummary) -> TaskContinuation {
        let mut continuation = task.continuation.clone();

        if continuation.next_subtask.is_none()
            && let Some(child) = self
                .children_by_parent
                .get(&task.id)
                .into_iter()
                .flatten()
                .find(|candidate| is_ready_summary(candidate, self.index, self.now))
        {
            continuation.next_subtask = Some(child.id.clone());
        }

        if continuation.next_task.is_none() {
            let mut ready_dependencies = task
                .depends_on
                .iter()
                .filter_map(|dependency_id| self.index.tasks.get(dependency_id))
                .filter(|dependency| is_ready_summary(dependency, self.index, self.now))
                .cloned()
                .collect::<Vec<_>>();
            ready_dependencies.sort_by(compare_summary_for_list);

            if let Some(dependency) = ready_dependencies.first() {
                continuation.next_task = Some(dependency.id.clone());
            } else if let Some(next_task) = self
                .ready_top_level
                .iter()
                .find(|candidate| candidate.id != task.id)
            {
                continuation.next_task = Some(next_task.id.clone());
            }
        }

        continuation
    }

    fn build_state_task(&self, task: TaskSummary) -> StateTask {
        StateTask {
            ready: is_ready_summary(&task, self.index, self.now),
            dependency_count: task.depends_on.len(),
            child_count: self.child_count(&task.id),
            next: self.resolve_continuation(&task),
            task,
        }
    }

    fn build_state_section(&self, mut tasks: Vec<TaskSummary>, limit: usize) -> Vec<StateTask> {
        tasks.sort_by(compare_summary_for_list);
        tasks
            .into_iter()
            .take(limit)
            .map(|task| self.build_state_task(task))
            .collect()
    }

    fn build_counts(&self, tasks: &[TaskSummary]) -> crate::model::StateCounts {
        let mut counts = crate::model::StateCounts::default();
        for task in tasks {
            match task.status {
                TaskStatus::Todo => counts.todo += 1,
                TaskStatus::Active => counts.active += 1,
                TaskStatus::Checkpoint => counts.checkpoint += 1,
                TaskStatus::Blocked => counts.blocked += 1,
                TaskStatus::Review => counts.review += 1,
                TaskStatus::Done => counts.done += 1,
            }
            if is_ready_summary(task, self.index, self.now) {
                counts.ready += 1;
            }
            if is_pending_dependency_summary(task, self.index, self.now) {
                counts.pending_dependencies += 1;
            }
            if task.status == TaskStatus::Done && !self.resolve_continuation(task).is_empty() {
                counts.handoff += 1;
            }
        }
        counts
    }
}

impl TaskStore {
    pub fn list_tasks(&self, filter: &ListFilter) -> Result<Vec<crate::model::TaskSummary>> {
        if !self.root.exists() {
            return Ok(Vec::new());
        }

        let index = self.read_index()?;
        let now = Utc::now();
        let query = normalize_query(filter.query.as_deref());
        let mut items = index.tasks.values().cloned().collect::<Vec<_>>();
        items.retain(|task| super::helpers::match_status_filter(task, filter));
        if filter.ready_only {
            items.retain(|task| is_ready_summary(task, &index, now));
        }
        if let Some(query) = query.as_deref() {
            items.retain(|task| task_matches_query(task, query));
        }
        items.sort_by(compare_summary_for_list);
        if let Some(limit) = filter.limit {
            items.truncate(limit);
        }
        Ok(items)
    }

    pub fn ready_tasks(
        &self,
        query: Option<String>,
        limit: Option<usize>,
    ) -> Result<Vec<ReadyTask>> {
        if !self.root.exists() {
            return Ok(Vec::new());
        }

        let index = self.read_index()?;
        let now = Utc::now();
        let context = QueryContext::new(&index, now);
        let query = normalize_query(query.as_deref());
        let mut items = index
            .tasks
            .values()
            .filter(|task| is_ready_summary(task, &index, now))
            .cloned()
            .collect::<Vec<_>>();
        if let Some(query) = query.as_deref() {
            items.retain(|task| task_matches_query(task, query));
        }
        items.sort_by(compare_summary_for_list);
        let mut ready = items
            .into_iter()
            .map(|task| ReadyTask {
                dependency_count: task.depends_on.len(),
                child_count: context.child_count(&task.id),
                next: context.resolve_continuation(&task),
                task,
            })
            .collect::<Vec<_>>();
        if let Some(limit) = limit {
            ready.truncate(limit);
        }
        Ok(ready)
    }

    pub fn state_snapshot(&self, query: Option<String>, limit: usize) -> Result<StateSnapshot> {
        if !self.root.exists() {
            return Ok(StateSnapshot::default());
        }

        let index = self.read_index()?;
        let now = Utc::now();
        let context = QueryContext::new(&index, now);
        let query = normalize_query(query.as_deref());
        let tasks = index
            .tasks
            .values()
            .filter(|task| {
                query
                    .as_deref()
                    .is_none_or(|query| task_matches_query(task, query))
            })
            .cloned()
            .collect::<Vec<_>>();

        let counts = context.build_counts(&tasks);
        let ready = context.build_state_section(
            tasks
                .iter()
                .filter(|task| is_ready_summary(task, &index, now))
                .cloned()
                .collect(),
            limit,
        );
        let pending_dependencies = context.build_state_section(
            tasks
                .iter()
                .filter(|task| is_pending_dependency_summary(task, &index, now))
                .cloned()
                .collect(),
            limit,
        );
        let active = context.build_state_section(
            tasks
                .iter()
                .filter(|task| task.status == TaskStatus::Active)
                .cloned()
                .collect(),
            limit,
        );
        let blocked = context.build_state_section(
            tasks
                .iter()
                .filter(|task| task.status == TaskStatus::Blocked)
                .cloned()
                .collect(),
            limit,
        );
        let checkpoint = context.build_state_section(
            tasks
                .iter()
                .filter(|task| task.status == TaskStatus::Checkpoint)
                .cloned()
                .collect(),
            limit,
        );
        let review = context.build_state_section(
            tasks
                .iter()
                .filter(|task| task.status == TaskStatus::Review)
                .cloned()
                .collect(),
            limit,
        );
        let handoff = context.build_state_section(
            tasks
                .iter()
                .filter(|task| {
                    task.status == TaskStatus::Done
                        && !context.resolve_continuation(task).is_empty()
                })
                .cloned()
                .collect(),
            limit,
        );

        Ok(StateSnapshot {
            counts,
            ready,
            pending_dependencies,
            active,
            blocked,
            checkpoint,
            review,
            handoff,
        })
    }

    pub fn next_task(&self, id: &str) -> Result<StateTask> {
        let index = self.read_index()?;
        let resolved_id = self.resolve_task_reference_in_index(&index, id)?;
        let task = index
            .tasks
            .get(&resolved_id)
            .cloned()
            .expect("resolved task must exist");
        let context = QueryContext::new(&index, Utc::now());
        Ok(context.build_state_task(task))
    }

    pub fn continuation_tasks(&self, limit: usize) -> Result<Vec<StateTask>> {
        if !self.root.exists() {
            return Ok(Vec::new());
        }

        let index = self.read_index()?;
        let now = Utc::now();
        let context = QueryContext::new(&index, now);
        let mut items = index
            .tasks
            .values()
            .filter(|task| {
                matches!(task.status, TaskStatus::Checkpoint | TaskStatus::Done)
                    && !context.resolve_continuation(task).is_empty()
            })
            .cloned()
            .collect::<Vec<_>>();
        items.sort_by(compare_summary_for_list);
        let mut tasks = items
            .into_iter()
            .map(|task| context.build_state_task(task))
            .collect::<Vec<_>>();
        tasks.truncate(limit);
        Ok(tasks)
    }

    pub fn task_detail(&self, id: &str) -> Result<TaskDetail> {
        let index = self.read_index()?;
        let context = QueryContext::new(&index, Utc::now());
        let resolved_id = self.resolve_task_reference_in_index(&index, id)?;
        let task = self.read_task_by_id(&resolved_id)?;
        let mut dependencies = Vec::new();
        let mut blocked_by = Vec::new();
        let mut missing_dependencies = Vec::new();
        for dependency_id in &task.summary.depends_on {
            match index.tasks.get(dependency_id) {
                Some(summary) => {
                    dependencies.push(summary.clone());
                    if summary.status != TaskStatus::Done {
                        blocked_by.push(summary.clone());
                    }
                }
                None => missing_dependencies.push(dependency_id.clone()),
            }
        }
        dependencies.sort_by(compare_summary_for_list);
        blocked_by.sort_by(compare_summary_for_list);
        let mut children = index
            .tasks
            .values()
            .filter(|candidate| candidate.parent.as_deref() == Some(task.summary.id.as_str()))
            .cloned()
            .collect::<Vec<_>>();
        children.sort_by(compare_summary_for_list);

        Ok(TaskDetail {
            ready: is_ready_summary(&task.summary, &index, context.now),
            next: context.resolve_continuation(&task.summary),
            task,
            dependencies,
            missing_dependencies,
            blocked_by,
            children,
        })
    }

    pub fn read_events(&self, id: Option<&str>, limit: Option<usize>) -> Result<Vec<TaskEvent>> {
        if !self.events_path().exists() {
            return Ok(Vec::new());
        }

        let resolved_id = match id {
            Some(task_id) => Some(self.resolve_task_reference(task_id)?),
            None => None,
        };

        let mut events = if let Some(task_id) = resolved_id.as_deref() {
            self.ensure_task_event_log(task_id)?;
            self.read_events_file(self.task_events_path(task_id))?
        } else {
            self.read_events_file(self.events_path())?
        };
        events.sort_by_key(|e| std::cmp::Reverse(e.at));
        if let Some(limit) = limit {
            events.truncate(limit);
        }
        Ok(events)
    }
}