kanbrain-core 0.4.1

The core of kanbrain (the `kb` command line and the kanban app): entries, markdown storage, tags and time tracking. Internal: its API follows kanbrain's versions and isn't meant for other crates.
Documentation
use super::*;
use chrono::Duration;

#[test]
fn status_changes_are_recorded_once() {
    let t0 = parse_ts("2026-09-24 09:00").unwrap();
    let mut task = Task::new(1, "x".into(), t0);
    assert_eq!(task.status_since(), t0);
    assert!(task.set_status(Status::Doing, t0 + Duration::hours(1), Author::Human));
    assert!(!task.set_status(Status::Doing, t0 + Duration::hours(2), Author::Human), "already doing");
    assert!(task.set_status(Status::Done, t0 + Duration::hours(3), Author::Human));
    assert_eq!(
        task.history,
        [
            StatusChange { at: t0 + Duration::hours(1), from: Status::Todo, to: Status::Doing, by: Author::Human },
            StatusChange { at: t0 + Duration::hours(3), from: Status::Doing, to: Status::Done, by: Author::Human },
        ]
    );
    assert_eq!(task.status_since(), t0 + Duration::hours(3));
}

#[test]
fn leaving_doing_closes_the_session() {
    let t0 = parse_ts("2026-09-24 09:00").unwrap();
    let mut task = Task::new(1, "x".into(), t0);
    task.set_status(Status::Doing, t0, Author::Human);
    task.start_session(t0);
    assert_eq!(task.running_for(t0 + Duration::minutes(5)), Some(Duration::minutes(5)));
    task.set_status(Status::Parked, t0 + Duration::minutes(30), Author::Human);
    assert_eq!(task.sessions[0].end, Some(t0 + Duration::minutes(30)));
    assert_eq!(task.total(t0 + Duration::hours(2)), Duration::minutes(30));
}

#[test]
fn time_between_clips_sessions_to_the_range() {
    let session = Session { start: parse_ts("2026-09-23 23:00").unwrap(), end: parse_ts("2026-09-24 01:30") };
    let (from, to) = (parse_ts("2026-09-24 00:00").unwrap(), parse_ts("2026-09-25 00:00").unwrap());
    assert_eq!(session.overlap(from, to, now()), Duration::minutes(90));
}

#[test]
fn search() {
    let mut task = Task::new(1, "Fix Login page".into(), now());
    task.tags = vec!["prod".into(), "support".into()];
    assert!(task.matches(""));
    assert!(task.matches("login"));
    assert!(task.matches("LOGIN #pro"));
    assert!(task.matches("supp"));
    assert!(!task.matches("#login"));
    assert!(!task.matches("login #backend"));
    assert!(task.matches("##PROD,"), "a tag written as tags are typed, as search reads it");
    task.description = "Flaky on mobile".into();
    assert!(!task.matches("mobile"), "the board filter doesn't look in the description; kb search does");
}

#[test]
fn a_question_waits_for_a_person_until_the_task_is_picked_up() {
    let t0 = now();
    let at = |minutes| t0 + Duration::minutes(minutes);
    let mut task = Task::new(1, "Pick a cache".into(), t0);
    task.set_status(Status::Doing, at(1), Author::Agent);
    task.comments.push(Comment { question: true, ..Comment::new(at(2), "Redis?".into(), Author::Agent) });
    task.set_status(Status::Parked, at(2), Author::Agent);
    assert!(matches!(task.asked(), Some(Asked::Waiting(q)) if q.body == "Redis?"));

    task.comments.push(Comment::new(at(3), "still thinking".into(), Author::Agent));
    assert!(matches!(task.asked(), Some(Asked::Waiting(_))), "an agent's comment isn't an answer");
    task.comments.push(Comment::new(at(4), "Redis".into(), Author::Human));
    assert!(matches!(task.asked(), Some(Asked::Answered(a)) if a.body == "Redis"));

    task.set_status(Status::Doing, at(5), Author::Agent);
    assert_eq!(task.asked(), None, "only parked tasks wait");
    task.set_status(Status::Parked, at(6), Author::Agent);
    assert_eq!(task.asked(), None, "picked up: parked again for another reason");
}

#[test]
fn a_count_takes_the_singular_for_one_only() {
    assert_eq!(counted(1, "session", "sessions"), "1 session");
    assert_eq!(counted(0, "session", "sessions"), "0 sessions");
    assert_eq!(counted(2, "entry", "entries"), "2 entries");
}