kanbrain-core 0.3.5

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 round_trip() {
    let t0 = parse_ts("2026-09-24 09:00").unwrap();
    let mut task = Task::new(7, "Fix prod login".into(), t0);
    task.description = "customer X reported it\n\n- check logs\n### steps".into();
    task.comments = vec![
        Comment::new(t0, "first\n\nwith a blank line".into(), Author::Human),
        Comment {
            edited: Some(t0 + Duration::minutes(9)),
            ..Comment::new(t0 + Duration::minutes(5), "second".into(), Author::Human)
        },
        Comment::new(t0 + Duration::minutes(7), String::new(), Author::Human),
    ];
    task.tags = vec!["prod".into(), "support".into()];
    task.set_status(Status::Doing, t0 + Duration::minutes(10), Author::Human);
    task.start_session(t0 + Duration::minutes(10));
    task.set_status(Status::Done, t0 + Duration::hours(2), Author::Human);

    let back = Task::from_markdown(&task.to_markdown()).unwrap();
    assert_eq!(back.id, 7);
    assert_eq!(back.title, task.title);
    assert_eq!(back.status, Status::Done);
    assert_eq!(back.created, t0);
    assert_eq!(back.description, task.description);
    assert_eq!(back.comments, task.comments);
    assert_eq!(back.tags, task.tags);
    assert_eq!(back.history, task.history);
    assert_eq!(back.sessions, task.sessions);
    assert_eq!(back.total(t0), Duration::minutes(110));
}

#[test]
fn untracked_tasks_have_no_sessions_section() {
    assert!(!Task::new(1, "x".into(), now()).to_markdown().contains("## Sessions"));
}

#[test]
fn open_sessions_that_cannot_be_running_are_closed() {
    let md = "# x\n\n- id: 1\n- status: parked\n\n## History\n\n- 2026-09-24 11:00 doing -> parked\n\n\
              ## Sessions\n\n- 2026-09-24 10:00 -> running\n- 2026-09-24 09:00 -> running\n";
    let task = Task::from_markdown(md).unwrap();
    assert_eq!(task.sessions[0].end, parse_ts("2026-09-24 10:00"), "closed at the next start");
    assert_eq!(task.sessions[1].end, parse_ts("2026-09-24 11:00"), "closed when the task was parked");
    assert!(!task.is_running());
}

#[test]
fn hand_typed_history_and_missing_status() {
    let md = "# x\n\n- id: 1\n\n## History\n\n- 2026-09-24 10:00 todo -> doing\n";
    let task = Task::from_markdown(md).unwrap();
    assert_eq!(task.status, Status::Todo, "the status line decides, not the history");
    assert_eq!(task.history[0].at, parse_ts("2026-09-24 10:00").unwrap());
    assert_eq!(task.history[0].to, Status::Doing);
    assert!(Task::from_markdown("# x\n\n- id: 1\n\n## History\n\n- 2026-09-24 10:00 todo -> nope\n").is_err());
}

#[test]
fn missing_id_is_an_error() {
    assert!(Task::from_markdown("# no id\n").is_err());
}

#[test]
fn notes_have_a_kind_and_links_but_no_status_or_history() {
    let mut note = Task::new(3, "How deploys work".into(), now());
    note.kind = Kind::Note;
    note.links = vec![1, 2];
    let md = note.to_markdown();
    assert!(md.contains("- kind: note") && md.contains("- links: 1, 2"));
    assert!(!md.contains("- status:") && !md.contains("## History"));
    let back = Task::from_markdown(&md).unwrap();
    assert_eq!((back.kind, back.links), (Kind::Note, vec![1, 2]));
}

#[test]
fn links_accept_hashes_and_skip_repeats() {
    assert_eq!(parse_links("#5 9, 5, x").0, [5, 9]);
}

#[test]
fn links_to_other_repositories_keep_their_name_and_round_trip() {
    let (links, repo_links) = parse_links("5, shop-api#3, shop-api#3, web#x");
    assert_eq!(links, [5]);
    assert_eq!(repo_links, [RepoLink { repo: "shop-api".into(), id: 3 }], "repeats and bad ids skipped");
    let mut task = Task::new(1, "cart total".into(), now());
    (task.links, task.repo_links) = (links, repo_links);
    let md = task.to_markdown();
    assert!(md.contains("- links: 5, shop-api#3\n"), "{md}");
    assert_eq!(Task::from_markdown(&md).unwrap().repo_links, task.repo_links);
}

#[test]
fn what_a_newer_version_wrote_is_written_back() {
    let newer = "# Ship it\n\n- id: 4\n- status: todo\n- tags: \n- links: 2, web#1, blog/web#3, ~5\n\
                 - due: 2026-11-01\n- created: 2026-10-01T09:00:00+02:00\n\n## Description\n\n## Comments\n\n\
                 ## Checklist\n\n- [x] tests\n- [ ] notes\n\n## History\n\n";
    let mut task = Task::from_markdown(newer).unwrap();
    assert_eq!(task.links, [2]);
    task.comments.push(Comment::new(now(), "saved by this version".into(), Author::Human));
    let md = task.to_markdown();
    assert!(md.contains("- links: 2, web#1, blog/web#3, ~5\n- due: 2026-11-01\n"), "{md}");
    assert!(md.ends_with("\n\n## Checklist\n\n- [x] tests\n- [ ] notes\n\n"), "{md}");
    assert_eq!(Task::from_markdown(&md).unwrap().to_markdown(), md, "and stays as it is");
}

#[test]
fn authors_questions_and_decision_states_round_trip() {
    let t0 = parse_ts("2026-09-24 09:00").unwrap();
    let mut task = Task::new(3, "Pick a cache".into(), t0);
    task.comments = vec![
        Comment { question: true, ..Comment::new(t0, "Redis or in-process?".into(), Author::Agent) },
        Comment { unknown: vec!["pinned".into()], ..Comment::new(t0, "Redis".into(), Author::Human) },
    ];
    task.set_status(Status::Parked, t0, Author::Agent);
    let file = task.to_markdown();
    assert!(file.contains("(agent, question)\n") && file.contains("(pinned)\n"), "{file}");
    assert!(file.contains("todo -> parked (agent)\n"), "{file}");
    let back = Task::from_markdown(&file).unwrap();
    assert_eq!((back.comments, back.history), (task.comments, task.history));

    let mut decision = Task::new(4, "Use Redis".into(), t0);
    decision.kind = Kind::Decision;
    decision.decision = DecisionState::Proposed;
    let file = decision.to_markdown();
    assert!(file.contains("- decision: proposed\n"), "{file}");
    assert_eq!(Task::from_markdown(&file).unwrap().decision, DecisionState::Proposed);
    decision.decision = DecisionState::Accepted;
    assert!(!decision.to_markdown().contains("- decision:"), "in force needs no line");
}

#[test]
fn hand_written_headers_and_unknown_authors_still_read() {
    let md = "# x\n\n- id: 1\n- status: parked\n\n## Comments\n\n### 2026-09-24 10:00 (edited 2026-09-24 10:05)\n\n\
              old form\n\n### Steps (draft)\n\nnot a header\n\n## History\n\n- 2026-09-24 11:00 todo -> parked (robot)\n";
    let task = Task::from_markdown(md).unwrap();
    assert_eq!(task.comments.len(), 1);
    assert_eq!(task.comments[0].edited, parse_ts("2026-09-24 10:05"));
    assert!(task.comments[0].body.contains("### Steps (draft)"));
    assert_eq!(task.history[0].by, Author::Human, "an unknown name doesn't lose the file");
}