lazytask 0.5.0

A task manager built for AI coding agents — plain markdown files, strict CLI, keyboard-driven TUI
Documentation
use super::*;
use crate::config::load_for_workspace_root;
use crate::domain::{TaskStatus, TaskType};
use std::fs;
use tempfile::TempDir;

fn service_for_temp(temp: &TempDir) -> TaskService {
    let config = load_for_workspace_root(temp.path()).unwrap();
    TaskService::new(config)
}

#[test]
fn create_start_done_delete_flow() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

    let task = service
        .create_task(CreateTaskInput {
            title: "Rewrite CLI".to_string(),
            task_type: TaskType::Task,
            details: "Implement command tree".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();
    assert_eq!(task.status, TaskStatus::Todo);

    let task = service.start_task("rewrite-cli").unwrap();
    assert_eq!(task.status, TaskStatus::InProgress);

    let task = service.done_task_without_learning("rewrite-cli").unwrap();
    assert_eq!(task.status, TaskStatus::Done);

    let deleted = service.delete_task("rewrite-cli").unwrap();
    assert_eq!(deleted.title, "Rewrite CLI");
    assert!(service.list_tasks(None, None).unwrap().is_empty());
}

#[test]
fn restore_deleted_task() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

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

    let deleted = service.delete_task("bring-me-back").unwrap();
    let restored = service.restore_task(&deleted).unwrap();

    assert_eq!(restored.title, "Bring me back");
    assert_eq!(restored.status, TaskStatus::Todo);
    assert_eq!(service.list_tasks(None, None).unwrap().len(), 1);
}

#[test]
fn restore_fails_when_file_name_already_exists() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

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

    let deleted = service.delete_task("duplicate-title").unwrap();
    service
        .create_task(CreateTaskInput {
            title: "Duplicate title".to_string(),
            task_type: TaskType::Task,
            details: "second".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();

    let err = service.restore_task(&deleted).unwrap_err();
    assert!(matches!(err, ServiceError::TaskAlreadyExists(_)));
}

#[test]
fn create_task_normalizes_escaped_newlines_in_details() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

    let task = service
        .create_task(CreateTaskInput {
            title: "Escaped details".to_string(),
            task_type: TaskType::Task,
            details: "line one\\nline two".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();

    assert_eq!(task.details, "line one\nline two");
}

#[test]
fn discard_task_moves_task_to_discard() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

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

    let discarded = service.discard_task("old-duplicate").unwrap();
    assert_eq!(discarded.status, TaskStatus::Discard);
}

#[test]
fn discard_task_with_note_normalizes_and_validates_note() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

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

    let discarded = service
        .discard_task_with_note("document-decision", "line one\\nline two")
        .unwrap();
    assert_eq!(discarded.status, TaskStatus::Discard);
    assert_eq!(
        discarded.discard_note.as_deref(),
        Some("line one\nline two")
    );

    let err = service
        .discard_task_with_note("document-decision", "   ")
        .unwrap_err();
    assert!(matches!(err, ServiceError::ValidationError(_)));
}

#[test]
fn create_ignores_discard_for_duplicate_check_but_done_still_blocks() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

    service
        .create_task(CreateTaskInput {
            title: "Duplicate title".to_string(),
            task_type: TaskType::Task,
            details: "first".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();
    service
        .discard_task_with_note("duplicate-title", "wont do")
        .unwrap();

    let recreated = service
        .create_task(CreateTaskInput {
            title: "Duplicate title".to_string(),
            task_type: TaskType::Task,
            details: "second".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();
    assert_eq!(recreated.status, TaskStatus::Todo);

    service
        .done_task_without_learning("duplicate-title")
        .unwrap();
    let err = service
        .create_task(CreateTaskInput {
            title: "Duplicate title".to_string(),
            task_type: TaskType::Task,
            details: "third".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap_err();
    assert!(matches!(err, ServiceError::TaskAlreadyExists(_)));
}

#[test]
fn get_tasks_ignores_discarded_matches() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

    service
        .create_task(CreateTaskInput {
            title: "Query me".to_string(),
            task_type: TaskType::Task,
            details: "details".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();
    service
        .discard_task_with_note("query-me", "not now")
        .unwrap();

    let err = service.get_tasks(&["query me".to_string()]).unwrap_err();
    assert!(matches!(err, ServiceError::TaskNotFound(_)));
}

#[test]
fn edit_task_overwrites_selected_task() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

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

    let edited = service
        .edit_task(
            "edit-me",
            "Edited title".to_string(),
            TaskType::Bug,
            "after".to_string(),
        )
        .unwrap();

    assert_eq!(edited.file_name, created.file_name);
    assert_eq!(edited.status, created.status);
    assert_eq!(edited.title, "Edited title");
    assert_eq!(edited.task_type, TaskType::Bug);
    assert_eq!(edited.details, "after");
    assert!(edited.updated_at >= created.updated_at);
}

#[test]
fn edit_task_normalizes_escaped_newlines_in_details() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

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

    let edited = service
        .edit_task(
            "edit-escaped-details",
            "Edit escaped details".to_string(),
            TaskType::Task,
            "line one\\nline two".to_string(),
        )
        .unwrap();

    assert_eq!(edited.details, "line one\nline two");
}

#[test]
fn cleanup_expired_terminal_tasks_deletes_done_and_discard_by_ttl() {
    let temp = TempDir::new().unwrap();
    fs::write(
        temp.path().join("lazytask.toml"),
        "[retention]\ndone_discard_ttl_days = 7\n",
    )
    .unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

    service
        .create_task(CreateTaskInput {
            title: "Old done".to_string(),
            task_type: TaskType::Task,
            details: "old".to_string(),
            start: false,
            require_details: true,
        })
        .unwrap();
    service.done_task_without_learning("old-done").unwrap();

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

    let done_path = temp.path().join(".tasks/done/old-done.md");
    let discard_path = temp.path().join(".tasks/discard/old-discard.md");
    for path in [&done_path, &discard_path] {
        let content = fs::read_to_string(path).unwrap();
        let rewritten = content
            .lines()
            .map(|line| {
                if line.starts_with("updated: ") {
                    "updated: 2000-01-01T00:00:00Z".to_string()
                } else {
                    line.to_string()
                }
            })
            .collect::<Vec<_>>()
            .join("\n");
        fs::write(path, format!("{rewritten}\n")).unwrap();
    }

    let deleted = service.cleanup_expired_terminal_tasks().unwrap();
    assert_eq!(deleted, 2);
    assert!(!done_path.exists());
    assert!(!discard_path.exists());
}

#[test]
fn add_learning_records_entry() {
    let temp = TempDir::new().unwrap();
    let service = service_for_temp(&temp);
    service.init().unwrap();

    service.add_learning("some learning").unwrap();

    let result = service.learn().unwrap();
    assert_eq!(result.entries.len(), 1);
    assert_eq!(result.entries[0].learnings, "some learning");
}