tododo 0.1.2

A minimal terminal todo manager built with Rust and Ratatui
Documentation
use chrono::Datelike;
use clap::{Parser, Subcommand};

use crate::domain::todo::Todo;
use crate::domain::{SortMode, TodoService, TodoStatus};
use crate::error::AppError;

fn pending_todos(todos: Vec<Todo>) -> Vec<Todo> {
    todos
        .into_iter()
        .filter(|t| matches!(t.status, TodoStatus::Pending))
        .collect()
}

#[derive(Parser)]
#[command(name = "todo")]
#[command(about = "A minimalist terminal todo manager", long_about = None)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Commands>,

    #[arg(short, long, help = "List all todos")]
    pub list: bool,

    #[arg(short = 'j', long, help = "Export todos as JSON")]
    pub json: bool,

    #[arg(short = 'i', long, help = "Import todos from JSON (stdin)")]
    pub import: bool,
}

#[derive(Subcommand)]
pub enum Commands {
    #[command(about = "List all todos")]
    List,

    #[command(about = "Add a new todo", alias = "a")]
    Add {
        #[arg(help = "Todo title")]
        title: String,

        #[arg(short = 'c', long, help = "Comment/description")]
        comment: Option<String>,

        #[arg(short = 'p', long, help = "Priority (S, A, B, C)")]
        priority: Option<String>,
    },

    #[command(about = "Mark todo as done")]
    Done {
        #[arg(help = "Todo index (1-based)")]
        index: usize,
    },

    #[command(about = "Export todos as JSON")]
    Json,

    #[command(about = "Delete todo by index")]
    Delete {
        #[arg(help = "Todo index (1-based)")]
        index: usize,
    },

    #[command(name = "import", hide = true)]
    Import,

    #[command(about = "Add comment to todo by index")]
    Comment {
        #[arg(help = "Todo index (1-based)")]
        index: usize,

        #[arg(help = "Comment text to append")]
        text: String,
    },

    #[command(about = "Export completed todos as JSON")]
    Export {
        #[arg(long, help = "Export todos completed this week (Monday to now)")]
        week: bool,
    },
}

pub async fn run_list(service: &TodoService) -> Result<(), AppError> {
    let todos = service.list_active_sorted(SortMode::Priority).await?;
    let pending = pending_todos(todos);
    if pending.is_empty() {
        println!("No todos");
        return Ok(());
    }
    for (i, todo) in pending.iter().enumerate() {
        let status = "â—‹";
        let priority = todo.priority.to_char();
        let priority_display = if priority.is_empty() { " " } else { priority };
        let title = &todo.title;
        let created = todo.created_at.format("%Y-%m-%d %H:%M:%S");
        println!(
            "{} [{}] {:>1} {} ({})",
            i + 1,
            status,
            priority_display,
            title,
            created
        );
    }
    Ok(())
}

pub async fn run_add(
    service: &TodoService,
    title: &str,
    comment: Option<&str>,
    priority: Option<&str>,
) -> Result<(), AppError> {
    service.create(title, "").await?;

    let note = comment.unwrap_or("");
    let priority_char_str = priority.unwrap_or("");

    if !note.is_empty() || !priority_char_str.is_empty() {
        let todos = service.list_active_sorted(SortMode::Priority).await?;
        if let Some(todo) = todos.iter().find(|t| t.title == title.trim()) {
            let note_to_use = if note.is_empty() { &todo.note } else { note };
            let prio_to_use = if priority_char_str.is_empty() {
                ""
            } else {
                priority_char_str
            };
            service.update(&todo.id, &todo.title, note_to_use).await?;
            if !prio_to_use.is_empty() {
                service.set_priority(&todo.id, prio_to_use).await?;
            }
        }
    }
    println!("Added: {}", title.trim());
    Ok(())
}

pub async fn run_done(service: &TodoService, index: usize) -> Result<(), AppError> {
    let todos = service.list_active_sorted(SortMode::Priority).await?;
    let pending = pending_todos(todos);
    if index == 0 || index > pending.len() {
        eprintln!("Error: Invalid index {}", index);
        return Ok(());
    }
    let todo = &pending[index - 1];
    service.toggle(&todo.id).await?;
    println!("Completed: {}", todo.title);
    Ok(())
}

pub async fn run_delete(service: &TodoService, index: usize) -> Result<(), AppError> {
    let todos = service.list_active_sorted(SortMode::Priority).await?;
    let pending = pending_todos(todos);
    if index == 0 || index > pending.len() {
        eprintln!("Error: Invalid index {}", index);
        return Ok(());
    }
    let todo = &pending[index - 1];
    service.delete(&todo.id).await?;
    println!("Deleted: {}", todo.title);
    Ok(())
}

pub async fn run_comment(service: &TodoService, index: usize, text: &str) -> Result<(), AppError> {
    let todos = service.list_active_sorted(SortMode::Priority).await?;
    let pending = pending_todos(todos);
    if index == 0 || index > pending.len() {
        eprintln!("Error: Invalid index {}", index);
        return Ok(());
    }
    let todo = &pending[index - 1];
    let new_note = if todo.note.is_empty() {
        text.to_string()
    } else {
        format!("{}\n{}", todo.note, text)
    };
    service.update(&todo.id, &todo.title, &new_note).await?;
    println!("Comment added to: {}", todo.title);
    Ok(())
}

pub async fn run_export_week(service: &TodoService) -> Result<(), AppError> {
    let now = chrono::Local::now();
    let offset = *now.offset();
    let iso = now.date_naive().iso_week();
    let monday = chrono::NaiveDate::from_isoywd_opt(iso.year(), iso.week(), chrono::Weekday::Mon)
        .unwrap()
        .and_hms_opt(0, 0, 0)
        .unwrap()
        .and_utc()
        .with_timezone(&offset);
    let todos = service.list_completed_since(monday).await?;
    let json =
        serde_json::to_string_pretty(&todos).map_err(|e| AppError::Validation(e.to_string()))?;
    println!("{}", json);
    Ok(())
}

pub async fn run_json_export(service: &TodoService) -> Result<(), AppError> {
    let todos = service.list_active_sorted(SortMode::Priority).await?;
    let json =
        serde_json::to_string_pretty(&todos).map_err(|e| AppError::Validation(e.to_string()))?;
    println!("{}", json);
    Ok(())
}

pub async fn run_json_import(service: &TodoService) -> Result<(), AppError> {
    use std::io::Read;
    let mut input = String::new();
    std::io::stdin()
        .read_to_string(&mut input)
        .map_err(|e| AppError::Validation(e.to_string()))?;
    let todos: Vec<crate::entity::todo_item::Model> =
        serde_json::from_str(&input).map_err(|e| AppError::Validation(e.to_string()))?;
    let count = todos.len();
    for todo in todos {
        service.create(&todo.title, "").await?;
        let todos = service.list_active_sorted(SortMode::Priority).await?;
        if let Some(created) = todos.iter().find(|t| t.title == todo.title) {
            if !todo.note.is_empty() {
                service
                    .update(&created.id, &created.title, &todo.note)
                    .await?;
            }
            if todo.priority != 5 {
                let prio_char = match todo.priority {
                    1 => "S",
                    2 => "A",
                    3 => "B",
                    4 => "C",
                    _ => "",
                };
                if !prio_char.is_empty() {
                    service.set_priority(&created.id, prio_char).await?;
                }
            }
            if todo.status == TodoStatus::COMPLETED {
                service.toggle(&created.id).await?;
            }
        }
    }
    println!("Imported {} todos", count);
    Ok(())
}