tli 0.1.2

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

use anyhow::{Result, bail};
use serde::Deserialize;

use crate::model::{
    ReadyTask, StateSnapshot, StateTask, TaskDetail, TaskEvent, TaskRecord, TaskSchedule,
    TaskStatus, TaskSummary,
};
use crate::root::parse_timestamp;
use crate::store::{AddTaskInput, ListFilter, ProgressUpdate, ScheduleUpdate, TaskStore};

#[derive(Debug, Clone)]
pub(crate) struct TaskService {
    store: TaskStore,
}

impl TaskService {
    pub(crate) fn new(store: TaskStore) -> Self {
        Self { store }
    }

    pub(crate) fn root(&self) -> &Path {
        self.store.root()
    }

    pub(crate) fn add_task(&self, input: AddTaskRequest) -> Result<TaskRecord> {
        self.store.add_task(AddTaskInput {
            id: normalize_optional(input.id),
            title: input.title,
            summary_text: normalize_optional(input.summary),
            ready_at: parse_optional_timestamp(input.ready_at)?,
            schedule: schedule_from_fields(input.every_minutes, input.cron)?,
            labels: split_csv_values(input.labels),
        })
    }

    pub(crate) fn schedule_task(&self, id: &str, input: ScheduleTaskRequest) -> Result<TaskRecord> {
        self.store.configure_schedule(
            id,
            ScheduleUpdate {
                schedule: schedule_from_fields(input.every_minutes, input.cron)?,
                ready_at: parse_optional_timestamp(input.ready_at)?,
                clear: input.clear.unwrap_or(false),
            },
        )
    }

    pub(crate) fn list_tasks(&self, query: TaskListQuery) -> Result<Vec<TaskSummary>> {
        self.store.list_tasks(&ListFilter {
            statuses: query.status,
            include_done_by_default: query.all.unwrap_or(false),
            ready_only: query.ready.unwrap_or(false),
            labels: split_csv_values(query.label),
            query: normalize_optional(query.query),
            limit: query.limit,
        })
    }

    pub(crate) fn ready_tasks(&self, query: ReadyQuery) -> Result<Vec<ReadyTask>> {
        self.store
            .ready_tasks(normalize_optional(query.query), query.limit)
    }

    pub(crate) fn state_snapshot(&self, query: StateQuery) -> Result<StateSnapshot> {
        self.store
            .state_snapshot(normalize_optional(query.query), query.limit.unwrap_or(50))
    }

    pub(crate) fn continuation_tasks(&self, query: ContinuationQuery) -> Result<Vec<StateTask>> {
        self.store.continuation_tasks(query.limit.unwrap_or(50))
    }

    pub(crate) fn task_detail(&self, id: &str) -> Result<TaskDetail> {
        self.store.task_detail(id)
    }

    pub(crate) fn task_events(
        &self,
        id: Option<&str>,
        limit: Option<usize>,
    ) -> Result<Vec<TaskEvent>> {
        self.store.read_events(id, limit)
    }

    pub(crate) fn start_task(&self, id: &str, input: NoteRequest) -> Result<TaskRecord> {
        self.store.start_task(id, normalize_optional(input.note))
    }

    pub(crate) fn checkpoint_task(&self, id: &str, input: ProgressRequest) -> Result<TaskRecord> {
        self.store.checkpoint_task(id, input.into_update(false))
    }

    pub(crate) fn block_task(&self, id: &str, input: BlockTaskRequest) -> Result<TaskRecord> {
        self.store.block_task(id, input.reason)
    }

    pub(crate) fn review_task(&self, id: &str, input: NoteRequest) -> Result<TaskRecord> {
        self.store.review_task(id, normalize_optional(input.note))
    }

    pub(crate) fn complete_task(&self, id: &str, input: DoneTaskRequest) -> Result<TaskRecord> {
        self.store.complete_task(
            id,
            ProgressUpdate {
                note: normalize_optional(input.note),
                next_step: normalize_optional(input.next_step),
                next_task: normalize_optional(input.next_task),
                clear_schedule: input.clear_schedule.unwrap_or(false),
            },
        )
    }

    pub(crate) fn add_note(&self, id: &str, input: AddNoteRequest) -> Result<TaskRecord> {
        self.store.add_note(id, input.text)
    }

    pub(crate) fn add_dependency(
        &self,
        id: &str,
        input: DependencyTaskRequest,
    ) -> Result<TaskRecord> {
        self.store.add_dependency(id, &input.dependency)
    }

    pub(crate) fn remove_dependency(
        &self,
        id: &str,
        input: DependencyTaskRequest,
    ) -> Result<TaskRecord> {
        self.store.remove_dependency(id, &input.dependency)
    }
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct AddTaskRequest {
    pub(crate) title: String,
    #[serde(default)]
    pub(crate) id: Option<String>,
    #[serde(default)]
    pub(crate) summary: Option<String>,
    #[serde(default)]
    pub(crate) ready_at: Option<String>,
    #[serde(default)]
    pub(crate) every_minutes: Option<u32>,
    #[serde(default)]
    pub(crate) cron: Option<String>,
    #[serde(default)]
    pub(crate) labels: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct ScheduleTaskRequest {
    #[serde(default)]
    pub(crate) every_minutes: Option<u32>,
    #[serde(default)]
    pub(crate) cron: Option<String>,
    #[serde(default)]
    pub(crate) ready_at: Option<String>,
    #[serde(default)]
    pub(crate) clear: Option<bool>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct TaskListQuery {
    #[serde(default)]
    pub(crate) status: Vec<TaskStatus>,
    #[serde(default)]
    pub(crate) all: Option<bool>,
    #[serde(default)]
    pub(crate) ready: Option<bool>,
    #[serde(default)]
    pub(crate) label: Option<String>,
    #[serde(default)]
    pub(crate) query: Option<String>,
    #[serde(default)]
    pub(crate) limit: Option<usize>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct ReadyQuery {
    #[serde(default)]
    pub(crate) query: Option<String>,
    #[serde(default)]
    pub(crate) limit: Option<usize>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct StateQuery {
    #[serde(default)]
    pub(crate) query: Option<String>,
    #[serde(default)]
    pub(crate) limit: Option<usize>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct ContinuationQuery {
    #[serde(default)]
    pub(crate) limit: Option<usize>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct EventsQuery {
    #[serde(default)]
    pub(crate) limit: Option<usize>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct NoteRequest {
    #[serde(default)]
    pub(crate) note: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct ProgressRequest {
    #[serde(default)]
    pub(crate) note: Option<String>,
    #[serde(default)]
    pub(crate) next_step: Option<String>,
    #[serde(default)]
    pub(crate) next_task: Option<String>,
}

impl ProgressRequest {
    fn into_update(self, clear_schedule: bool) -> ProgressUpdate {
        ProgressUpdate {
            note: normalize_optional(self.note),
            next_step: normalize_optional(self.next_step),
            next_task: normalize_optional(self.next_task),
            clear_schedule,
        }
    }
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct DoneTaskRequest {
    #[serde(default)]
    pub(crate) note: Option<String>,
    #[serde(default)]
    pub(crate) next_step: Option<String>,
    #[serde(default)]
    pub(crate) next_task: Option<String>,
    #[serde(default)]
    pub(crate) clear_schedule: Option<bool>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct BlockTaskRequest {
    pub(crate) reason: String,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct AddNoteRequest {
    pub(crate) text: String,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct DependencyTaskRequest {
    pub(crate) dependency: String,
}

pub(crate) fn schedule_from_fields(
    every_minutes: Option<u32>,
    cron: Option<String>,
) -> Result<Option<TaskSchedule>> {
    match (every_minutes, normalize_optional(cron)) {
        (Some(_), Some(_)) => bail!("every_minutes cannot be combined with cron"),
        (Some(every_minutes), None) => Ok(Some(TaskSchedule::Interval { every_minutes })),
        (None, Some(expression)) => Ok(Some(TaskSchedule::Cron { expression })),
        (None, None) => Ok(None),
    }
}

fn parse_optional_timestamp(
    value: Option<String>,
) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
    normalize_optional(value)
        .as_deref()
        .map(parse_timestamp)
        .transpose()
}

fn split_csv_values(value: Option<String>) -> Vec<String> {
    normalize_optional(value)
        .map(|value| {
            value
                .split(',')
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(str::to_string)
                .collect()
        })
        .unwrap_or_default()
}

fn normalize_optional(value: Option<String>) -> Option<String> {
    value
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}