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 chrono::{TimeZone, Utc};
use std::fs;
use tempfile::TempDir;

fn storage_for_temp(temp: &TempDir) -> Storage {
    let config = load_for_workspace_root(temp.path()).unwrap();
    Storage::from_app_config(&config)
}

#[test]
fn parses_learning_entries_file() {
    let entries = learning::parse_learning_entries(
        "2026-02-21T14:00:00Z\n- line 1\n- line 2\n\n2026-02-21T15:00:00Z\n- x\n- y\n",
    )
    .unwrap();

    assert_eq!(entries.len(), 2);
    assert_eq!(entries[0].lines, vec!["line 1", "line 2"]);
    assert_eq!(entries[1].lines, vec!["x", "y"]);
}

#[test]
fn parses_learning_with_pipe_in_bullet() {
    let entries = learning::parse_learning_entries(
        "2026-02-21T14:00:00Z\n- The useToast hook returns string | toast ID\n- normal line\n",
    )
    .unwrap();

    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].lines.len(), 2);
    assert_eq!(
        entries[0].lines[0],
        "The useToast hook returns string | toast ID"
    );
    assert_eq!(entries[0].lines[1], "normal line");
}

#[test]
fn round_trip_learning_with_pipe_in_bullet() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    storage.ensure_layout().unwrap();
    let now = Utc.with_ymd_and_hms(2026, 3, 12, 10, 0, 0).unwrap();

    storage
        .append_learning(now, &["hook returns string | toast ID".to_string()])
        .unwrap();

    let entries = storage.read_learning_entries().unwrap();
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].lines, vec!["hook returns string | toast ID"]);
}

#[test]
fn round_trip_create_and_list_task() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    storage.ensure_layout().unwrap();
    let now = Utc.with_ymd_and_hms(2026, 2, 21, 15, 0, 0).unwrap();

    storage
        .create_task(
            "Ship rewrite",
            TaskStatus::Todo,
            TaskType::Task,
            "Do it",
            now,
        )
        .unwrap();

    let tasks = storage.list_tasks(None, None).unwrap();
    assert_eq!(tasks.len(), 1);
    assert_eq!(tasks[0].title, "Ship rewrite");
}

#[test]
fn list_tasks_can_filter_by_type() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    storage.ensure_layout().unwrap();
    let now = Utc.with_ymd_and_hms(2026, 2, 21, 15, 0, 0).unwrap();

    storage
        .create_task(
            "Normal task",
            TaskStatus::Todo,
            TaskType::Task,
            "Do it",
            now,
        )
        .unwrap();
    storage
        .create_task("Fix auth", TaskStatus::Todo, TaskType::Bug, "Fix it", now)
        .unwrap();

    let bugs = storage.list_tasks(None, Some(TaskType::Bug)).unwrap();
    assert_eq!(bugs.len(), 1);
    assert_eq!(bugs[0].task_type, TaskType::Bug);
    assert_eq!(bugs[0].title, "Fix auth");
}

#[test]
fn round_trip_discard_note_in_markdown() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    storage.ensure_layout().unwrap();
    let now = Utc.with_ymd_and_hms(2026, 2, 21, 15, 0, 0).unwrap();

    let mut task = storage
        .create_task("Discard me", TaskStatus::Todo, TaskType::Task, "Do it", now)
        .unwrap();
    task.discard_note = Some("line one\nline two".to_string());
    storage.move_task(&task, TaskStatus::Discard, now).unwrap();

    let content = fs::read_to_string(temp.path().join(".tasks/discard/discard-me.md")).unwrap();
    assert!(content.contains("discard-note:"));
    assert!(content.contains("  line one"));
    assert!(content.contains("  line two"));

    let parsed = storage
        .list_tasks(Some(TaskStatus::Discard), None)
        .unwrap()
        .pop()
        .unwrap();
    assert_eq!(parsed.discard_note.as_deref(), Some("line one\nline two"));
}

#[test]
fn parse_task_without_discard_note_stays_compatible() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    storage.ensure_layout().unwrap();

    let path = temp.path().join(".tasks/todo/no-note.md");
    fs::write(
        path,
        "# No note\nstatus: todo\ntype: task\ncreated: 2026-02-21T15:00:00Z\nupdated: 2026-02-21T15:00:00Z\ndetails:\n  text\n",
    )
    .unwrap();

    let task = storage
        .list_tasks(Some(TaskStatus::Todo), None)
        .unwrap()
        .pop()
        .unwrap();
    assert!(task.discard_note.is_none());
}

#[test]
fn init_prompt_uses_agents_file_by_default() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    let prompts = storage.prompts;
    let prompt_markdown = crate::config::markdown_for_key(prompts.agent_init_key).unwrap();

    storage.ensure_agent_prompt_guidance().unwrap();

    let content = fs::read_to_string(temp.path().join(storage.layout.agents_file)).unwrap();
    let start = content.find(prompts.important_block_start).unwrap();
    let body_start = start + prompts.important_block_start.len();
    let body_end = content[body_start..]
        .find(prompts.important_block_end)
        .map(|idx| body_start + idx)
        .unwrap();
    let inserted_body = content[body_start..body_end].trim_matches('\n');
    assert_eq!(inserted_body, prompt_markdown.trim_matches('\n'));
}

#[test]
fn init_prompt_prefers_claude_when_agents_missing() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    let prompts = storage.prompts;
    fs::write(temp.path().join(storage.layout.claude_file), "existing").unwrap();

    storage.ensure_agent_prompt_guidance().unwrap();

    let claude_content = fs::read_to_string(temp.path().join(storage.layout.claude_file)).unwrap();
    assert!(claude_content.contains(prompts.important_block_start));
    assert!(!temp.path().join(storage.layout.agents_file).exists());
}

#[test]
fn init_prompt_append_is_idempotent() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    let prompts = storage.prompts;

    storage.ensure_agent_prompt_guidance().unwrap();
    storage.ensure_agent_prompt_guidance().unwrap();

    let content = fs::read_to_string(temp.path().join(storage.layout.agents_file)).unwrap();
    let count = content.matches(prompts.important_block_start).count();
    assert_eq!(count, 1);
}

#[test]
fn init_prompt_upgrade_rewrites_existing_lazytask_block() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    let prompts = storage.prompts;
    let path = temp.path().join(storage.layout.agents_file);
    fs::write(
        &path,
        format!(
            "header\n{}\nold lazytask guidance\n{}\nfooter\n",
            prompts.important_block_start, prompts.important_block_end
        ),
    )
    .unwrap();

    storage
        .ensure_agent_prompt_guidance_with_upgrade(true)
        .unwrap();

    let prompt_markdown = crate::config::markdown_for_key(prompts.agent_init_key).unwrap();
    let content = fs::read_to_string(path).unwrap();
    assert!(content.contains("header"));
    assert!(content.contains("footer"));
    assert!(content.contains(prompt_markdown.trim_matches('\n')));
    assert!(!content.contains("old lazytask guidance"));
}

#[test]
fn delete_terminal_tasks_updated_before_removes_only_expired_done_and_discard() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    storage.ensure_layout().unwrap();

    let old = Utc.with_ymd_and_hms(2020, 1, 1, 12, 0, 0).unwrap();
    let recent = Utc.with_ymd_and_hms(2026, 2, 21, 12, 0, 0).unwrap();
    let cutoff = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();

    storage
        .create_task("done old", TaskStatus::Done, TaskType::Task, "old", old)
        .unwrap();
    storage
        .create_task(
            "discard old",
            TaskStatus::Discard,
            TaskType::Task,
            "old",
            old,
        )
        .unwrap();
    storage
        .create_task(
            "done recent",
            TaskStatus::Done,
            TaskType::Task,
            "recent",
            recent,
        )
        .unwrap();
    storage
        .create_task("todo old", TaskStatus::Todo, TaskType::Task, "old", old)
        .unwrap();
    storage
        .create_task(
            "in progress old",
            TaskStatus::InProgress,
            TaskType::Task,
            "old",
            old,
        )
        .unwrap();

    let deleted = storage
        .delete_terminal_tasks_updated_before(cutoff)
        .unwrap();
    assert_eq!(deleted.len(), 2);

    assert!(!temp.path().join(".tasks/done/done-old.md").exists());
    assert!(!temp.path().join(".tasks/discard/discard-old.md").exists());
    assert!(temp.path().join(".tasks/done/done-recent.md").exists());
    assert!(temp.path().join(".tasks/todo/todo-old.md").exists());
    assert!(
        temp.path()
            .join(".tasks/in-progress/in-progress-old.md")
            .exists()
    );
}

#[test]
fn delete_terminal_tasks_updated_before_is_noop_when_tasks_root_missing() {
    let temp = TempDir::new().unwrap();
    let storage = storage_for_temp(&temp);
    let cutoff = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();

    let deleted = storage
        .delete_terminal_tasks_updated_before(cutoff)
        .unwrap();
    assert!(deleted.is_empty());
}