a73x

tests/cli_test.rs

Ref:   Size: 30.4 KiB

mod common;

use common::TestRepo;

// ===========================================================================
// Issue commands
// ===========================================================================

#[test]
fn test_issue_open_and_show() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let out = repo.run_ok(&["issue", "open", "-t", "My first bug"]);
    assert!(out.starts_with("Opened issue "));
    let id = out.trim().strip_prefix("Opened issue ").unwrap();
    assert_eq!(id.len(), 8, "should print 8-char short ID");

    let out = repo.run_ok(&["issue", "show", id]);
    assert!(out.contains("My first bug"));
    assert!(out.contains("[open]"));
    assert!(out.contains("Alice"));
}

#[test]
fn test_issue_open_with_body() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Steps to reproduce..."]);
    let id = out.trim().strip_prefix("Opened issue ").unwrap();

    let out = repo.run_ok(&["issue", "show", id]);
    assert!(out.contains("Steps to reproduce..."));
}

#[test]
fn test_issue_list_filters_closed() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.issue_open("Open bug");
    let closed_id = repo.issue_open("Closed bug");
    repo.run_ok(&["issue", "close", &closed_id]);

    let out = repo.run_ok(&["issue", "list"]);
    assert!(out.contains("Open bug"));
    assert!(!out.contains("Closed bug"));

    // Closed issues are archived, so --all alone won't show them; need --archived
    let out = repo.run_ok(&["issue", "list", "--all", "--archived"]);
    assert!(out.contains("Open bug"));
    assert!(out.contains("Closed bug"));
    assert!(out.contains("closed"));
}

#[test]
fn test_issue_comment() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Discussion");

    let out = repo.run_ok(&["issue", "comment", &id, "-b", "First thought"]);
    assert!(out.contains("Comment added"));

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("First thought"));
    assert!(out.contains("Comments"));
}

#[test]
fn test_issue_close_with_reason() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Will close");

    repo.run_ok(&["issue", "close", &id, "-r", "Duplicate of #42"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("[closed]"));
    assert!(out.contains("Duplicate of #42"));
}

#[test]
fn test_issue_close_without_reason() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Close silently");

    let out = repo.run_ok(&["issue", "close", &id]);
    assert!(out.contains("Issue closed"));

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("[closed]"));
}

#[test]
fn test_issue_prefix_resolution() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Prefix test");

    // Use first 4 chars as prefix
    let short = &id[..4];
    let out = repo.run_ok(&["issue", "show", short]);
    assert!(out.contains("Prefix test"));
}

#[test]
fn test_issue_nonexistent_id() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let err = repo.run_err(&["issue", "show", "deadbeef"]);
    assert!(err.contains("no issue found"));
}

#[test]
fn test_issue_list_empty() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let out = repo.run_ok(&["issue", "list"]);
    assert!(out.contains("No issues found"));
}

#[test]
fn test_multiple_issues_independent() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let id1 = repo.issue_open("Issue one");
    let id2 = repo.issue_open("Issue two");

    repo.run_ok(&["issue", "comment", &id1, "-b", "Comment on one"]);
    repo.run_ok(&["issue", "close", &id2]);

    let out = repo.run_ok(&["issue", "show", &id1]);
    assert!(out.contains("[open]"));
    assert!(out.contains("Comment on one"));

    let out = repo.run_ok(&["issue", "show", &id2]);
    assert!(out.contains("[closed]"));
    assert!(!out.contains("Comment on one"));
}

// ===========================================================================
// Issue edit
// ===========================================================================

#[test]
fn test_issue_edit_title() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Old title");

    let out = repo.run_ok(&["issue", "edit", &id, "-t", "New title"]);
    assert!(out.contains("Issue updated"));

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("New title"));
    assert!(!out.contains("Old title"));
}

#[test]
fn test_issue_edit_body() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Old body"]);
    let id = out.trim().strip_prefix("Opened issue ").unwrap();

    repo.run_ok(&["issue", "edit", id, "-b", "New body with details"]);

    let out = repo.run_ok(&["issue", "show", id]);
    assert!(out.contains("New body with details"));
    assert!(!out.contains("Old body"));
}

#[test]
fn test_issue_edit_title_and_body() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let out = repo.run_ok(&["issue", "open", "-t", "Original", "-b", "Original body"]);
    let id = out.trim().strip_prefix("Opened issue ").unwrap();

    repo.run_ok(&["issue", "edit", id, "-t", "Updated", "-b", "Updated body"]);

    let out = repo.run_ok(&["issue", "show", id]);
    assert!(out.contains("Updated"));
    assert!(out.contains("Updated body"));
}

#[test]
fn test_issue_edit_preserves_comments() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Will edit");

    repo.run_ok(&["issue", "comment", &id, "-b", "A comment before edit"]);
    repo.run_ok(&["issue", "edit", &id, "-t", "Edited title"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("Edited title"));
    assert!(out.contains("A comment before edit"));
}

#[test]
fn test_issue_edit_requires_title_or_body() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("No change");

    let err = repo.run_err(&["issue", "edit", &id]);
    assert!(
        err.contains("--title") || err.contains("--body") || err.contains("at least one"),
        "should require at least --title or --body, got: {}",
        err
    );
}

// ===========================================================================
// Issue labels
// ===========================================================================

#[test]
fn test_issue_label_and_show() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Labeled issue");

    let out = repo.run_ok(&["issue", "label", &id, "bug"]);
    assert!(out.contains("Label") && out.contains("added"));

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("bug"));
}

#[test]
fn test_issue_multiple_labels() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Multi-label issue");

    repo.run_ok(&["issue", "label", &id, "bug"]);
    repo.run_ok(&["issue", "label", &id, "priority"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("bug"));
    assert!(out.contains("priority"));
}

#[test]
fn test_issue_unlabel() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Remove label");

    repo.run_ok(&["issue", "label", &id, "bug"]);
    repo.run_ok(&["issue", "label", &id, "wontfix"]);
    repo.run_ok(&["issue", "unlabel", &id, "bug"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(!out.contains("bug"));
    assert!(out.contains("wontfix"));
}

#[test]
fn test_issue_label_shown_in_list() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Listed with label");

    repo.run_ok(&["issue", "label", &id, "enhancement"]);

    let out = repo.run_ok(&["issue", "list"]);
    assert!(out.contains("enhancement"));
}

// ===========================================================================
// Issue assignees
// ===========================================================================

#[test]
fn test_issue_assign_and_show() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Assigned issue");

    let out = repo.run_ok(&["issue", "assign", &id, "Bob"]);
    assert!(out.contains("Assigned"));

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("Bob"));
    assert!(out.contains("Assignee"));
}

#[test]
fn test_issue_unassign() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Unassign test");

    repo.run_ok(&["issue", "assign", &id, "Bob"]);
    repo.run_ok(&["issue", "unassign", &id, "Bob"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(!out.contains("Bob") || !out.contains("Assignee"));
}

// ===========================================================================
// Patch commands
// ===========================================================================

#[test]
fn test_patch_create_and_show() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.commit_file("hello.txt", "hello world", "add hello");

    let id = repo.patch_create("Add hello");

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("Add hello"));
    assert!(out.contains("[open]"));
    assert!(out.contains("Alice"));
}

#[test]
fn test_patch_list_filters_by_status() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.patch_create("Open patch");
    let closed_id = repo.patch_create("Closed patch");
    repo.run_ok(&["patch", "close", &closed_id]);

    let out = repo.run_ok(&["patch", "list"]);
    assert!(out.contains("Open patch"));
    assert!(!out.contains("Closed patch"));

    // Closed patches are archived, so --all alone won't show them; need --archived
    let out = repo.run_ok(&["patch", "list", "--all", "--archived"]);
    assert!(out.contains("Open patch"));
    assert!(out.contains("Closed patch"));
}

#[test]
fn test_patch_comment() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Review me");

    let out = repo.run_ok(&["patch", "comment", &id, "-b", "Looks good overall"]);
    assert!(out.contains("Comment added"));

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("Looks good overall"));
    assert!(out.contains("Comments"));
}

#[test]
fn test_patch_inline_comment() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Review me");

    repo.run_ok(&[
        "patch",
        "comment",
        &id,
        "-b",
        "Use const here",
        "-f",
        "src/main.rs",
        "-l",
        "42",
    ]);

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("Use const here"));
    assert!(out.contains("src/main.rs"));
    assert!(out.contains("42"));
    assert!(out.contains("Inline Comments"));
}

#[test]
fn test_patch_inline_comment_requires_both_file_and_line() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Review me");

    // --file without --line
    let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-f", "foo.rs"]);
    assert!(err.contains("--file and --line must both be provided"));

    // --line without --file
    let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-l", "10"]);
    assert!(err.contains("--file and --line must both be provided"));
}

#[test]
fn test_patch_review_approve() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Feature X");

    let out = repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM!"]);
    assert!(out.contains("Review submitted"));

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("LGTM!"));
    assert!(out.contains("(approve)"));
    assert!(out.contains("Reviews"));
}

#[test]
fn test_patch_review_request_changes() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Feature Y");

    repo.run_ok(&[
        "patch",
        "review",
        &id,
        "-v",
        "request-changes",
        "-b",
        "Needs error handling",
    ]);

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("(request-changes)"));
    assert!(out.contains("Needs error handling"));
}

#[test]
fn test_patch_review_invalid_verdict() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Feature Z");

    let err = repo.run_err(&["patch", "review", &id, "-v", "yolo", "-b", "whatever"]);
    assert!(err.contains("verdict must be"));
}

#[test]
fn test_patch_revise() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("WIP feature");

    // Switch to the patch's branch and add a new commit
    repo.git(&["checkout", "test/wip-feature"]);
    repo.commit_file("v2.txt", "v2", "version 2");
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&[
        "patch",
        "revise",
        &id,
        "-b",
        "Updated implementation",
    ]);
    assert!(out.contains("Patch revised"));

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("Updated implementation"));
    // Branch-based patch show displays the branch name, not the raw head OID
    assert!(out.contains("test/wip-feature"));
}

#[test]
fn test_patch_diff() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Create a feature branch with a new file
    repo.git(&["checkout", "-b", "feature"]);
    repo.commit_file(
        "hello.rs",
        "fn main() {\n    println!(\"hello\");\n}\n",
        "add hello",
    );
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&["patch", "create", "-t", "Add hello", "-B", "feature"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap();

    let out = repo.run_ok(&["patch", "diff", id]);
    assert!(out.contains("hello.rs"), "should show filename");
    assert!(out.contains("fn main()"), "should show added code");
    assert!(out.contains("+"), "should show + for additions");
}

#[test]
fn test_patch_diff_no_changes() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    // Patch pointing at same commit as main — no diff
    let id = repo.patch_create("No diff patch");

    let out = repo.run_ok(&["patch", "diff", &id]);
    assert!(
        out.contains("No diff") || out.contains("identical"),
        "should indicate no diff, got: {}",
        out
    );
}

#[test]
fn test_patch_close() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Will close");

    let out = repo.run_ok(&["patch", "close", &id, "-r", "Superseded"]);
    assert!(out.contains("Patch closed"));

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("[closed]"));
}

#[test]
fn test_patch_auto_detect_merge_on_git_merge() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Create a feature branch ahead of main
    repo.git(&["checkout", "-b", "feature"]);
    repo.commit_file("feature.txt", "new feature", "add feature");
    repo.git(&["checkout", "main"]);

    // Create patch pointing at the feature branch
    let out = repo.run_ok(&["patch", "create", "-t", "Add feature", "-B", "feature"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap();

    // Merge via git directly
    repo.git(&["merge", "feature"]);

    // Patch should auto-detect as merged
    let out = repo.run_ok(&["patch", "show", id]);
    assert!(out.contains("[merged]"));
}

#[test]
fn test_patch_list_empty() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let out = repo.run_ok(&["patch", "list"]);
    assert!(out.contains("No patches found"));
}

// ===========================================================================
// Cross-references (patch --fixes issue)
// ===========================================================================

#[test]
fn test_patch_create_with_fixes() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let issue_id = repo.issue_open("Login bug");

    repo.git(&["checkout", "-b", "fix"]);
    repo.commit_file("fix.rs", "fixed", "fix login");
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&[
        "patch", "create",
        "-t", "Fix login bug",
        "-B", "fix",
        "--fixes", &issue_id,
    ]);
    let patch_id = out.trim().strip_prefix("Created patch ").unwrap();

    // Patch show should mention the linked issue
    let out = repo.run_ok(&["patch", "show", patch_id]);
    assert!(out.contains("Fixes"), "should show Fixes field");
    assert!(out.contains(&issue_id[..8]), "should show linked issue ID");
}


// ===========================================================================
// Unread tracking
// ===========================================================================

#[test]
fn test_issue_list_shows_unread_after_new_comments() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Unread test");

    // View the issue to mark it as read
    repo.run_ok(&["issue", "show", &id]);

    // Add comments after viewing
    repo.run_ok(&["issue", "comment", &id, "-b", "New comment 1"]);
    repo.run_ok(&["issue", "comment", &id, "-b", "New comment 2"]);

    // List should show unread count
    let out = repo.run_ok(&["issue", "list"]);
    assert!(
        out.contains("2 new"),
        "should show 2 new events, got: {}",
        out
    );
}

#[test]
fn test_issue_show_marks_as_read() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Read test");

    repo.run_ok(&["issue", "comment", &id, "-b", "A comment"]);

    // View to mark as read
    repo.run_ok(&["issue", "show", &id]);

    // List should show no unread
    let out = repo.run_ok(&["issue", "list"]);
    assert!(
        !out.contains("new"),
        "should not show 'new' after viewing, got: {}",
        out
    );
}

#[test]
fn test_issue_list_no_unread_for_never_viewed() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    // A brand new issue that's never been shown should not show "new"
    // (only show unread count after the user has viewed it once)
    repo.issue_open("Fresh issue");

    let out = repo.run_ok(&["issue", "list"]);
    assert!(
        !out.contains("new"),
        "never-viewed issue should not show unread count, got: {}",
        out
    );
}

// ===========================================================================
// Init command
// ===========================================================================

#[test]
fn test_init_no_remotes() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let out = repo.run_ok(&["init"]);
    assert!(out.contains("No remotes"));
}

// ===========================================================================
// Top-level status/log commands
// ===========================================================================

#[test]
fn test_status_command_reports_summary_and_recent_items() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.issue_open("Status bug");
    repo.patch_create("Status patch");

    let out = repo.run_ok(&["status"]);
    assert!(out.contains("Issues:  1 open, 0 closed"), "unexpected status output: {}", out);
    assert!(out.contains("Patches: 1 open, 0 merged, 0 closed"), "unexpected status output: {}", out);
    assert!(out.contains("Recently updated:"), "unexpected status output: {}", out);
    assert!(out.contains("[issue]") && out.contains("Status bug"), "unexpected status output: {}", out);
    assert!(out.contains("[patch]") && out.contains("Status patch"), "unexpected status output: {}", out);
}

#[test]
fn test_log_command_shows_recent_collab_events_and_limit() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let issue_id = repo.issue_open("Logged issue");
    repo.run_ok(&["issue", "comment", &issue_id, "-b", "Logged comment"]);
    repo.patch_create("Logged patch");

    let out = repo.run_ok(&["log"]);
    assert!(out.contains("IssueOpen issue"), "unexpected log output: {}", out);
    assert!(out.contains("IssueComment issue"), "unexpected log output: {}", out);
    assert!(out.contains("PatchCreate patch"), "unexpected log output: {}", out);
    assert!(out.contains("open \"Logged issue\""), "unexpected log output: {}", out);
    assert!(out.contains("Logged comment"), "unexpected log output: {}", out);
    assert!(out.contains("create \"Logged patch\""), "unexpected log output: {}", out);

    let limited = repo.run_ok(&["log", "-n", "2"]);
    assert_eq!(limited.lines().count(), 2, "expected exactly 2 log lines, got: {}", limited);
}

// ===========================================================================
// Full scenario tests
// ===========================================================================

#[test]
fn test_full_issue_lifecycle() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Open
    let id = repo.issue_open("Login page crashes");

    // Multiple comments
    repo.run_ok(&["issue", "comment", &id, "-b", "Stack trace attached"]);
    repo.run_ok(&["issue", "comment", &id, "-b", "Reproduced on Chrome"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("Stack trace attached"));
    assert!(out.contains("Reproduced on Chrome"));

    // Close with reason (this archives the issue)
    repo.run_ok(&["issue", "close", &id, "-r", "Fixed in commit abc123"]);
    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("[closed]"));
    assert!(out.contains("Fixed in commit abc123"));

    // Issue should be archived and not in default list
    let out = repo.run_ok(&["issue", "list"]);
    assert!(!out.contains("Login page crashes") || out.contains("No issues"));

    // But visible with --archived
    let out = repo.run_ok(&["issue", "list", "--all", "--archived"]);
    assert!(out.contains("Login page crashes"));
}

#[test]
fn test_full_patch_review_cycle() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Create feature branch with v1
    repo.git(&["checkout", "-b", "feature"]);
    repo.commit_file("feature.rs", "fn hello() {}", "v1 of feature");
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&["patch", "create", "-t", "Add hello function", "-B", "feature"]);
    let id = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();

    // Review: request changes
    repo.run_ok(&[
        "patch",
        "review",
        &id,
        "-v",
        "request-changes",
        "-b",
        "Add documentation",
    ]);

    // Inline comment on the code
    repo.run_ok(&[
        "patch",
        "comment",
        &id,
        "-b",
        "Missing doc comment",
        "-f",
        "feature.rs",
        "-l",
        "1",
    ]);

    // General comment
    repo.run_ok(&["patch", "comment", &id, "-b", "Otherwise looks good"]);

    // Revise with updated code
    repo.git(&["checkout", "feature"]);
    repo.commit_file(
        "feature.rs",
        "/// Says hello\nfn hello() {}",
        "v2: add docs",
    );
    repo.git(&["checkout", "main"]);

    repo.run_ok(&[
        "patch",
        "revise",
        &id,
        "-b",
        "Added documentation",
    ]);

    // Approve
    repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM now"]);

    // Merge via git
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "feature"]);

    // Verify final state — auto-detected as merged
    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("[merged]"));
    assert!(out.contains("Added documentation"));
    assert!(out.contains("LGTM now"));
    assert!(out.contains("(request-changes)"));
    assert!(out.contains("(approve)"));
    assert!(out.contains("Missing doc comment"));
    assert!(out.contains("Otherwise looks good"));
    assert!(out.contains("Inline Comments"));
    assert!(out.contains("feature.rs"));
}

// ===========================================================================
// T014: init-key CLI command
// ===========================================================================

#[test]
fn test_init_key_creates_key_files() {
    // Use a custom HOME so we don't clobber real keys
    let tmp_home = tempfile::TempDir::new().unwrap();
    let config_dir = tmp_home.path().join(".config").join("git-collab");

    // Create a repo for the CLI to run in
    let repo_dir = tempfile::TempDir::new().unwrap();
    common::git_cmd(repo_dir.path(), &["init", "-b", "main"]);
    common::git_cmd(repo_dir.path(), &["config", "user.name", "Alice"]);
    common::git_cmd(repo_dir.path(), &["config", "user.email", "alice@example.com"]);
    common::git_cmd(repo_dir.path(), &["commit", "--allow-empty", "-m", "init"]);

    // Run init-key with overridden HOME
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["init-key"])
        .current_dir(repo_dir.path())
        .env("HOME", tmp_home.path())
        .env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
        .output()
        .expect("failed to run git-collab");

    let stdout = String::from_utf8(output.stdout).unwrap();
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(
        output.status.success(),
        "init-key should succeed: stdout={}, stderr={}",
        stdout,
        stderr
    );
    assert!(stdout.contains("Signing key generated"), "should print success message");
    assert!(stdout.contains("Public key:"), "should print public key");

    // Key files should exist
    assert!(config_dir.join("signing-key").exists(), "private key file should exist");
    assert!(config_dir.join("signing-key.pub").exists(), "public key file should exist");

    // Run init-key again without --force: should fail
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["init-key"])
        .current_dir(repo_dir.path())
        .env("HOME", tmp_home.path())
        .env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
        .output()
        .expect("failed to run git-collab");
    assert!(
        !output.status.success(),
        "init-key without --force should fail when key exists"
    );
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(
        stderr.contains("already exists") || stderr.contains("--force"),
        "error should mention --force, got: {}",
        stderr
    );

    // Run init-key with --force: should succeed
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["init-key", "--force"])
        .current_dir(repo_dir.path())
        .env("HOME", tmp_home.path())
        .env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
        .output()
        .expect("failed to run git-collab");
    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(
        output.status.success(),
        "init-key --force should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(stdout.contains("Signing key generated"));
}

// ===========================================================================
// Issue delete
// ===========================================================================

#[test]
fn test_issue_delete_removes_from_list() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Bug to delete");

    // Verify it shows up
    let out = repo.run_ok(&["issue", "list"]);
    assert!(out.contains("Bug to delete"));

    // Delete it
    let out = repo.run_ok(&["issue", "delete", &id]);
    assert!(out.contains("Deleted issue"));

    // Verify it's gone
    let out = repo.run_ok(&["issue", "list", "--all"]);
    assert!(!out.contains("Bug to delete"));
}

#[test]
fn test_issue_delete_nonexistent_errors() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let err = repo.run_err(&["issue", "delete", "deadbeef"]);
    assert!(!err.is_empty());
}

#[test]
fn test_issue_delete_then_show_errors() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Ephemeral");
    repo.run_ok(&["issue", "delete", &id]);
    repo.run_err(&["issue", "show", &id]);
}

// ===========================================================================
// Patch delete
// ===========================================================================

#[test]
fn test_patch_delete_removes_from_list() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Patch to delete");

    // Verify it shows up
    let out = repo.run_ok(&["patch", "list"]);
    assert!(out.contains("Patch to delete"));

    // Delete it
    let out = repo.run_ok(&["patch", "delete", &id]);
    assert!(out.contains("Deleted patch"));

    // Verify it's gone
    let out = repo.run_ok(&["patch", "list", "--all"]);
    assert!(!out.contains("Patch to delete"));
}

#[test]
fn test_patch_delete_nonexistent_errors() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let err = repo.run_err(&["patch", "delete", "deadbeef"]);
    assert!(!err.is_empty());
}

#[test]
fn test_patch_delete_then_show_errors() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Ephemeral patch");
    repo.run_ok(&["patch", "delete", &id]);
    repo.run_err(&["patch", "show", &id]);
}

// ===========================================================================
// Dashboard smoke
// ===========================================================================

#[test]
fn test_dashboard_smoke_launches_and_quits_cleanly() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    let output = repo.run_dashboard_smoke("q");
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "dashboard should exit cleanly\nstdout: {}\nstderr: {}",
        stdout,
        stderr
    );
}

#[test]
fn test_dashboard_smoke_with_seeded_collab_data_exits_cleanly() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.issue_open("Dashboard issue");
    repo.patch_create("Dashboard patch");

    let output = repo.run_dashboard_smoke("q");
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "dashboard with seeded data should exit cleanly\nstdout: {}\nstderr: {}",
        stdout,
        stderr
    );
}