tam 1.0.14

A tasks manager cli tool
use crate::{
    agent::Ctx,
    cli::StatusArg,
    commands::agent::resolve_project_for_human,
    model::{Task, TaskStatus},
    print_info, print_success,
    services::task::{self, AssignedFilter, CreateTaskInput, TaskFilter, TaskUpdateInput},
};

fn human_scope(ctx: &Ctx) -> anyhow::Result<Option<i32>> {
    if ctx.project.is_some() {
        Ok(resolve_project_for_human(ctx)?.id)
    } else {
        Ok(None)
    }
}

fn resolve_actor(ctx: &Ctx) -> String {
    ctx.agent.clone().unwrap_or_else(|| "human".to_string())
}

fn parse_assigned(ctx: &Ctx, assigned: Option<String>) -> anyhow::Result<Option<AssignedFilter>> {
    match assigned.as_deref() {
        None => Ok(None),
        Some("unassigned") => Ok(Some(AssignedFilter::Unassigned)),
        Some("me") => match &ctx.agent {
            Some(agent) => Ok(Some(AssignedFilter::Agent(agent.clone()))),
            None => anyhow::bail!("--assigned me requires --agent (or TAM_AGENT)"),
        },
        Some(agent) => Ok(Some(AssignedFilter::Agent(agent.to_string()))),
    }
}

pub fn add(
    ctx: &Ctx,
    title: String,
    description: Option<String>,
    priority: Option<i32>,
    tag: Vec<String>,
    idempotency_key: Option<String>,
) -> anyhow::Result<bool> {
    let project = resolve_project_for_human(ctx)?;
    let project_id = project
        .id
        .ok_or_else(|| anyhow::anyhow!("project '{}' has no id", project.name))?;
    let (task, deduplicated) = task::create_task(
        project_id,
        CreateTaskInput {
            title,
            description,
            priority,
            tags: tag,
            idempotency_key,
        },
        &resolve_actor(ctx),
    )?;
    print_success!("Task '{}' added successfully.", task.title);
    if deduplicated {
        print_info!(
            "Task {} already existed for this idempotency key.",
            task.id.unwrap_or_default()
        );
    }
    Ok(true)
}

#[allow(clippy::too_many_arguments)]
pub fn list(
    ctx: &Ctx,
    status: Option<StatusArg>,
    assigned: Option<String>,
    priority: Option<i32>,
    tag: Vec<String>,
    search: Option<String>,
    limit: Option<usize>,
) -> anyhow::Result<bool> {
    let project = resolve_project_for_human(ctx)?;
    let project_id = project
        .id
        .ok_or_else(|| anyhow::anyhow!("project '{}' has no id", project.name))?;
    let filter = TaskFilter {
        status: status.map(TaskStatus::from),
        assigned: parse_assigned(ctx, assigned)?,
        priority,
        tags: tag,
        search,
        limit,
    };
    let (tasks, _) = task::list_filtered(project_id, &filter)?;
    if tasks.is_empty() {
        print_info!("No tasks found. Use `tam task add` to add a task.");
    } else {
        print_info!("Current project: {}", project.name);
        Task::print(&tasks);
    }
    Ok(true)
}

pub fn delete(id: i32) -> anyhow::Result<bool> {
    task::delete(id)?;
    print_success!("Task '{}' deleted successfully.", id);
    Ok(true)
}

pub fn start(id: i32) -> anyhow::Result<bool> {
    task::start(id)?;
    print_success!("Task '{}' started successfully.", id);
    Ok(true)
}

pub fn done(ctx: &Ctx, id: i32) -> anyhow::Result<bool> {
    task::complete_task(id, human_scope(ctx)?, ctx.agent.as_deref())?;
    print_success!("Task '{}' marked as done successfully.", id);
    Ok(true)
}

pub fn edit(id: i32, title: String) -> anyhow::Result<bool> {
    task::edit(id, title)?;
    print_success!("Task '{}' updated successfully.", id);
    Ok(true)
}

pub fn show(ctx: &Ctx, id: i32) -> anyhow::Result<bool> {
    let task = task::get_task(id, human_scope(ctx)?)?;
    println!("ID: {}", task.id.unwrap_or_default());
    println!("Project: {} (#{})", task.project, task.project_id);
    println!("Title: {}", task.title);
    if !task.content.is_empty() {
        println!("Description: {}", task.content);
    }
    println!("Status: {}", task.status.as_str());
    println!("Priority: {}", task.priority);
    println!("Tags: {}", task.tags);
    println!(
        "Assigned agent: {}",
        task.assigned_agent.as_deref().unwrap_or("-")
    );
    println!(
        "Lease expires: {}",
        task.lease_expires_at
            .map(|value| value.to_string())
            .unwrap_or_else(|| "-".to_string())
    );
    println!("Revision: {}", task.revision);
    Ok(true)
}

pub fn update(
    ctx: &Ctx,
    id: i32,
    title: Option<String>,
    description: Option<String>,
    status: Option<StatusArg>,
    priority: Option<i32>,
    tags: Option<String>,
) -> anyhow::Result<bool> {
    let updated = task::update_task(
        id,
        human_scope(ctx)?,
        TaskUpdateInput {
            title,
            description,
            status: status.map(TaskStatus::from),
            priority,
            tags: tags.map(|tags| tags.split(',').map(|tag| tag.to_string()).collect()),
        },
        &resolve_actor(ctx),
    )?;
    print_success!("Task '{}' updated successfully.", updated.id.unwrap_or(id));
    Ok(true)
}

pub fn claim(ctx: &Ctx, id: i32, lease: i64, force: bool) -> anyhow::Result<bool> {
    let agent = ctx
        .agent
        .clone()
        .ok_or_else(|| anyhow::anyhow!("this command requires --agent (or TAM_AGENT)"))?;
    let claimed = task::claim_task(id, human_scope(ctx)?, &agent, lease, force)?;
    print_success!(
        "Task '{}' claimed by '{}' until {}.",
        claimed.id.unwrap_or(id),
        agent,
        claimed
            .lease_expires_at
            .map(|value| value.to_string())
            .unwrap_or_else(|| "-".to_string())
    );
    Ok(true)
}

pub fn heartbeat(ctx: &Ctx, id: i32, lease: i64) -> anyhow::Result<bool> {
    let agent = ctx
        .agent
        .clone()
        .ok_or_else(|| anyhow::anyhow!("this command requires --agent (or TAM_AGENT)"))?;
    task::heartbeat_task(id, human_scope(ctx)?, &agent, lease)?;
    print_success!("Task '{}' lease renewed.", id);
    Ok(true)
}

pub fn release(ctx: &Ctx, id: i32, force: bool) -> anyhow::Result<bool> {
    let released = task::release_task(id, human_scope(ctx)?, ctx.agent.as_deref(), force)?;
    print_success!(
        "Task '{}' released (status: {}).",
        released.id.unwrap_or(id),
        released.status.as_str()
    );
    Ok(true)
}

pub fn next(ctx: &Ctx, claim: bool, lease: i64) -> anyhow::Result<bool> {
    let project = resolve_project_for_human(ctx)?;
    let project_id = project
        .id
        .ok_or_else(|| anyhow::anyhow!("project '{}' has no id", project.name))?;
    if claim && ctx.agent.is_none() {
        anyhow::bail!("claiming the next task requires --agent (or TAM_AGENT)");
    }
    match task::next_task(project_id, ctx.agent.as_deref(), claim, lease)? {
        Some(task) => {
            print_info!(
                "Next task: #{} {} [{}] priority {}",
                task.id.unwrap_or_default(),
                task.title,
                task.status.as_str(),
                task.priority
            );
        }
        None => print_info!("No claimable tasks."),
    }
    Ok(true)
}

pub fn events(ctx: &Ctx, id: i32) -> anyhow::Result<bool> {
    let events = task::task_events(id, human_scope(ctx)?)?;
    if events.is_empty() {
        print_info!("No events for task {}.", id);
    } else {
        for event in events {
            println!(
                "#{} {} actor={} from={} to={} at={}",
                event.id,
                event.event,
                event.actor,
                event
                    .from_status
                    .map(|status| status.as_str())
                    .unwrap_or("-"),
                event.to_status.map(|status| status.as_str()).unwrap_or("-"),
                event.created.format("%Y-%m-%d %H:%M:%S")
            );
        }
    }
    Ok(true)
}

pub fn status(ctx: &Ctx) -> anyhow::Result<bool> {
    let project = resolve_project_for_human(ctx)?;
    let project_id = project
        .id
        .ok_or_else(|| anyhow::anyhow!("project '{}' has no id", project.name))?;
    let summary = task::status_summary(project_id)?;
    print_info!("Project: {}", project.name);
    println!(
        "todo={} in-progress={} done={} leased={} unassigned={}",
        summary.todo, summary.in_progress, summary.done, summary.leased, summary.unassigned
    );
    for task in &summary.next {
        println!(
            "next: #{} {} [priority {}] status={}",
            task.id.unwrap_or_default(),
            task.title,
            task.priority,
            task.status.as_str()
        );
    }
    Ok(true)
}