atlassian-cli 0.9.3

Unified CLI for Atlassian Cloud products
//! Checks that every command line printed in `README.md` actually parses.
//!
//! The README drifted badly before this existed: 45 of its 128 examples had
//! stale syntax (`jira get` after the `issue` group was introduced, `--id` for
//! arguments that became positional, `--output json` where the flag is
//! `--format`). Documentation that does not run is worse than none, and a
//! reader has no way to tell which half they are looking at.
//!
//! Only the argument parse is exercised. Commands are pointed at an unroutable
//! localhost port, so nothing reaches the network: clap rejects a malformed
//! command line with exit code 2 before any request is attempted. A panic
//! (exit code 101) also fails the test: `jira bulk export` once parsed cleanly
//! and then panicked reading the global `--format`, before any request, and a
//! check for exit code 2 alone let the broken README example through.

use std::path::Path;
use std::process::Command;

use tempfile::TempDir;

const BIN: &str = env!("CARGO_BIN_EXE_atlassian-cli");

/// Command lines in the README, one per line, ignoring continuations and
/// anything containing a shell pipeline (which is not a single argv).
fn readme_commands() -> Vec<String> {
    let readme = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../README.md")
        .canonicalize()
        .expect("README.md not found");
    let text = std::fs::read_to_string(readme).expect("failed to read README.md");

    let mut out = Vec::new();
    for line in text.lines() {
        let line = line.trim();
        if !line.starts_with("atlassian-cli ") || line.ends_with('\\') {
            continue;
        }
        let line = line.split('#').next().unwrap_or("").trim();
        if line.is_empty() || line.contains('|') || out.contains(&line.to_string()) {
            continue;
        }
        out.push(line.to_string());
    }
    out
}

/// Minimal shell-style split: enough for the quoting the README uses.
fn split_args(line: &str) -> Vec<String> {
    let mut args = Vec::new();
    let mut current = String::new();
    let mut quote: Option<char> = None;
    let mut started = false;

    for c in line.chars() {
        match quote {
            Some(q) if c == q => quote = None,
            Some(_) => current.push(c),
            None if c == '\'' || c == '"' => {
                quote = Some(c);
                started = true;
            }
            None if c.is_whitespace() => {
                if started || !current.is_empty() {
                    args.push(std::mem::take(&mut current));
                    started = false;
                }
            }
            None => current.push(c),
        }
    }
    if started || !current.is_empty() {
        args.push(current);
    }
    args
}

#[test]
fn every_readme_command_parses() {
    let dir = TempDir::new().unwrap();
    let config = dir.path().join("config.yaml");
    // Port 1 refuses instantly, so a command that parses fails at connect
    // rather than doing anything.
    std::fs::write(
        &config,
        "default_profile: t\nprofiles:\n  t:\n    email: a@b.c\n    base_url: http://127.0.0.1:1\n    workspace: w\n",
    )
    .unwrap();

    let commands = readme_commands();
    assert!(
        commands.len() > 100,
        "expected to find the README's command examples, found {}",
        commands.len()
    );

    // One process per example, so spawn them all first and collect after.
    // Sequentially this takes about 100s, which is not worth it in CI.
    let children: Vec<_> = commands
        .iter()
        .map(|line| {
            let args = split_args(line);
            let child = Command::new(BIN)
                // Supply a config only when the example does not name one itself;
                // otherwise clap rejects the flag as repeated and a
                // perfectly good documented command looks broken.
                .args(if args.iter().any(|a| a == "--config") {
                    Vec::new()
                } else {
                    vec!["--config".to_string(), config.display().to_string()]
                })
                // Never resolve against the developer's real home.
                .env("HOME", config.parent().unwrap_or_else(|| Path::new(".")))
                .env(
                    "ATLASSIAN_CLI_CONFIG_DIR",
                    config.parent().unwrap_or_else(|| Path::new(".")),
                )
                .env_remove("XDG_CONFIG_HOME")
                .env_remove("ATLASSIAN_API_TOKEN")
                .env_remove("ATLASSIAN_BITBUCKET_TOKEN")
                .env_remove("BITBUCKET_TOKEN")
                .args(&args[1..])
                .env("ATLASSIAN_CLI_TOKEN_T", "x")
                .env("ATLASSIAN_CLI_BITBUCKET_TOKEN_T", "x")
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::piped())
                .spawn()
                .expect("failed to run the CLI");
            (line, child)
        })
        .collect();

    let mut failures = Vec::new();
    for (line, child) in children {
        let output = child
            .wait_with_output()
            .expect("failed to wait for the CLI");
        // clap exits 2 on a usage error, and a Rust panic exits 101. Anything
        // else means the command line was accepted and the command got as far
        // as the network, which is all this test cares about.
        if matches!(output.status.code(), Some(2) | Some(101)) {
            let reason = String::from_utf8_lossy(&output.stderr)
                .lines()
                .find(|l| !l.trim().is_empty())
                .unwrap_or("")
                .to_string();
            failures.push(format!("  {line}\n      -> {reason}"));
        }
    }

    assert!(
        failures.is_empty(),
        "{} of {} README commands do not parse:\n{}",
        failures.len(),
        commands.len(),
        failures.join("\n")
    );
}