bzr 0.2.0

A CLI for Bugzilla, inspired by gh
Documentation
//! Shared test utilities used by both unit tests (`src/`) and integration tests (`tests/`).
//!
//! Tests that set `XDG_CONFIG_HOME` must hold `ENV_LOCK` to avoid races.

/// Acquire `ENV_LOCK`, start a mock server, create a temp dir, and configure it.
/// Returns the guard, mock server, and temp dir (all must stay alive for the test).
///
/// # Panics
///
/// Panics if the temp directory cannot be created.
#[expect(clippy::unwrap_used)]
pub async fn setup_test_env() -> (
    tokio::sync::MutexGuard<'static, ()>,
    wiremock::MockServer,
    tempfile::TempDir,
) {
    let lock = super::ENV_LOCK.lock().await;
    let mock = wiremock::MockServer::start().await;
    let tmp = tempfile::TempDir::new().unwrap();
    setup_config(&tmp, &mock.uri());
    (lock, mock, tmp)
}

/// Write a test config file to the given temp directory.
///
/// # Panics
///
/// Panics if the config directory or file cannot be created.
#[expect(clippy::unwrap_used)]
pub fn setup_config(tmp: &tempfile::TempDir, server_url: &str) {
    let config_dir = tmp.path().join("bzr");
    std::fs::create_dir_all(&config_dir).unwrap();
    let config_content = format!(
        r#"
default_server = "test"

[servers.test]
url = "{server_url}"
api_key = "test-key"
auth_method = "header"
api_mode = "rest"
"#,
    );
    std::fs::write(config_dir.join("config.toml"), config_content).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        std::fs::set_permissions(&config_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
        std::fs::set_permissions(
            config_dir.join("config.toml"),
            std::fs::Permissions::from_mode(0o600),
        )
        .unwrap();
    }
    // SAFETY: Tests are serialized via ENV_LOCK; no other threads read this var concurrently.
    unsafe { std::env::set_var("XDG_CONFIG_HOME", tmp.path()) };
}

/// Capture stdout written during an async operation.
///
/// Redirects file descriptor 1 to a temp file, runs the future, restores
/// stdout, then returns the captured content. Must be called while holding
/// `ENV_LOCK` (tests are single-threaded via `setup_test_env`).
///
/// # Panics
///
/// Panics if stdout redirection or temp file operations fail.
#[cfg(unix)]
#[expect(clippy::unwrap_used)]
pub async fn capture_stdout<F, T>(f: F) -> (T, String)
where
    F: std::future::Future<Output = T>,
{
    use std::io::{Read, Seek, Write};
    use std::os::unix::io::AsRawFd;

    extern "C" {
        fn dup(fd: std::ffi::c_int) -> std::ffi::c_int;
        fn dup2(oldfd: std::ffi::c_int, newfd: std::ffi::c_int) -> std::ffi::c_int;
        fn close(fd: std::ffi::c_int) -> std::ffi::c_int;
    }

    let tmp = tempfile::NamedTempFile::new().unwrap();
    let tmp_fd = tmp.as_file().as_raw_fd();

    // SAFETY: dup() on a valid fd is safe; tests are serialized via ENV_LOCK.
    let saved_stdout = unsafe { dup(1) };
    assert!(saved_stdout >= 0, "dup(1) failed");

    // SAFETY: dup2() on valid fds is safe.
    unsafe {
        dup2(tmp_fd, 1);
    }

    let result = f.await;
    std::io::stdout().flush().unwrap();

    // SAFETY: Restoring the saved fd.
    unsafe {
        dup2(saved_stdout, 1);
        close(saved_stdout);
    }

    let mut captured = String::new();
    let mut file = tmp.reopen().unwrap();
    file.seek(std::io::SeekFrom::Start(0)).unwrap();
    file.read_to_string(&mut captured).unwrap();

    (result, captured)
}

/// Extract the first valid JSON value from a string that may contain
/// other test output mixed in (due to concurrent test threads writing
/// to the same stdout fd).
///
/// # Panics
///
/// Panics if no valid JSON is found in the output.
#[expect(
    clippy::panic,
    reason = "test helper: unrecoverable if output is not JSON"
)]
pub fn extract_json(output: &str) -> serde_json::Value {
    if let Ok(v) = serde_json::from_str(output) {
        return v;
    }
    for (i, ch) in output.char_indices() {
        if ch == '[' || ch == '{' {
            if let Some(v) = try_parse_from(&output[i..], ch) {
                return v;
            }
        }
    }
    panic!("no valid JSON found in captured output: {output}");
}

/// Try to parse JSON starting at `slice`, whose first char is `opener` (`[` or `{`).
/// First attempts the full slice, then progressively shorter prefixes ending at
/// each matching close bracket, walking from the end backwards.
fn try_parse_from(slice: &str, opener: char) -> Option<serde_json::Value> {
    if let Ok(v) = serde_json::from_str(slice) {
        return Some(v);
    }
    let closing = if opener == '[' { ']' } else { '}' };
    for (j, jch) in slice.char_indices().rev() {
        if jch == closing {
            if let Ok(v) = serde_json::from_str(&slice[..=j]) {
                return Some(v);
            }
        }
    }
    None
}

/// Build a mock XML-RPC Bug.search response containing one bug.
pub fn xmlrpc_bug_response(id: i64, summary: &str) -> String {
    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
            <methodResponse><params><param><value><struct>
              <member><name>bugs</name><value><array><data>
                <value><struct>
                  <member><name>id</name><value><int>{id}</int></value></member>
                  <member><name>summary</name><value><string>{summary}</string></value></member>
                  <member><name>status</name><value><string>NEW</string></value></member>
                  <member><name>product</name><value><string>TestProduct</string></value></member>
                  <member><name>component</name><value><string>General</string></value></member>
                  <member><name>assigned_to</name><value><string>dev@example.com</string></value></member>
                  <member><name>priority</name><value><string>P1</string></value></member>
                  <member><name>severity</name><value><string>normal</string></value></member>
                  <member><name>keywords</name><value><array><data></data></array></value></member>
                  <member><name>blocks</name><value><array><data></data></array></value></member>
                  <member><name>depends_on</name><value><array><data></data></array></value></member>
                  <member><name>cc</name><value><array><data></data></array></value></member>
                </struct></value>
              </data></array></value></member>
            </struct></value></param></params></methodResponse>"#
    )
}

#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn extract_json_skips_leading_noise() {
        let parsed = extract_json("warning: mixed output\n{\"id\":42,\"name\":\"bug\"}\n");
        assert_eq!(parsed["id"], 42);
        assert_eq!(parsed["name"], "bug");
    }

    #[test]
    fn extract_json_finds_bracketed_payload_with_trailing_noise() {
        let parsed = extract_json("noise\n[{\"id\":1},{\"id\":2}]\nextra output");
        assert_eq!(parsed.as_array().unwrap().len(), 2);
        assert_eq!(parsed[1]["id"], 2);
    }

    #[test]
    fn xmlrpc_bug_response_contains_expected_bug_fields() {
        let xml = xmlrpc_bug_response(42, "Crash on startup");
        assert!(xml.contains("<int>42</int>"));
        assert!(xml.contains("<string>Crash on startup</string>"));
        assert!(xml.contains("<name>status</name>"));
        assert!(xml.contains("<string>NEW</string>"));
    }
}