lazytask 0.5.0

A task manager built for AI coding agents — plain markdown files, strict CLI, keyboard-driven TUI
Documentation
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use lazytask::config::load_for_workspace_root;
use lazytask::domain::TaskType;
use lazytask::services::{CreateTaskInput, TaskService};
use lazytask::tui::actions::Action;
use lazytask::tui::app::App;
use std::path::Path;
use tempfile::TempDir;

fn runtime_for_path(path: &Path) -> (TaskService, usize) {
    let config = load_for_workspace_root(path).unwrap();
    let learn_threshold = config.hints.learn_threshold;
    (TaskService::new(config), learn_threshold)
}

#[test]
fn reducer_navigation_stays_in_bounds() {
    let temp = TempDir::new().unwrap();
    let (service, learn_threshold) = runtime_for_path(temp.path());
    service.init().unwrap();

    service
        .create_task(CreateTaskInput {
            title: "Task one".to_string(),
            task_type: TaskType::Task,
            details: "a".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();

    let mut app = App::new(service, learn_threshold);
    app.dispatch(Action::RefreshTasks);

    app.dispatch(Action::MoveSelectionUp);
    assert_eq!(app.state.selected_index, 0);

    app.dispatch(Action::MoveSelectionDown);
    assert_eq!(app.state.selected_index, 0);
}

#[test]
fn create_mode_cancel_returns_to_normal() {
    let temp = TempDir::new().unwrap();
    let (service, learn_threshold) = runtime_for_path(temp.path());
    service.init().unwrap();

    let mut app = App::new(service, learn_threshold);
    app.dispatch(Action::CreateTaskRequested);
    app.on_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

    assert!(matches!(app.state.mode, lazytask::tui::app::Mode::Normal));
}

#[test]
fn create_submission_allows_empty_details() {
    let temp = TempDir::new().unwrap();
    let (service, learn_threshold) = runtime_for_path(temp.path());
    service.init().unwrap();

    let mut app = App::new(service, learn_threshold);
    app.dispatch(Action::CreateTaskSubmitted {
        title: "TUI without details".to_string(),
        task_type: TaskType::Task,
        details: String::new(),
    });

    assert_eq!(app.state.tasks.len(), 1);
    assert_eq!(app.state.tasks[0].title, "TUI without details");
    assert_eq!(app.state.tasks[0].details, "");
}

#[test]
fn delete_can_be_undone_and_logs_shortcut_hint() {
    let temp = TempDir::new().unwrap();
    let (service, learn_threshold) = runtime_for_path(temp.path());
    service.init().unwrap();

    service
        .create_task(CreateTaskInput {
            title: "Undo me".to_string(),
            task_type: TaskType::Task,
            details: "details".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();

    let mut app = App::new(service, learn_threshold);
    app.dispatch(Action::RefreshTasks);
    app.dispatch(Action::DeleteSelected);

    assert_eq!(app.state.tasks.len(), 0);
    assert_eq!(
        app.state.log_entries.back().unwrap().message,
        "task \"Undo me\" deleted (press u to undo)"
    );

    app.dispatch(Action::UndoDelete);
    assert_eq!(app.state.tasks.len(), 1);
    assert_eq!(app.state.tasks[0].title, "Undo me");
}

#[test]
fn startup_logs_learning_hint_when_line_count_exceeds_threshold() {
    let temp = TempDir::new().unwrap();
    std::fs::write(
        temp.path().join("lazytask.toml"),
        "[hints]\nlearn_threshold = 1\n",
    )
    .unwrap();
    let (service, learn_threshold) = runtime_for_path(temp.path());
    service.init().unwrap();

    let learnings = (0..=1)
        .map(|i| format!("- line {i}"))
        .collect::<Vec<_>>()
        .join("\n");
    std::fs::write(temp.path().join(".tasks/LEARNINGS.md"), learnings).unwrap();

    let mut app = App::new(service, learn_threshold);
    app.dispatch(Action::CheckLearningHint);

    let latest_log = app.state.log_entries.back().unwrap();
    assert!(!latest_log.is_error);
    assert!(latest_log.message.contains("lt learn"));
    assert!(latest_log.message.contains("Ask your AI agent"));
}

#[test]
fn edit_mode_cancel_keeps_task_unchanged() {
    let temp = TempDir::new().unwrap();
    let (service, learn_threshold) = runtime_for_path(temp.path());
    service.init().unwrap();

    service
        .create_task(CreateTaskInput {
            title: "Editable task".to_string(),
            task_type: TaskType::Task,
            details: "before".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();

    let mut app = App::new(service, learn_threshold);
    app.dispatch(Action::RefreshTasks);
    app.on_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
    app.on_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
    app.dispatch(Action::RefreshTasks);

    assert!(matches!(app.state.mode, lazytask::tui::app::Mode::Normal));
    assert_eq!(app.state.tasks.len(), 1);
    assert_eq!(app.state.tasks[0].title, "Editable task");
    assert_eq!(app.state.tasks[0].details, "before");
}

#[test]
fn edit_submission_overwrites_selected_task() {
    let temp = TempDir::new().unwrap();
    let (service, learn_threshold) = runtime_for_path(temp.path());
    service.init().unwrap();

    service
        .create_task(CreateTaskInput {
            title: "Editable title".to_string(),
            task_type: TaskType::Task,
            details: "before".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();

    let mut app = App::new(service, learn_threshold);
    app.dispatch(Action::RefreshTasks);
    app.dispatch(Action::EditTaskSubmitted {
        file_name: "editable-title".to_string(),
        title: "Edited title".to_string(),
        task_type: TaskType::Bug,
        details: "after".to_string(),
    });

    assert_eq!(app.state.tasks.len(), 1);
    assert_eq!(app.state.tasks[0].title, "Edited title");
    assert_eq!(app.state.tasks[0].task_type, TaskType::Bug);
    assert_eq!(app.state.tasks[0].details, "after");
}

#[test]
fn discarded_task_cannot_be_edited_started_or_done() {
    let temp = TempDir::new().unwrap();
    let (service, learn_threshold) = runtime_for_path(temp.path());
    service.init().unwrap();

    service
        .create_task(CreateTaskInput {
            title: "Discarded task".to_string(),
            task_type: TaskType::Task,
            details: "before".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();
    service.discard_task("discarded-task").unwrap();

    let mut app = App::new(service, learn_threshold);
    app.dispatch(Action::RefreshTasks);

    app.on_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
    assert!(matches!(app.state.mode, lazytask::tui::app::Mode::Normal));
    assert_eq!(
        app.state.log_entries.back().unwrap().message,
        "discarded tasks are terminal; delete instead"
    );

    app.dispatch(Action::StartSelected);
    assert_eq!(
        app.state.log_entries.back().unwrap().message,
        "start failed: discarded tasks are terminal"
    );

    app.dispatch(Action::DoneSelected);
    assert_eq!(
        app.state.log_entries.back().unwrap().message,
        "done failed: discarded tasks are terminal"
    );
}

#[test]
fn deleting_discarded_task_does_not_offer_undo() {
    let temp = TempDir::new().unwrap();
    let (service, learn_threshold) = runtime_for_path(temp.path());
    service.init().unwrap();

    service
        .create_task(CreateTaskInput {
            title: "Discard and delete".to_string(),
            task_type: TaskType::Task,
            details: "before".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();
    service.discard_task("discard-and-delete").unwrap();

    let mut app = App::new(service, learn_threshold);
    app.dispatch(Action::RefreshTasks);
    app.dispatch(Action::DeleteSelected);

    assert_eq!(app.state.tasks.len(), 0);
    assert!(app.state.last_deleted.is_none());
    assert_eq!(
        app.state.log_entries.back().unwrap().message,
        "discarded task \"Discard and delete\" deleted"
    );
}