sara-tasks 1.8.1

Sara — folder-aware task manager
use anyhow::Result;
use chrono::Utc;
use rusqlite::Connection;
use serde_json::{Value, json};

use crate::commands::shared::{
    normalize_list, parse_due, parse_duration_mins, print_cancelled, split_csv,
};
use crate::infrastructure::config::Config;
use crate::infrastructure::db;
use crate::infrastructure::model::Task;
use crate::infrastructure::tui;
use crate::infrastructure::tui::review_form::{FormContext, FormInput, run_form};

mod render;

#[allow(clippy::too_many_arguments)]
pub fn run(
    conn: &Connection,
    cfg: &Config,
    id_or_uuid: &str,
    description: Option<&str>,
    priority: Option<&str>,
    due: Option<&str>,
    clear_due: bool,
    tags: &[String],
    clear_tags: bool,
    estimate: Option<&str>,
    clear_estimate: bool,
    every: Option<&str>,
    clear_recur: bool,
) -> Result<()> {
    let task = db::resolve_task(conn, id_or_uuid)?;

    let has_field_flags = description.is_some()
        || priority.is_some()
        || due.is_some()
        || clear_due
        || !tags.is_empty()
        || clear_tags
        || estimate.is_some()
        || clear_estimate
        || every.is_some()
        || clear_recur;
    if has_field_flags {
        return apply_fields(
            conn,
            cfg,
            task,
            description,
            priority,
            due,
            clear_due,
            tags,
            clear_tags,
            estimate,
            clear_estimate,
            every,
            clear_recur,
        );
    }

    let pending = db::list_tasks(conn, None)?;
    let available_deps: Vec<(String, String)> = pending
        .iter()
        .filter(|t| t.uuid != task.uuid)
        .map(|t| {
            let id = format!("{}", t.id.unwrap_or(0));
            (id, t.description.clone())
        })
        .collect();

    let project_files: Vec<String> = db::get_project(conn, &task.project)?
        .and_then(|p| p.path)
        .map(|p| {
            crate::infrastructure::util::files::collect_project_entries(std::path::Path::new(&p))
        })
        .unwrap_or_default();

    let current_files = db::get_task_files(conn, &task.uuid)?;

    let due_str = task
        .due
        .map(|d| d.format("%Y-%m-%d").to_string())
        .unwrap_or_default();

    let ctx = FormContext {
        initial: FormInput {
            description: task.description.clone(),
            project: task.project.clone(),
            priority: task.priority.clone(),
            due: due_str,
            tags: task.tags.join(","),
            selected_deps: vec![],
            selected_files: current_files,
        },
        available_deps,
        available_files: project_files,
        suggested_dep_indices: vec![],
        suggested_files: vec![],
    };

    let result = tui::with_terminal(|t| run_form(t, ctx));

    let Some(form) = result? else {
        print_cancelled();
        return Ok(());
    };

    let mut updated = task.clone();
    updated.description = form.description;
    updated.project = form.project.clone();
    updated.priority = form.priority;
    updated.tags = split_csv(&form.tags);
    updated.modified = Utc::now();

    if form.due.is_empty() {
        updated.due = None;
    } else {
        updated.due = parse_due(&form.due, cfg);
    }

    updated.urgency = db::compute_urgency(&updated, &cfg.urgency, false, 0);
    db::update_task(conn, &updated)?;

    db::set_task_files(conn, &updated.uuid, &form.selected_files)?;

    db::refresh_urgency(conn, &cfg.urgency, &updated.uuid)?;

    render::print_updated(updated.id.unwrap_or(0), &updated.description);
    Ok(())
}

#[allow(clippy::too_many_arguments)]
pub fn modify_value(
    conn: &Connection,
    cfg: &Config,
    id_or_uuid: &str,
    description: Option<&str>,
    priority: Option<&str>,
    due: Option<&str>,
    clear_due: bool,
    tags: &[String],
    clear_tags: bool,
    estimate: Option<&str>,
    clear_estimate: bool,
    every: Option<&str>,
    clear_recur: bool,
) -> Result<Value> {
    let has_field = description.is_some()
        || priority.is_some()
        || due.is_some()
        || clear_due
        || !tags.is_empty()
        || clear_tags
        || estimate.is_some()
        || clear_estimate
        || every.is_some()
        || clear_recur;
    anyhow::ensure!(
        has_field,
        "modify requires at least one field to change (description, priority, due, clear_due, tags, clear_tags, estimate, clear_estimate, every, or clear_recur)"
    );

    let task = db::resolve_task(conn, id_or_uuid)?;
    let mut updated = merge_task_fields(
        task,
        cfg,
        description,
        priority,
        due,
        clear_due,
        tags,
        clear_tags,
        estimate,
        clear_estimate,
        every,
        clear_recur,
    )?;

    updated.urgency = db::compute_urgency(&updated, &cfg.urgency, false, 0);
    db::update_task(conn, &updated)?;
    db::refresh_urgency(conn, &cfg.urgency, &updated.uuid)?;

    Ok(json!({
        "task": updated.id,
        "uuid": updated.uuid.to_string(),
        "description": updated.description,
        "priority": updated.priority.as_ref().map(|p| p.label()),
        "due": updated.due.map(|d| d.format("%Y-%m-%d").to_string()),
        "tags": updated.tags,
        "estimate_mins": updated.estimate_mins,
        "recur": updated.recur,
    }))
}

#[allow(clippy::too_many_arguments)]
fn apply_fields(
    conn: &Connection,
    cfg: &Config,
    task: Task,
    description: Option<&str>,
    priority: Option<&str>,
    due: Option<&str>,
    clear_due: bool,
    tags: &[String],
    clear_tags: bool,
    estimate: Option<&str>,
    clear_estimate: bool,
    every: Option<&str>,
    clear_recur: bool,
) -> Result<()> {
    let uuid = task.uuid.to_string();
    let v = modify_value(
        conn,
        cfg,
        &uuid,
        description,
        priority,
        due,
        clear_due,
        tags,
        clear_tags,
        estimate,
        clear_estimate,
        every,
        clear_recur,
    )?;
    render::print_updated(
        v["task"].as_i64().unwrap_or(0),
        v["description"].as_str().unwrap_or_default(),
    );
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn merge_task_fields(
    task: Task,
    cfg: &Config,
    description: Option<&str>,
    priority: Option<&str>,
    due: Option<&str>,
    clear_due: bool,
    tags: &[String],
    clear_tags: bool,
    estimate: Option<&str>,
    clear_estimate: bool,
    every: Option<&str>,
    clear_recur: bool,
) -> Result<Task> {
    let mut updated = task;

    if let Some(d) = description {
        updated.description = d.to_string();
    }

    if let Some(p) = priority {
        updated.priority = Some(
            p.parse()
                .map_err(|_| anyhow::anyhow!("Unknown priority: {p} (expected H, M, or L)"))?,
        );
    }

    if clear_tags {
        updated.tags = vec![];
    } else if !tags.is_empty() {
        updated.tags = normalize_list(tags);
    }

    if clear_due {
        updated.due = None;
    } else if let Some(d) = due {
        match parse_due(d, cfg) {
            Some(dt) => updated.due = Some(dt),
            None => anyhow::bail!("Could not parse due date: {d}"),
        }
    }

    if clear_estimate {
        updated.estimate_mins = None;
    } else if let Some(e) = estimate {
        match parse_duration_mins(e) {
            Some(mins) => updated.estimate_mins = Some(mins),
            None => anyhow::bail!(
                "Could not parse estimate: {e} (expected e.g. \"90m\", \"2h\", \"2h30m\")"
            ),
        }
    }

    if clear_recur {
        updated.recur = None;
    } else if let Some(r) = every {
        updated.recur = if r.trim().is_empty() {
            None
        } else {
            Some(r.trim().to_string())
        };
    }

    updated.modified = Utc::now();
    Ok(updated)
}

#[cfg(test)]
#[path = "../../../tests/unit/commands/modify/mod.rs"]
mod tests;