tasks-cli-rs 0.1.1

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use std::io::Write;
use std::path::PathBuf;

use chrono::{DateTime, NaiveDate, Utc};

use crate::error::{Error, Result};
use crate::library::Registry;
use crate::model::{Priority, Task};
use crate::storage;

pub fn active_library_root() -> Result<PathBuf> {
    let registry = Registry::load()?;
    let (_, path) = registry.current_library()?;
    Ok(path.to_path_buf())
}

pub fn parse_due(due: &str) -> Result<DateTime<Utc>> {
    let date: NaiveDate = due
        .parse()
        .map_err(|_| Error::InvalidTaskFile(format!("invalid due date '{due}', expected YYYY-MM-DD")))?;
    Ok(date.and_hms_opt(23, 59, 59).unwrap().and_utc())
}

/// Template priority: library .tasks-meta.toml > global config > default.
fn filename_template(root: &std::path::Path) -> Result<String> {
    if let Some(f) = storage::LibraryMeta::load(root)?.filename_format {
        return Ok(f);
    }
    if let Some(f) = crate::config::Config::load()?.filename_format {
        return Ok(f);
    }
    Ok(storage::DEFAULT_FILENAME_FORMAT.to_string())
}

/// Renames the task file to match the current template when the rendered
/// name differs (e.g. after a title change). Returns the current path.
fn sync_filename(root: &std::path::Path, st: &storage::StoredTask) -> Result<PathBuf> {
    let template = filename_template(root)?;
    let new_name = storage::render_filename(&template, &st.task)?;
    let new_path = st.path.with_file_name(&new_name);
    if new_path != st.path {
        std::fs::rename(&st.path, &new_path)?;
        println!("renamed: {new_name}");
    }
    Ok(new_path)
}

pub fn new(
    title: String,
    priority: Option<String>,
    tags: Vec<String>,
    due: Option<String>,
    dir: Option<String>,
) -> Result<()> {
    let root = active_library_root()?;
    let seq = storage::LibraryMeta::allocate_seq(&root)?;
    let mut task = Task::new(seq, title);
    if let Some(p) = priority {
        task.meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
    }
    task.meta.tags = tags;
    if let Some(d) = due {
        task.meta.due_date = Some(parse_due(&d)?);
    }
    let target_dir = match dir {
        Some(d) => root.join(d),
        None => root.clone(),
    };
    let template = filename_template(&root)?;
    let path = target_dir.join(storage::render_filename(&template, &task)?);
    storage::write_task(&path, &task)?;
    println!("created task #{seq} [{}] {}", task.short_id(), task.meta.title);
    println!("  {}", path.display());
    Ok(())
}

pub struct ListArgs {
    pub dir: Option<String>,
    pub status: Option<String>,
    pub tag: Option<String>,
    pub priority: Option<String>,
    pub due_before: Option<String>,
    pub due_after: Option<String>,
}

fn build_filter(
    status: Option<String>,
    tag: Option<String>,
    priority: Option<String>,
    due_before: Option<String>,
    due_after: Option<String>,
    text: Option<String>,
) -> Result<crate::filter::Filter> {
    Ok(crate::filter::Filter {
        status: status
            .map(|s| s.parse().map_err(Error::InvalidTaskFile))
            .transpose()?,
        tag,
        priority: priority
            .map(|p| p.parse().map_err(Error::InvalidTaskFile))
            .transpose()?,
        due_before: due_before.map(|d| parse_due(&d)).transpose()?,
        due_after: due_after
            .map(|d| Ok::<_, Error>(parse_due(&d)? - chrono::Duration::seconds(86399)))
            .transpose()?,
        text,
    })
}

pub fn list(args: ListArgs) -> Result<()> {
    let root = active_library_root()?;
    let scope = match &args.dir {
        Some(d) => root.join(d),
        None => root.clone(),
    };
    if !scope.exists() {
        return Err(Error::TaskNotFound(format!("directory {}", scope.display())));
    }
    let filter = build_filter(args.status, args.tag, args.priority, args.due_before, args.due_after, None)?;
    let tasks: Vec<_> = storage::load_all_tasks(&scope)?
        .into_iter()
        .filter(|t| filter.matches(&t.task))
        .collect();
    print_task_table(&tasks);
    Ok(())
}

pub fn search(query: String, status: Option<String>, tag: Option<String>) -> Result<()> {
    let root = active_library_root()?;
    let filter = build_filter(status, tag, None, None, None, Some(query))?;
    let tasks: Vec<_> = storage::load_all_tasks(&root)?
        .into_iter()
        .filter(|t| filter.matches(&t.task))
        .collect();
    print_task_table(&tasks);
    Ok(())
}

pub fn tag_add(id: &str, tags: Vec<String>) -> Result<()> {
    set_fields(id, None, None, None, tags, Vec::new())
}

pub fn tag_remove(id: &str, tags: Vec<String>) -> Result<()> {
    set_fields(id, None, None, None, Vec::new(), tags)
}

pub fn tag_list() -> Result<()> {
    let root = active_library_root()?;
    let mut counts = std::collections::BTreeMap::<String, usize>::new();
    for st in storage::load_all_tasks(&root)? {
        for tag in st.task.meta.tags {
            *counts.entry(tag).or_default() += 1;
        }
    }
    if counts.is_empty() {
        println!("no tags");
    }
    for (tag, n) in counts {
        println!("{tag}\t{n}");
    }
    Ok(())
}

pub fn print_task_table(tasks: &[storage::StoredTask]) {
    if tasks.is_empty() {
        println!("no tasks");
        return;
    }
    println!("{:<5} {:<9} {:<12} {:<8} {:<12} {:<20} TITLE", "SEQ", "ID", "STATUS", "PRI", "DUE", "TAGS");
    for st in tasks {
        let m = &st.task.meta;
        let due = m
            .due_date
            .map(|d| d.date_naive().to_string())
            .unwrap_or_else(|| "-".into());
        let tags = if m.tags.is_empty() { "-".into() } else { m.tags.join(",") };
        println!(
            "{:<5} {:<9} {:<12} {:<8} {:<12} {:<20} {}",
            format!("#{}", m.seq),
            st.task.short_id(),
            m.status.to_string(),
            m.priority.to_string(),
            due,
            tags,
            m.title
        );
    }
}

pub fn show(id: &str) -> Result<()> {
    let root = active_library_root()?;
    let st = storage::resolve_task(&root, id)?;
    let m = &st.task.meta;
    println!("#{} [{}] {}", m.seq, st.task.short_id(), m.title);
    println!("  uuid:      {}", m.id);
    println!("  status:    {}", m.status);
    println!("  priority:  {}", m.priority);
    println!("  tags:      {}", if m.tags.is_empty() { "-".into() } else { m.tags.join(", ") });
    println!("  created:   {}", m.created_at);
    if let Some(t) = m.started_at {
        println!("  started:   {t}");
    }
    if let Some(t) = m.due_date {
        println!("  due:       {t}");
    }
    if let Some(t) = m.completed_at {
        println!("  completed: {t}");
    }
    println!("  file:      {}", st.path.display());
    if !m.steps.is_empty() {
        let done = m.steps.iter().filter(|s| s.status == crate::model::Status::Done).count();
        println!("  steps:     {done}/{}", m.steps.len());
        for s in &m.steps {
            let mark = match s.status {
                crate::model::Status::Done => "x",
                crate::model::Status::InProgress => ">",
                _ => " ",
            };
            print!("    [{mark}] {} {}", s.id, s.title);
            if !s.depends_on.is_empty() {
                print!(" (depends on: {})", s.depends_on.join(", "));
            }
            println!();
        }
    }
    if !st.task.body.is_empty() {
        println!("\n{}", st.task.body.trim_end());
    }
    Ok(())
}

pub fn delete(id: &str, force: bool) -> Result<()> {
    let root = active_library_root()?;
    let st = storage::resolve_task(&root, id)?;
    if !force && !confirm(&format!("delete task #{} '{}'?", st.task.meta.seq, st.task.meta.title))? {
        println!("aborted");
        return Ok(());
    }
    std::fs::remove_file(&st.path)?;
    println!("deleted #{} {}", st.task.meta.seq, st.task.meta.title);
    Ok(())
}

pub fn set_status(id: &str, status: crate::model::Status) -> Result<()> {
    let root = active_library_root()?;
    let mut st = storage::resolve_task(&root, id)?;
    st.task.set_status(status);
    storage::write_task(&st.path, &st.task)?;
    println!("#{} {} -> {}", st.task.meta.seq, st.task.meta.title, status);
    Ok(())
}

pub fn set_fields(
    id: &str,
    title: Option<String>,
    priority: Option<String>,
    due: Option<String>,
    tag_add: Vec<String>,
    tag_remove: Vec<String>,
) -> Result<()> {
    let root = active_library_root()?;
    let mut st = storage::resolve_task(&root, id)?;
    let old_title = st.task.meta.title.clone();
    if let Some(t) = title {
        st.task.meta.title = t;
    }
    if let Some(p) = priority {
        st.task.meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
    }
    if let Some(d) = due {
        st.task.meta.due_date = if d == "none" { None } else { Some(parse_due(&d)?) };
    }
    for tag in tag_add {
        if !st.task.meta.tags.contains(&tag) {
            st.task.meta.tags.push(tag);
        }
    }
    st.task.meta.tags.retain(|t| !tag_remove.contains(t));
    storage::write_task(&st.path, &st.task)?;
    if st.task.meta.title != old_title {
        sync_filename(&root, &st)?;
    }
    println!("updated #{} {}", st.task.meta.seq, st.task.meta.title);
    Ok(())
}

pub fn edit(id: Option<String>, editor_flag: Option<String>, lib: bool) -> Result<()> {
    let config = crate::config::Config::load()?;
    let editor = crate::editor::detect(editor_flag.as_deref(), &config)?;
    let root = active_library_root()?;
    if lib {
        return crate::editor::open(&editor, &root);
    }
    let id = id.ok_or_else(|| Error::InvalidTaskFile("missing task id (or use --lib)".into()))?;
    let st = storage::resolve_task(&root, &id)?;
    let original = std::fs::read_to_string(&st.path)?;
    crate::editor::open(&editor, &st.path)?;
    let edited = std::fs::read_to_string(&st.path)?;
    match validate_edit(&st.task, &edited) {
        Ok(task) => {
            println!("saved #{} {}", task.meta.seq, task.meta.title);
            if task.meta.title != st.task.meta.title {
                let updated = storage::StoredTask { path: st.path.clone(), task };
                sync_filename(&root, &updated)?;
            }
            Ok(())
        }
        Err(reason) => {
            std::fs::write(&st.path, original)?;
            Err(Error::EditRejected(reason))
        }
    }
}

/// The file must still parse and the immutable fields (id, seq, created_at)
/// must be unchanged; otherwise returns the rejection reason.
fn validate_edit(before: &Task, edited_content: &str) -> std::result::Result<Task, String> {
    let task = storage::parse_task(edited_content).map_err(|e| e.to_string())?;
    if task.meta.id != before.meta.id {
        return Err(format!("field 'id' is immutable ({} -> {})", before.meta.id, task.meta.id));
    }
    if task.meta.seq != before.meta.seq {
        return Err(format!("field 'seq' is immutable ({} -> {})", before.meta.seq, task.meta.seq));
    }
    if task.meta.created_at != before.meta.created_at {
        return Err("field 'created_at' is immutable".into());
    }
    Ok(task)
}

fn confirm(prompt: &str) -> Result<bool> {
    print!("{prompt} [y/N] ");
    std::io::stdout().flush()?;
    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;
    Ok(matches!(input.trim(), "y" | "Y" | "yes"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_due_valid() {
        let dt = parse_due("2026-08-01").unwrap();
        assert_eq!(dt.date_naive().to_string(), "2026-08-01");
    }

    #[test]
    fn parse_due_invalid() {
        assert!(parse_due("tomorrow").is_err());
        assert!(parse_due("2026-13-01").is_err());
    }

    #[test]
    fn validate_edit_accepts_mutable_changes() {
        let task = Task::new(1, "old title");
        let mut edited = task.clone();
        edited.meta.title = "new title".into();
        edited.meta.tags = vec!["x".into()];
        edited.body = "new body".into();
        let content = storage::render_task(&edited).unwrap();
        let got = validate_edit(&task, &content).unwrap();
        assert_eq!(got.meta.title, "new title");
    }

    #[test]
    fn validate_edit_rejects_broken_yaml() {
        let task = Task::new(1, "t");
        assert!(validate_edit(&task, "not a task file").is_err());
        assert!(validate_edit(&task, "---\nid: [broken\n---\n").is_err());
    }

    #[test]
    fn validate_edit_rejects_immutable_fields() {
        let task = Task::new(1, "t");

        let mut changed = task.clone();
        changed.meta.id = uuid::Uuid::new_v4();
        let err = validate_edit(&task, &storage::render_task(&changed).unwrap()).unwrap_err();
        assert!(err.contains("'id'"));

        let mut changed = task.clone();
        changed.meta.seq = 99;
        let err = validate_edit(&task, &storage::render_task(&changed).unwrap()).unwrap_err();
        assert!(err.contains("'seq'"));

        let mut changed = task.clone();
        changed.meta.created_at = Utc::now() + chrono::Duration::days(1);
        let err = validate_edit(&task, &storage::render_task(&changed).unwrap()).unwrap_err();
        assert!(err.contains("'created_at'"));
    }
}