tli 0.1.2

Fast file-backed task tracker for humans, hooks, and AI agents.
Documentation
use anyhow::{Result, bail};
use chrono::{DateTime, Local, Utc};

use crate::model::{
    StoreIndex, TaskContinuation, TaskEvent, TaskEventKind, TaskNote, TaskRecord, TaskStatus,
    TaskSummary,
};

use super::{
    AddTaskInput, ProgressUpdate, ScheduleUpdate, TaskStore,
    helpers::{
        describe_progress_message, ensure_distinct, ensure_task_exists, has_dependency_path,
        next_scheduled_ready_at, normalize_labels, normalize_optional_text,
        normalize_required_text, resolve_ready_at, slugify, validate_schedule,
    },
};

impl TaskStore {
    pub fn add_task(&self, input: AddTaskInput) -> Result<TaskRecord> {
        let _lock = self.acquire_write_lock()?;
        let mut index = self.read_index()?;
        let now = Utc::now();
        let id = self.next_task_id(input.id.as_deref(), &input.title, &index)?;
        let schedule = validate_schedule(input.schedule)?;
        let task = TaskRecord {
            summary: TaskSummary {
                id: id.clone(),
                title: input.title.trim().to_string(),
                status: TaskStatus::Todo,
                created_at: now,
                updated_at: now,
                ready_at: resolve_ready_at(input.ready_at, schedule.as_ref(), now)?,
                schedule,
                labels: normalize_labels(input.labels),
                depends_on: Vec::new(),
                continuation: TaskContinuation::default(),
            },
            summary_text: normalize_optional_text(input.summary_text),
            blocked_reason: None,
            completed_at: None,
            completed_note: None,
            active_at: None,
            checkpointed_at: None,
            review_requested_at: None,
            notes: Vec::new(),
        };

        index.tasks.insert(id.clone(), task.summary.clone());
        self.write_task(&task)?;
        self.write_index(&index)?;
        self.append_event(TaskEvent {
            at: now,
            task_id: id,
            kind: TaskEventKind::Created,
            status: Some(TaskStatus::Todo),
            message: "task created".to_string(),
        })?;
        Ok(task)
    }

    pub fn start_task(&self, id: &str, note: Option<String>) -> Result<TaskRecord> {
        let note = normalize_optional_text(note);
        let id = self.resolve_task_reference(id)?;
        self.update_task_resolved(&id, TaskEventKind::Started, |task, now| {
            if task.summary.status == TaskStatus::Done {
                bail!("cannot start task '{id}' because it is already done");
            }
            task.summary.status = TaskStatus::Active;
            task.summary.updated_at = now;
            task.summary.continuation = TaskContinuation::default();
            task.active_at = Some(now);
            task.checkpointed_at = None;
            task.blocked_reason = None;
            if let Some(note) = note {
                task.notes.push(TaskNote {
                    at: now,
                    text: note,
                });
            }
            Ok("task started".to_string())
        })
    }

    pub fn checkpoint_task(&self, id: &str, update: ProgressUpdate) -> Result<TaskRecord> {
        let update = update.normalize();
        let id = self.resolve_task_reference(id)?;
        self.update_task_resolved(&id, TaskEventKind::Checkpointed, |task, now| {
            if task.summary.status == TaskStatus::Done {
                bail!("cannot checkpoint task '{id}' because it is already done");
            }
            task.summary.status = TaskStatus::Checkpoint;
            task.summary.updated_at = now;
            task.summary.continuation = update.continuation();
            task.checkpointed_at = Some(now);
            task.blocked_reason = None;
            if let Some(note) = update.note.clone() {
                task.notes.push(TaskNote {
                    at: now,
                    text: note,
                });
            }
            Ok(describe_progress_message(
                "checkpoint saved",
                &task.summary.continuation,
            ))
        })
    }

    pub fn block_task(&self, id: &str, reason: String) -> Result<TaskRecord> {
        let id = self.resolve_task_reference(id)?;
        self.update_task_resolved(&id, TaskEventKind::Blocked, |task, now| {
            if task.summary.status == TaskStatus::Done {
                bail!("cannot block task '{id}' because it is already done");
            }
            let reason = normalize_required_text(reason.clone(), "block reason")?;
            task.summary.status = TaskStatus::Blocked;
            task.summary.updated_at = now;
            task.blocked_reason = Some(reason.clone());
            task.notes.push(TaskNote {
                at: now,
                text: format!("Blocked: {reason}"),
            });
            Ok(format!("task blocked: {reason}"))
        })
    }

    pub fn review_task(&self, id: &str, note: Option<String>) -> Result<TaskRecord> {
        let note = normalize_optional_text(note);
        let id = self.resolve_task_reference(id)?;
        self.update_task_resolved(&id, TaskEventKind::ReviewRequested, |task, now| {
            if task.summary.status == TaskStatus::Done {
                bail!("cannot send task '{id}' to review because it is already done");
            }
            task.summary.status = TaskStatus::Review;
            task.summary.updated_at = now;
            task.review_requested_at = Some(now);
            task.blocked_reason = None;
            if let Some(note) = note.clone() {
                task.notes.push(TaskNote {
                    at: now,
                    text: note.clone(),
                });
                Ok(format!("review requested: {note}"))
            } else {
                Ok("review requested".to_string())
            }
        })
    }

    pub fn configure_schedule(&self, id: &str, update: ScheduleUpdate) -> Result<TaskRecord> {
        let schedule = validate_schedule(update.schedule)?;
        let id = self.resolve_task_reference(id)?;
        self.update_task_resolved(&id, TaskEventKind::ScheduleUpdated, move |task, now| {
            if update.clear {
                if schedule.is_some() || update.ready_at.is_some() {
                    bail!("--clear cannot be combined with --cron, --every-minutes, or --ready-at");
                }
                task.summary.schedule = None;
                task.summary.ready_at = None;
                return Ok("schedule cleared".to_string());
            }

            let Some(schedule) = schedule.clone() else {
                bail!("schedule update requires --cron, --every-minutes, or --clear");
            };
            task.summary.schedule = Some(schedule.clone());
            task.summary.ready_at = resolve_ready_at(update.ready_at, Some(&schedule), now)?;
            Ok(format!(
                "schedule updated: {} next={}",
                schedule,
                task.summary
                    .ready_at
                    .as_ref()
                    .map(DateTime::<Utc>::to_rfc3339)
                    .unwrap_or_else(|| "none".to_string())
            ))
        })
    }

    pub fn complete_task(&self, id: &str, update: ProgressUpdate) -> Result<TaskRecord> {
        let update = update.normalize();
        let id = self.resolve_task_reference(id)?;
        self.update_task_resolved(&id, TaskEventKind::Completed, |task, now| {
            task.summary.updated_at = now;
            task.summary.continuation = update.continuation();
            task.completed_at = Some(now);
            task.blocked_reason = None;
            task.completed_note = update.note.clone();
            if let Some(note) = update.note.clone() {
                task.notes.push(TaskNote {
                    at: now,
                    text: note.clone(),
                });
            }

            if update.clear_schedule {
                task.summary.schedule = None;
                task.summary.ready_at = None;
                task.summary.status = TaskStatus::Done;
                return Ok(describe_progress_message(
                    "task completed; schedule cleared",
                    &task.summary.continuation,
                ));
            }

            if let Some(schedule) = task.summary.schedule.as_ref() {
                let next_ready_at = next_scheduled_ready_at(task.summary.ready_at, schedule, now)?;
                task.summary.status = TaskStatus::Todo;
                task.summary.ready_at = Some(next_ready_at);
                return Ok(format!(
                    "{}; next ready at {}",
                    describe_progress_message("cycle completed", &task.summary.continuation),
                    next_ready_at.to_rfc3339()
                ));
            }

            task.summary.status = TaskStatus::Done;
            Ok(describe_progress_message(
                "task completed",
                &task.summary.continuation,
            ))
        })
    }

    pub fn add_note(&self, id: &str, text: String) -> Result<TaskRecord> {
        let id = self.resolve_task_reference(id)?;
        self.update_task_resolved(&id, TaskEventKind::NoteAdded, |task, now| {
            let text = normalize_required_text(text.clone(), "note")?;
            task.summary.updated_at = now;
            task.notes.push(TaskNote {
                at: now,
                text: text.clone(),
            });
            Ok(format!("note added: {text}"))
        })
    }

    pub fn add_dependency(&self, task_id: &str, dependency_id: &str) -> Result<TaskRecord> {
        let _lock = self.acquire_write_lock()?;
        let mut index = self.read_index()?;
        let task_id = self.resolve_task_reference_in_index(&index, task_id)?;
        let dependency_id = self.resolve_task_reference_in_index(&index, dependency_id)?;
        ensure_distinct(&task_id, &dependency_id, "dependency")?;
        ensure_task_exists(&index, &task_id)?;
        ensure_task_exists(&index, &dependency_id)?;
        if has_dependency_path(&index, &dependency_id, &task_id) {
            bail!(
                "cannot add dependency '{}' -> '{}' because it would create a cycle",
                task_id,
                dependency_id
            );
        }

        let mut task = self.read_task_by_id(&task_id)?;
        if task
            .summary
            .depends_on
            .iter()
            .any(|value| value == &dependency_id)
        {
            bail!("task '{task_id}' already depends on '{dependency_id}'");
        }

        task.summary.depends_on.push(dependency_id.clone());
        task.summary.depends_on.sort();
        task.summary.updated_at = Utc::now();
        index
            .tasks
            .insert(task.summary.id.clone(), task.summary.clone());
        self.write_task(&task)?;
        self.write_index(&index)?;
        self.append_event(TaskEvent {
            at: task.summary.updated_at,
            task_id: task.summary.id.clone(),
            kind: TaskEventKind::DependencyAdded,
            status: Some(task.summary.status),
            message: format!("task now depends on {dependency_id}"),
        })?;
        Ok(task)
    }

    pub fn remove_dependency(&self, task_id: &str, dependency_id: &str) -> Result<TaskRecord> {
        let _lock = self.acquire_write_lock()?;
        let mut index = self.read_index()?;
        let task_id = self.resolve_task_reference_in_index(&index, task_id)?;
        let dependency_id = self.resolve_task_reference_in_index(&index, dependency_id)?;
        ensure_task_exists(&index, &task_id)?;
        let mut task = self.read_task_by_id(&task_id)?;
        let original_len = task.summary.depends_on.len();
        task.summary
            .depends_on
            .retain(|value| value != &dependency_id);
        if task.summary.depends_on.len() == original_len {
            bail!("task '{task_id}' does not depend on '{dependency_id}'");
        }

        task.summary.updated_at = Utc::now();
        index
            .tasks
            .insert(task.summary.id.clone(), task.summary.clone());
        self.write_task(&task)?;
        self.write_index(&index)?;
        self.append_event(TaskEvent {
            at: task.summary.updated_at,
            task_id: task.summary.id.clone(),
            kind: TaskEventKind::DependencyRemoved,
            status: Some(task.summary.status),
            message: format!("dependency removed: {dependency_id}"),
        })?;
        Ok(task)
    }

    fn update_task_resolved<F>(
        &self,
        id: &str,
        event_kind: TaskEventKind,
        update: F,
    ) -> Result<TaskRecord>
    where
        F: FnOnce(&mut TaskRecord, DateTime<Utc>) -> Result<String>,
    {
        let _lock = self.acquire_write_lock()?;
        let mut index = self.read_index()?;
        let mut task = self.read_task_by_id(id)?;
        let now = Utc::now();
        let message = update(&mut task, now)?;
        task.summary.updated_at = now;
        index
            .tasks
            .insert(task.summary.id.clone(), task.summary.clone());
        self.write_task(&task)?;
        self.write_index(&index)?;
        self.append_event(TaskEvent {
            at: now,
            task_id: task.summary.id.clone(),
            kind: event_kind,
            status: Some(task.summary.status),
            message,
        })?;
        Ok(task)
    }

    fn next_task_id(
        &self,
        preferred: Option<&str>,
        title: &str,
        index: &StoreIndex,
    ) -> Result<String> {
        let base = if let Some(preferred) = preferred {
            let normalized = slugify(preferred);
            if normalized.is_empty() {
                bail!("task id '{preferred}' does not contain usable characters");
            }
            normalized
        } else {
            let title_slug = slugify(title);
            if title_slug.is_empty() {
                bail!("task title must contain letters or numbers");
            }
            format!("{}-{}", Local::now().format("%Y%m%d-%H%M%S"), title_slug)
        };

        if !index.tasks.contains_key(&base) && !self.task_path(&base).exists() {
            return Ok(base);
        }
        if preferred.is_some() {
            bail!("task id '{base}' already exists");
        }

        for counter in 2.. {
            let candidate = format!("{base}-{counter}");
            if !index.tasks.contains_key(&candidate) && !self.task_path(&candidate).exists() {
                return Ok(candidate);
            }
        }
        unreachable!("monotonic integer suffix should eventually become unique")
    }
}