communique 1.2.3

Editorialized release notes powered by AI
use std::path::Path;
use std::process::Command;

use serde_json::json;

#[test]
fn test_sponsors_command() {
    let bin = env!("CARGO_BIN_EXE_communique");

    let result = Command::new(bin)
        .arg("sponsors")
        .env("CLX_NO_PROGRESS", "1")
        .output()
        .expect("failed to run communique");

    let stdout = String::from_utf8_lossy(&result.stdout);
    let stderr = String::from_utf8_lossy(&result.stderr);
    assert!(
        result.status.success(),
        "communique failed with status {}:\n{}",
        result.status,
        stderr
    );
    assert!(stdout.contains("entire.io"), "stdout: {stdout}");
    assert!(stdout.contains("37signals"), "stdout: {stdout}");
    assert!(
        stdout.contains("https://jdx.dev/sponsors.html"),
        "stdout: {stdout}"
    );
    assert!(
        !stdout.contains("https://en.dev/sponsor.html"),
        "stdout: {stdout}"
    );
}

/// Spin up a wiremock server that responds to OpenAI chat completions
/// with a `submit_release_notes` tool call, then run the full CLI
/// pipeline against a temporary git repo.
#[tokio::test]
async fn test_generate_end_to_end() {
    let server = wiremock::MockServer::start().await;

    // Mock the OpenAI chat completions endpoint
    wiremock::Mock::given(wiremock::matchers::method("POST"))
        .and(wiremock::matchers::path("/chat/completions"))
        .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
            "choices": [{
                "message": {
                    "content": null,
                    "tool_calls": [{
                        "id": "call_1",
                        "type": "function",
                        "function": {
                            "name": "submit_release_notes",
                            "arguments": serde_json::to_string(&json!({
                                "changelog": "### Added\n- New feature",
                                "release_title": "First Release",
                                "release_body": "This is the first release.\n\n### Added\n- New feature"
                            })).unwrap()
                        }
                    }]
                },
                "finish_reason": "tool_calls"
            }],
            "usage": {"prompt_tokens": 100, "completion_tokens": 50}
        })))
        .mount(&server)
        .await;

    // Set up a temp git repo with commits and tags
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path();
    git(repo, &["init"]);
    git(repo, &["config", "user.email", "test@test.com"]);
    git(repo, &["config", "user.name", "Test"]);

    std::fs::write(repo.join("README.md"), "# hello").unwrap();
    git(repo, &["add", "-A"]);
    git(repo, &["commit", "-m", "initial commit"]);
    git(repo, &["tag", "v0.1.0"]);

    std::fs::write(repo.join("src.rs"), "fn main() {}").unwrap();
    git(repo, &["add", "-A"]);
    git(repo, &["commit", "-m", "add feature"]);
    git(repo, &["tag", "v0.2.0"]);

    let output_file = repo.join("output.md");

    // Build the binary path
    let bin = env!("CARGO_BIN_EXE_communique");

    let result = Command::new(bin)
        .current_dir(repo)
        .args([
            "generate",
            "v0.2.0",
            "v0.1.0",
            "--dry-run",
            "--repo",
            "test/repo",
            "--provider",
            "openai",
            "--model",
            "test-model",
            "--base-url",
            &server.uri(),
            "--output",
            output_file.to_str().unwrap(),
        ])
        .env("OPENAI_API_KEY", "test-key")
        .env("CLX_NO_PROGRESS", "1")
        .env_remove("GITHUB_TOKEN")
        .output()
        .expect("failed to run communique");

    let stderr = String::from_utf8_lossy(&result.stderr);
    assert!(
        result.status.success(),
        "communique failed with status {}:\n{}",
        result.status,
        stderr
    );

    let output = std::fs::read_to_string(&output_file).expect("output file should exist");
    assert!(output.contains("First Release"), "output: {output}");
    assert!(
        output.contains("### Added"),
        "output should contain changelog sections: {output}"
    );
}

#[tokio::test]
async fn test_generate_head_changelog_updates_unreleased() {
    let server = wiremock::MockServer::start().await;

    wiremock::Mock::given(wiremock::matchers::method("POST"))
        .and(wiremock::matchers::path("/chat/completions"))
        .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
            "choices": [{
                "message": {
                    "content": null,
                    "tool_calls": [{
                        "id": "call_1",
                        "type": "function",
                        "function": {
                            "name": "submit_release_notes",
                            "arguments": serde_json::to_string(&json!({
                                "changelog": "### Added\n- Unreleased feature",
                                "release_title": "HEAD - Draft Feature",
                                "release_body": "Draft feature release notes."
                            })).unwrap()
                        }
                    }]
                },
                "finish_reason": "tool_calls"
            }],
            "usage": {"prompt_tokens": 80, "completion_tokens": 40}
        })))
        .mount(&server)
        .await;

    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path();
    git(repo, &["init"]);
    git(repo, &["config", "user.email", "test@test.com"]);
    git(repo, &["config", "user.name", "Test"]);

    std::fs::write(repo.join("README.md"), "# hello").unwrap();
    git(repo, &["add", "-A"]);
    git(repo, &["commit", "-m", "initial"]);
    git(repo, &["tag", "v0.1.0"]);

    std::fs::write(repo.join("feature.rs"), "// feature").unwrap();
    std::fs::write(
        repo.join("CHANGELOG.md"),
        "# Changelog\n\n## [Unreleased]\n\n### Changed\n- Old draft\n\n## [0.1.0] - 2026-01-01\n### Added\n- Initial release\n",
    )
    .unwrap();
    git(repo, &["add", "-A"]);
    git(repo, &["commit", "-m", "add unreleased feature"]);

    let output_file = repo.join("output.md");
    let bin = env!("CARGO_BIN_EXE_communique");

    let result = Command::new(bin)
        .current_dir(repo)
        .args([
            "generate",
            "HEAD",
            "--changelog",
            "--repo",
            "test/repo",
            "--provider",
            "openai",
            "--model",
            "test-model",
            "--base-url",
            &server.uri(),
            "--output",
            output_file.to_str().unwrap(),
        ])
        .env("OPENAI_API_KEY", "test-key")
        .env("CLX_NO_PROGRESS", "1")
        .env_remove("GITHUB_TOKEN")
        .output()
        .expect("failed to run communique");

    let stderr = String::from_utf8_lossy(&result.stderr);
    assert!(result.status.success(), "communique failed: {}", stderr);

    let changelog = std::fs::read_to_string(repo.join("CHANGELOG.md")).unwrap();
    assert!(
        changelog.contains("## [Unreleased]\n\n### Added\n- Unreleased feature"),
        "changelog: {changelog}"
    );
    assert!(
        changelog.contains("## [0.1.0] - 2026-01-01\n### Added\n- Initial release"),
        "changelog should preserve released sections: {changelog}"
    );
    assert!(!changelog.contains("## [HEAD]"));
    assert!(!changelog.contains("## HEAD"));

    let output = std::fs::read_to_string(&output_file).unwrap();
    assert!(
        output.contains("# Unreleased: Draft Feature"),
        "output: {output}"
    );
    assert!(
        !output.contains("HEAD:"),
        "output should not expose HEAD label: {output}"
    );
}

/// Same as above but with `--concise` to verify changelog-only output.
#[tokio::test]
async fn test_generate_concise() {
    let server = wiremock::MockServer::start().await;

    wiremock::Mock::given(wiremock::matchers::method("POST"))
        .and(wiremock::matchers::path("/chat/completions"))
        .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
            "choices": [{
                "message": {
                    "content": null,
                    "tool_calls": [{
                        "id": "call_1",
                        "type": "function",
                        "function": {
                            "name": "submit_release_notes",
                            "arguments": serde_json::to_string(&json!({
                                "changelog": "### Fixed\n- Bug fix",
                                "release_title": "Patch Release",
                                "release_body": "Fixed a bug.\n\n### Fixed\n- Bug fix"
                            })).unwrap()
                        }
                    }]
                },
                "finish_reason": "tool_calls"
            }],
            "usage": {"prompt_tokens": 50, "completion_tokens": 25}
        })))
        .mount(&server)
        .await;

    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path();
    git(repo, &["init"]);
    git(repo, &["config", "user.email", "test@test.com"]);
    git(repo, &["config", "user.name", "Test"]);

    std::fs::write(repo.join("README.md"), "# hello").unwrap();
    git(repo, &["add", "-A"]);
    git(repo, &["commit", "-m", "initial"]);
    git(repo, &["tag", "v1.0.0"]);

    std::fs::write(repo.join("fix.rs"), "// fix").unwrap();
    git(repo, &["add", "-A"]);
    git(repo, &["commit", "-m", "fix bug"]);
    git(repo, &["tag", "v1.0.1"]);

    let output_file = repo.join("output.md");
    let bin = env!("CARGO_BIN_EXE_communique");

    let result = Command::new(bin)
        .current_dir(repo)
        .args([
            "generate",
            "v1.0.1",
            "--dry-run",
            "--concise",
            "--repo",
            "test/repo",
            "--provider",
            "openai",
            "--model",
            "test-model",
            "--base-url",
            &server.uri(),
            "--output",
            output_file.to_str().unwrap(),
        ])
        .env("OPENAI_API_KEY", "test-key")
        .env("CLX_NO_PROGRESS", "1")
        .env_remove("GITHUB_TOKEN")
        .output()
        .expect("failed to run communique");

    let stderr = String::from_utf8_lossy(&result.stderr);
    assert!(result.status.success(), "communique failed: {}", stderr);

    let output = std::fs::read_to_string(&output_file).unwrap();
    // Concise mode should output only the changelog, not the title
    assert!(output.contains("### Fixed"), "output: {output}");
    assert!(
        !output.contains("Patch Release"),
        "concise output should not contain release title: {output}"
    );
}

fn git(dir: &Path, args: &[&str]) {
    let output = Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
}