kanbrain-core 0.2.0

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::*;

fn parse(args: &[&str]) -> Result<EntryArgs> {
    let (name, rest) = args.split_first().unwrap();
    Ok(EntryArgs::parse(name, rest.iter().map(|s| (*s).to_string()))?.expect("an entry command"))
}

#[test]
fn add_takes_options_anywhere_and_the_rest_as_title() {
    let args = parse(&["add", "--decision", "Use", "--tag", "#Arch", "markdown", "--link", "#3", "--json"]).unwrap();
    assert!(args.json);
    let EntryCommand::Add(add) = args.command else { panic!("{:?}", args.command) };
    assert_eq!(add.kind, Kind::Decision);
    assert_eq!(
        (add.title.as_str(), add.tags.as_slice(), add.links.as_slice()),
        ("Use markdown", &["arch".to_string()][..], &[3][..])
    );
    assert!(parse(&["add", "--start", "--note", "x"]).is_err(), "notes can't be started");
    assert!(parse(&["add", "--tag", "x"]).is_err(), "a title is needed");
}

#[test]
fn ids_text_and_stdin() {
    assert_eq!(parse(&["done", "#4"]).unwrap().command, EntryCommand::SetStatus(4, Status::Done));
    assert_eq!(
        parse(&["comment", "4", "found", "it"]).unwrap().command,
        EntryCommand::Comment(4, Text::Given("found it".into()))
    );
    assert_eq!(parse(&["describe", "4", "-"]).unwrap().command, EntryCommand::Describe(4, Text::Stdin));
    assert_eq!(
        parse(&["link", "4", "5,", "#6", "api#2"]).unwrap().command,
        EntryCommand::Link(4, vec![5, 6], vec![RepoLink { repo: "api".into(), id: 2 }])
    );
    assert!(parse(&["show", "four"]).is_err());
    assert!(parse(&["show", "4", "5"]).is_err());
    assert!(parse(&["comment", "4"]).is_err());
    assert!(EntryArgs::parse("nope", std::iter::empty()).unwrap().is_none());
}

#[test]
fn project_shows_or_replaces_a_part() {
    assert_eq!(parse(&["project"]).unwrap().command, EntryCommand::Project(None));
    assert_eq!(
        parse(&["project", "describe", "An", "online", "shop."]).unwrap().command,
        EntryCommand::Project(Some((ProjectPart::Description, Text::Given("An online shop.".into()))))
    );
    assert_eq!(
        parse(&["project", "notes", "-"]).unwrap().command,
        EntryCommand::Project(Some((ProjectPart::Notes, Text::Stdin)))
    );
    assert!(parse(&["project", "describe"]).is_err(), "text is needed");
    assert!(parse(&["project", "rename", "x"]).is_err());
}

#[test]
fn project_writes_the_shared_card_and_the_repository_notes() {
    let (central, code) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
    let dir = code.path().join("api/.brain");
    std::fs::create_dir_all(&dir).unwrap();
    crate::projects::register(central.path(), "shop", &dir).unwrap();
    crate::projects::write_repo_card(&dir, "shop", "").unwrap();
    let open = || Brain::open_in(crate::store::Store::at(&dir).unwrap(), central.path()).unwrap();
    let mut brain = open();
    run_project(&mut brain, Some((ProjectPart::Description, Text::Given("An online shop.".into())))).unwrap();
    run_project(&mut brain, Some((ProjectPart::Notes, Text::Given("The API.".into())))).unwrap();
    let project = open().project.unwrap();
    assert_eq!((project.description.as_str(), project.notes.as_str()), ("An online shop.", "The API."));
    let mut outside = Brain::open(crate::store::Store::at(central.path()).unwrap()).unwrap();
    assert!(run_project(&mut outside, None).is_err(), "the central brain isn't a project");
}

#[test]
fn project_lists_the_repositorys_role_only_where_there_are_several() {
    let mut project = ProjectInfo {
        name: "notes".into(),
        description: "A notes CLI.".into(),
        repo: "notes".into(),
        notes: String::new(),
        siblings: Vec::new(),
    };
    assert_eq!(project_text(&project), "## Project notes\n\nA notes CLI.\n", "one repository: no role to write");
    project.siblings.push(("web".into(), "/code/web/.brain".into()));
    assert!(project_text(&project).ends_with("## Repository notes\n\n(empty)\n"), "several: its role is missing");
}

#[test]
fn questions_proposals_and_verdicts() {
    assert_eq!(parse(&["ask", "4", "-"]).unwrap().command, EntryCommand::Ask(4, Text::Stdin));
    let EntryCommand::Propose(4, decision) = parse(&["propose", "4", "--body", "-", "Use", "Redis"]).unwrap().command
    else {
        panic!("a proposal for #4")
    };
    assert_eq!((decision.title.as_str(), decision.body), ("Use Redis", Some(Text::Stdin)));
    assert!(parse(&["propose", "4", "--note", "x"]).is_err(), "it's a decision");
    assert_eq!(parse(&["accept", "9"]).unwrap().command, EntryCommand::Resolve(9, DecisionState::Accepted, None));
    assert_eq!(
        parse(&["reject", "9", "too", "costly"]).unwrap().command,
        EntryCommand::Resolve(9, DecisionState::Rejected, Some(Text::Given("too costly".into())))
    );
    assert!(parse(&["reject", "9"]).is_err(), "rejecting needs why");
}

#[test]
fn wait_ends_on_a_persons_answer_or_verdict() {
    let eight = Some(Duration::from_secs(8 * 60));
    assert_eq!(parse(&["wait", "#6"]).unwrap().command, EntryCommand::Wait(6, eight));
    let two = Some(Duration::from_secs(120));
    assert_eq!(parse(&["wait", "6", "--timeout", "2"]).unwrap().command, EntryCommand::Wait(6, two));
    assert_eq!(parse(&["wait", "6", "--timeout", "0"]).unwrap().command, EntryCommand::Wait(6, None), "no limit");
    assert!(parse(&["wait", "6", "now"]).is_err());
    assert!(parse(&["wait", "6", "--timeout"]).is_err());
    let dir = tempfile::tempdir().unwrap();
    let open = Brain::open(crate::store::Store::at(dir.path()).unwrap()).unwrap();
    let mut brain = Brain { author: crate::model::Author::Agent, ..open };
    let new = |title: &str| NewEntry { title: title.into(), ..NewEntry::default() };
    let task = brain.add(new("Rate-limit login")).unwrap();
    let settled_on = |brain: &Brain, id| settled(brain.entry(id).unwrap());
    let head = format!("#{task} Rate-limit login");
    assert_eq!(settled_on(&brain, task).unwrap(), format!("{head}: not waiting for an answer (todo)"));
    wait(&brain, task, None).expect("nothing to wait for: it returns at once");
    assert!(wait(&brain, 99, None).is_err(), "no such entry");

    brain.ask(task, "Per IP or per account?").unwrap();
    assert_eq!(settled_on(&brain, task), None);
    let timed_out = wait(&brain, task, Some(Duration::ZERO)).unwrap_err().to_string();
    assert!(timed_out.starts_with(&format!("no answer on #{task} yet")), "{timed_out}");
    brain.comment(task, "Checking the logs meanwhile.").unwrap();
    assert_eq!(settled_on(&brain, task), None, "an agent's comment isn't an answer");
    brain.author = crate::model::Author::Human;
    brain.comment(task, "Per account").unwrap();
    assert_eq!(settled_on(&brain, task).unwrap(), format!("{head}: answered\n\nPer account"));

    brain.author = crate::model::Author::Agent;
    let decision = brain.propose(task, new("Use Redis")).unwrap();
    assert_eq!(settled_on(&brain, decision), None, "proposed");
    assert_eq!(settled_on(&brain, task), None, "the task asks about it");
    brain.author = crate::model::Author::Human;
    brain.resolve(decision, DecisionState::Rejected, "too heavy").unwrap();
    assert_eq!(
        settled_on(&brain, decision).unwrap(),
        format!("#{decision} Use Redis: rejected\n\nRejected: too heavy")
    );
    assert!(
        settled_on(&brain, task).unwrap().ends_with(&format!("Rejected decision #{decision}, Use Redis: too heavy"))
    );
}