wsb-github 0.248.6265

GitHub issues, pull requests, gists and Projects boards, mirrored once per machine for every project under $PRJ
Documentation
//! What the command line decides by itself: which board, card, field and
//! option a name means, what a value is for the field it is written to, what
//! is said about a card, and which mistakes are refused before anything is
//! read.

use super::*;

fn args(words: &[&str]) -> Vec<String> {
    words.iter().map(|w| w.to_string()).collect()
}

fn board(owner: &str, number: i64, title: &str) -> GitHubProject {
    serde_json::from_value(serde_json::json!({
        "id": format!("PVT_{owner}_{number}"),
        "number": number,
        "title": title,
        "url": format!("https://github.com/orgs/{owner}/projects/{number}"),
        "short_description": null,
        "closed": false,
        "updated_at": "2026-10-02T10:00:00Z",
        "owner": owner,
        "fields": [
            { "id": "F_title", "name": "Title", "kind": "title", "options": [] },
            { "id": "F_assignees", "name": "Assignees", "kind": "assignees", "options": [] },
            { "id": "F_status", "name": "Status", "kind": "single_select", "options": [
                { "id": "o_backlog", "name": "Backlog", "color": "GREEN", "description": "" },
                { "id": "o_ready", "name": "Ready", "color": "BLUE", "description": "" },
                { "id": "o_done", "name": "Done", "color": "ORANGE", "description": "" }
            ] },
            { "id": "F_priority", "name": "Priority", "kind": "single_select", "options": [
                { "id": "o_p1", "name": "P1", "color": "ORANGE", "description": "" },
                { "id": "o_p2", "name": "P2", "color": "YELLOW", "description": "" }
            ] },
            { "id": "F_estimate", "name": "Estimate", "kind": "number", "options": [] },
            { "id": "F_target", "name": "Target date", "kind": "date", "options": [] },
            { "id": "F_notes", "name": "Notes", "kind": "text", "options": [] },
            { "id": "F_sprint", "name": "Sprint", "kind": "iteration", "options": [] }
        ]
    }))
    .expect("a board as the mirror holds one")
}

fn card(id: &str, title: &str, values: serde_json::Value) -> GitHubProjectItem {
    serde_json::from_value(serde_json::json!({
        "id": id,
        "kind": "DRAFT_ISSUE",
        "title": title,
        "content_id": format!("DI_{id}"),
        "body": "",
        "field_values": values,
        "updated_at": "2026-10-02T10:00:00Z"
    }))
    .expect("a card as the mirror holds one")
}

/// TEST12405: a value written to a field is the kind the BOARD says that
/// field holds.
#[test]
fn test12405_a_written_value_is_the_kind_its_field_holds() {
    let board = board("machinefabric", 1, "launch");
    let write = |assignment: &str| field_write(&board, assignment);

    assert_eq!(
        write("status=ready").unwrap(),
        (
            "F_status".to_string(),
            ProjectFieldWrite::SingleSelect { option_id: "o_ready".to_string() }
        )
    );
    assert_eq!(
        write("Estimate=2026").unwrap(),
        ("F_estimate".to_string(), ProjectFieldWrite::Number { number: 2026.0 })
    );
    assert_eq!(
        write("Notes=2026").unwrap(),
        ("F_notes".to_string(), ProjectFieldWrite::Text { text: "2026".to_string() })
    );
    // A text keeps everything after the first `=`, spaces and further `=` included.
    assert_eq!(
        write("Notes= a = b ").unwrap().1,
        ProjectFieldWrite::Text { text: " a = b ".to_string() }
    );
    assert_eq!(
        write("Target date=2028-02-29").unwrap().1,
        ProjectFieldWrite::Date { date: "2028-02-29".to_string() }
    );

    let refused = |assignment: &str| match write(assignment) {
        Err(Refusal::Failed(message)) => message,
        other => panic!("'{assignment}' was not refused as a value: {other:?}"),
    };
    let unknown = refused("Status=Shipping");
    assert!(
        unknown.contains("Backlog, Ready, Done"),
        "an option the field lacks must be answered with the ones it has: {unknown}"
    );
    assert!(refused("Estimate=three").contains("not a number"));
    assert!(refused("Estimate=inf").contains("finite"));
    for date in ["2027-02-29", "2026-13-01", "2026-1-1", "tomorrow", "2026-04-31"] {
        assert!(refused(&format!("Target date={date}")).contains("YYYY-MM-DD"), "{date}");
    }
    assert!(refused("Sprint=next").contains("iteration"));
    assert!(refused("Title=new").contains("project edit"));
    assert!(refused("Assignees=me").contains("not set from here"));
    assert!(refused("Size=XL").contains("has no field 'Size'"));

    // No `=` is the command line's mistake, not a value's.
    assert!(matches!(write("Status"), Err(Refusal::Usage(_))));
}

/// TEST12406: a board is named the way a person knows it, and a name two
/// boards share is refused with both.
#[test]
fn test12406_a_board_is_named_by_number_title_or_id() {
    let boards = vec![
        board("machinefabric", 1, "launch"),
        board("machinefabric", 2, "Launch notes"),
        board("someone", 1, "personal"),
    ];
    let named = |name: &str| find_board(&boards, name).map(|board| board.id.clone());

    assert_eq!(named("PVT_machinefabric_2").unwrap(), "PVT_machinefabric_2");
    assert_eq!(named("2").unwrap(), "PVT_machinefabric_2");
    assert_eq!(named("#2").unwrap(), "PVT_machinefabric_2");
    assert_eq!(named("someone/1").unwrap(), "PVT_someone_1");
    // An exact title wins over a title that merely begins the same way.
    assert_eq!(named("LAUNCH").unwrap(), "PVT_machinefabric_1");
    assert_eq!(named("pers").unwrap(), "PVT_someone_1");

    let shared = named("1").unwrap_err();
    assert!(
        shared.contains("machinefabric/1") && shared.contains("someone/1"),
        "number 1 is two boards here, and both must be named: {shared}"
    );
    assert!(named("la").unwrap_err().contains("2 boards"));
    let nothing = named("roadmap").unwrap_err();
    assert!(nothing.contains("machinefabric/1 'launch'"), "{nothing}");
}

/// TEST12407: a card is named by its id, the start of its id, what it points
/// at, or its exact title — and two cards with one title are refused by id.
#[test]
fn test12407_a_card_is_named_by_id_or_title_and_never_guessed() {
    let mut linked = card("PVTI_bbb222", "Fix the parser", serde_json::json!({}));
    linked.url = Some("https://github.com/machinefabric/capdag/issues/7".to_string());
    let cards = vec![
        card("PVTI_aaa111", "Write the post", serde_json::json!({})),
        linked,
        card("PVTI_aaa999", "Write the post", serde_json::json!({})),
        card("PVTI_ccc333", "PVTI_bbb", serde_json::json!({})),
    ];
    let named = |name: &str| find_item(&cards, name).map(|card| card.id.clone());

    assert_eq!(named("PVTI_aaa999").unwrap(), "PVTI_aaa999");
    assert_eq!(named("Fix the parser").unwrap(), "PVTI_bbb222");
    assert_eq!(named("https://github.com/machinefabric/capdag/issues/7").unwrap(), "PVTI_bbb222");
    assert_eq!(named("PVTI_bbb2").unwrap(), "PVTI_bbb222");
    // A card whose TITLE is the start of another's id is still that card.
    assert_eq!(named("PVTI_bbb").unwrap(), "PVTI_ccc333");

    let twice = named("Write the post").unwrap_err();
    assert!(
        twice.contains("PVTI_aaa111") && twice.contains("PVTI_aaa999"),
        "two cards share the title, and both ids must be given: {twice}"
    );
    assert!(named("PVTI_aaa").unwrap_err().contains("2 cards"));
    assert!(named("write the post").unwrap_err().contains("no card"), "a title is exact");
}

/// TEST12408: a card is reported by field NAME, once, for both readers.
#[test]
fn test12408_a_card_is_reported_by_field_name_for_both_readers() {
    let board = board("machinefabric", 1, "launch");
    let ready = card(
        "PVTI_1",
        "Add the model",
        serde_json::json!({
            "F_title": { "kind": "text", "text": "Add the model" },
            "F_priority": { "kind": "single_select", "option_id": "o_p1", "name": "P1" },
            "F_status": { "kind": "single_select", "option_id": "o_ready", "name": "Ready" },
            "F_estimate": { "kind": "number", "number": 3.0 },
            "F_target": { "kind": "date", "date": "2026-11-01" }
        }),
    );
    let loose = card("PVTI_2", "Somewhere", serde_json::json!({ "F_estimate": { "kind": "number", "number": 0.5 } }));
    let backlog = card(
        "PVTI_3",
        "Later",
        serde_json::json!({ "F_status": { "kind": "single_select", "option_id": "o_backlog", "name": "Backlog" } }),
    );

    let Record::ProjectItem { item, title, fields, url, .. } = item_record(&board, &ready) else {
        panic!("a card is a project_item record");
    };
    assert_eq!((item.as_str(), title.as_str(), url), ("PVTI_1", "Add the model", None));
    let said: Vec<(&str, &str, &str, Option<&str>)> = fields
        .iter()
        .map(|v| (v.field.as_str(), v.kind.as_str(), v.value.as_str(), v.option.as_deref()))
        .collect();
    assert_eq!(
        said,
        vec![
            ("Status", "single_select", "Ready", Some("o_ready")),
            ("Priority", "single_select", "P1", Some("o_p1")),
            ("Estimate", "number", "3", None),
            ("Target date", "date", "2026-11-01", None),
        ],
        "by name, in the board's field order, the title left out"
    );
    assert_eq!(named_values(&board, &loose)[0].value, "0.5");

    let prose = items_prose(&board, &[&loose, &ready, &backlog]);
    let headings: Vec<&str> = prose.iter().filter(|line| !line.starts_with("  ")).map(String::as_str).collect();
    assert_eq!(
        headings,
        vec!["launch — 3 card(s), by Status", "Backlog (1)", "Ready (1)", "No Status (1)"],
        "columns in the board's order, empty ones left out, the unplaced last"
    );
    let line = prose.iter().find(|line| line.contains("PVTI_1")).expect("the ready card");
    assert!(line.contains("Add the model") && line.contains("Priority: P1"), "{line}");
    assert!(!line.contains("Status:"), "the column is the heading, not repeated on the card: {line}");

    let wanted = |field: &str, value: &str| vec![(field.to_string(), value.to_string())];
    assert!(passes(&board, &ready, &wanted("status", "READY")));
    assert!(!passes(&board, &ready, &wanted("Status", "Backlog")));
    assert!(!passes(&board, &loose, &wanted("Status", "Ready")));
    assert!(passes(&board, &ready, &[wanted("Status", "Ready"), wanted("Priority", "p1")].concat()));
    assert!(!passes(&board, &ready, &[wanted("Status", "Ready"), wanted("Priority", "P2")].concat()));
}

/// TEST12409: a command called wrongly answers 2 without reading anything.
///
/// A script tells "you called this wrongly" (2) from "it did not work" (1).
/// Every one of these is decided from the command line alone — `$PRJ` is
/// pointed at a folder that does not exist, so a command that read anything
/// first would answer 1, not 2.
#[test]
fn test12409_a_command_called_wrongly_is_refused_before_anything_is_read() {
    // No other test in this binary reads PRJ from the environment.
    std::env::set_var("PRJ", "/nonexistent/wsb-github-cli-test");
    let wrong: &[&[&str]] = &[
        &["reticulate"],
        &["project", "reticulate"],
        &["project", "show"],
        &["project", "items"],
        &["project", "items", "1", "--colour", "red"],
        &["project", "items", "1", "--where", "Status"],
        &["project", "item", "1"],
        &["project", "item", "1", "a", "b"],
        &["project", "add", "1"],
        &["project", "add", "1", "--title", "t", "--url", "https://github.com/o/r/issues/1"],
        &["project", "add", "1", "--title", "  "],
        &["project", "add", "1", "--url", "https://github.com/o/r/issues/1", "--body", "b"],
        &["project", "add", "1", "--title", "t", "--body", "b", "--body-file", "f"],
        &["project", "add", "1", "--title", "t", "--title", "u"],
        &["project", "add", "1", "--title"],
        &["project", "add", "1", "--title", "t", "--set", "Status"],
        &["project", "set", "1", "card"],
        &["project", "set", "1", "card", "Status"],
        &["project", "clear", "1", "card"],
        &["project", "move", "1", "card"],
        &["project", "move", "1", "card", "--top", "--after", "other"],
        &["project", "edit", "1", "card"],
        &["project", "edit", "1", "card", "--title", ""],
        &["project", "archive", "1"],
        &["project", "remove", "1"],
        &["project", "remove", "1", "a", "b"],
        &["project", "column"],
        &["project", "column", "rename", "1", "x"],
        &["project", "column", "add", "1"],
        &["boards", "add"],
        &["boards", "add", "machinefabric"],
        &["issues", "--state", "sometimes"],
        &["sync", "now"],
    ];
    for words in wrong {
        assert_eq!(run(args(words)), 2, "{words:?} is a mistake at the command line");
    }
    assert_eq!(run(args(&["help"])), 0);
    assert_eq!(run(args(&["--help"])), 0);
    // Called with nothing at all is a mistake too.
    assert_eq!(run(Vec::new()), 2);
    // And one that is right is NOT refused as a usage mistake: it reads, and
    // fails on the PRJ that does not exist.
    assert_eq!(run(args(&["project", "show", "1"])), 1);

    // The reader itself.
    let read = Arguments::read(
        &args(&["1", "--set", "a=1", "card", "--set", "b=2", "--project", "p", "--top"]),
        &["--set"],
        &["--top"],
    )
    .unwrap();
    assert_eq!(read.positionals, vec!["1", "card"]);
    assert_eq!(read.all("--set"), ["a=1", "b=2"]);
    assert_eq!(read.value("--project").unwrap(), Some("p"));
    assert!(read.switch("--top"));
    assert!(matches!(read.value("--set"), Err(Refusal::Usage(_))));
    assert!(matches!(
        Arguments::read(&args(&["--titel", "x"]), &["--title"], &[]),
        Err(Refusal::Usage(_))
    ));

    // Every verb the usage names is one the dispatcher knows.
    for verb in ["sync", "status", "boards", "issues", "project show", "project items", "project item",
        "project add", "project set", "project clear", "project move", "project edit",
        "project archive", "project unarchive", "project remove", "project column add", "project column remove"]
    {
        assert!(USAGE.contains(&format!("  {verb}")) || USAGE.contains(&format!(", {verb}")),
            "the usage does not name '{verb}'");
    }
    assert!(
        USAGE.contains("project_board") && USAGE.contains("project_item_removed"),
        "the usage must say what a --jsonl reader is given"
    );
}

#[test]
fn a_calendar_date_is_a_real_day() {
    assert!(is_calendar_date("2028-02-29"));
    assert!(!is_calendar_date("2027-02-29"));
    assert!(!is_calendar_date("2026-00-10"));
    assert!(!is_calendar_date("26-01-01"));
}