stax 0.50.2

Fast stacked Git branches and PRs
Documentation
mod common;

use common::{OutputAssertions, TestRepo};
use serde_json::Value;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn ensure_crypto_provider() {
    let _ = rustls::crypto::ring::default_provider().install_default();
}

fn write_test_config(home: &Path, api_base_url: &str) {
    let config_dir = home.join(".config").join("stax");
    fs::create_dir_all(&config_dir).expect("Failed to create config dir");
    fs::write(
        config_dir.join("config.toml"),
        format!("[remote]\napi_base_url = \"{}\"\n", api_base_url),
    )
    .expect("Failed to write config");
}

fn configure_github_remote(repo: &TestRepo) {
    let output = repo.git(&[
        "remote",
        "add",
        "origin",
        "https://github.com/test/repo.git",
    ]);
    assert!(
        output.status.success(),
        "Failed to add origin: {}",
        TestRepo::stderr(&output)
    );
}

fn setup_repo(home: &Path, api_base_url: &str) -> TestRepo {
    let repo = TestRepo::new();
    configure_github_remote(&repo);
    write_test_config(home, api_base_url);
    repo
}

fn env_with_auth<'a>(home: &'a TempDir) -> [(&'a str, &'a str); 2] {
    [
        ("HOME", home.path().to_str().unwrap()),
        ("STAX_GITHUB_TOKEN", "mock-token"),
    ]
}

#[tokio::test]
async fn test_pr_list_human_output() {
    ensure_crypto_provider();
    let mock_server = MockServer::start().await;
    let home = TempDir::new().unwrap();
    let repo = setup_repo(home.path(), &mock_server.uri());

    Mock::given(method("GET"))
        .and(path("/repos/test/repo/pulls"))
        .and(query_param("state", "open"))
        .and(query_param("sort", "created"))
        .and(query_param("direction", "desc"))
        .and(query_param("per_page", "30"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
            {
                "number": 114,
                "title": "worktrees enhanced",
                "html_url": "https://github.com/test/repo/pull/114",
                "user": { "login": "cesarferreira" },
                "head": { "ref": "cesar/worktrees-enhanced" },
                "base": { "ref": "main" },
                "state": "open",
                "draft": false,
                "created_at": "2026-03-15T10:00:00Z"
            },
            {
                "number": 45,
                "title": "feat: add upstream remote support for fork workflows",
                "html_url": "https://github.com/test/repo/pull/45",
                "user": { "login": "rawnam" },
                "head": { "ref": "rawnam/02-12/feat-upstream-sync-and-pr-support-for-fork-workflows" },
                "base": { "ref": "main" },
                "state": "open",
                "draft": true,
                "created_at": "2026-03-14T09:00:00Z"
            }
        ])))
        .mount(&mock_server)
        .await;

    let output = repo.run_stax_with_env(&["pr", "list"], &env_with_auth(&home));
    output
        .assert_success()
        .assert_stdout_contains("test/repo")
        .assert_stdout_contains("2 open pull requests")
        .assert_stdout_contains("STATE")
        .assert_stdout_contains("#114")
        .assert_stdout_contains("draft")
        .assert_stdout_contains("worktrees enhanced");
}

#[tokio::test]
async fn test_pr_list_json_output() {
    ensure_crypto_provider();
    let mock_server = MockServer::start().await;
    let home = TempDir::new().unwrap();
    let repo = setup_repo(home.path(), &mock_server.uri());

    Mock::given(method("GET"))
        .and(path("/repos/test/repo/pulls"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
            {
                "number": 112,
                "title": "fix: harden stax log tree traversal against stack overflow",
                "html_url": "https://github.com/test/repo/pull/112",
                "user": { "login": "cesarferreira" },
                "head": { "ref": "fix/log-iterative-tree-traversal" },
                "base": { "ref": "main" },
                "state": "open",
                "draft": false,
                "created_at": "2026-03-15T11:00:00Z"
            }
        ])))
        .mount(&mock_server)
        .await;

    let output = repo.run_stax_with_env(&["pr", "list", "--json"], &env_with_auth(&home));
    output.assert_success();

    let json: Value = serde_json::from_str(&TestRepo::stdout(&output)).unwrap();
    let items = json.as_array().expect("Expected PR array");
    assert_eq!(items.len(), 1);
    assert_eq!(items[0]["number"], 112);
    assert_eq!(items[0]["author"], "cesarferreira");
    assert_eq!(items[0]["head_branch"], "fix/log-iterative-tree-traversal");
    assert_eq!(items[0]["base_branch"], "main");
    assert_eq!(items[0]["state"], "open");
    assert_eq!(items[0]["is_draft"], false);
    assert!(items[0]["created_at"]
        .as_str()
        .unwrap()
        .contains("2026-03-15"));
}

#[tokio::test]
async fn test_issue_list_human_output() {
    ensure_crypto_provider();
    let mock_server = MockServer::start().await;
    let home = TempDir::new().unwrap();
    let repo = setup_repo(home.path(), &mock_server.uri());

    Mock::given(method("GET"))
        .and(path("/repos/test/repo/issues"))
        .and(query_param("state", "open"))
        .and(query_param("sort", "updated"))
        .and(query_param("direction", "desc"))
        .and(query_param("per_page", "60"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
            {
                "number": 113,
                "title": "Handle browser launcher failures in open/pr/submit instead of swallowing them",
                "html_url": "https://github.com/test/repo/issues/113",
                "user": { "login": "cesarferreira" },
                "labels": [],
                "updated_at": "2026-03-15T12:00:00Z"
            },
            {
                "number": 77,
                "title": "Gitlab Support",
                "html_url": "https://github.com/test/repo/issues/77",
                "user": { "login": "geoHeil" },
                "labels": [{ "name": "help wanted" }],
                "updated_at": "2026-03-14T11:00:00Z"
            }
        ])))
        .mount(&mock_server)
        .await;

    let output = repo.run_stax_with_env(&["issue", "list"], &env_with_auth(&home));
    output
        .assert_success()
        .assert_stdout_contains("test/repo")
        .assert_stdout_contains("2 open issues")
        .assert_stdout_contains("LABELS")
        .assert_stdout_contains("#77")
        .assert_stdout_contains("help wanted");
}

#[tokio::test]
async fn test_issue_list_json_output() {
    ensure_crypto_provider();
    let mock_server = MockServer::start().await;
    let home = TempDir::new().unwrap();
    let repo = setup_repo(home.path(), &mock_server.uri());

    Mock::given(method("GET"))
        .and(path("/repos/test/repo/issues"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
            {
                "number": 65,
                "title": "Allow specifying CLI parameters through environment variables",
                "html_url": "https://github.com/test/repo/issues/65",
                "user": { "login": "cesarferreira" },
                "labels": [{ "name": "cli" }, { "name": "enhancement" }],
                "updated_at": "2026-03-15T08:30:00Z"
            }
        ])))
        .mount(&mock_server)
        .await;

    let output = repo.run_stax_with_env(&["issue", "list", "--json"], &env_with_auth(&home));
    output.assert_success();

    let json: Value = serde_json::from_str(&TestRepo::stdout(&output)).unwrap();
    let items = json.as_array().expect("Expected issue array");
    assert_eq!(items.len(), 1);
    assert_eq!(items[0]["number"], 65);
    assert_eq!(items[0]["author"], "cesarferreira");
    assert_eq!(items[0]["labels"][0], "cli");
    assert_eq!(items[0]["labels"][1], "enhancement");
    assert!(items[0]["updated_at"]
        .as_str()
        .unwrap()
        .contains("2026-03-15"));
}

#[tokio::test]
async fn test_pr_list_empty_state() {
    ensure_crypto_provider();
    let mock_server = MockServer::start().await;
    let home = TempDir::new().unwrap();
    let repo = setup_repo(home.path(), &mock_server.uri());

    Mock::given(method("GET"))
        .and(path("/repos/test/repo/pulls"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
        .mount(&mock_server)
        .await;

    let output = repo.run_stax_with_env(&["pr", "list"], &env_with_auth(&home));
    output
        .assert_success()
        .assert_stdout_contains("0 open pull requests")
        .assert_stdout_contains("No open pull requests.");
}

#[tokio::test]
async fn test_issue_list_empty_state() {
    ensure_crypto_provider();
    let mock_server = MockServer::start().await;
    let home = TempDir::new().unwrap();
    let repo = setup_repo(home.path(), &mock_server.uri());

    Mock::given(method("GET"))
        .and(path("/repos/test/repo/issues"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
        .mount(&mock_server)
        .await;

    let output = repo.run_stax_with_env(&["issue", "list"], &env_with_auth(&home));
    output
        .assert_success()
        .assert_stdout_contains("0 open issues")
        .assert_stdout_contains("No open issues.");
}

#[test]
fn test_pr_list_requires_auth() {
    let home = TempDir::new().unwrap();
    let repo = setup_repo(home.path(), "https://api.github.invalid");

    let output =
        repo.run_stax_with_env(&["pr", "list"], &[("HOME", home.path().to_str().unwrap())]);
    output.assert_failure();

    let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
    assert!(
        combined.contains("GitHub auth not configured"),
        "Expected auth error, got:\n{}",
        combined
    );
}

#[test]
fn test_issue_list_requires_remote() {
    let repo = TestRepo::new();
    let output = repo.run_stax(&["issue", "list"]);
    output.assert_failure();

    let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
    assert!(
        combined.contains("No git remote 'origin' found"),
        "Expected remote error, got:\n{}",
        combined
    );
}