cleanlib-cli 0.1.4

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! CLEANLIB-166 — `--output <invalid-value>` must reject with a clear error
//! + non-zero exit, not silently fall through to the text renderer.
//!
//! Pre-fix behavior across `scan` / `audit` / `policy preview` / `fix` was:
//! `match output.as_str() { "json" => …, _ => text-fallback }` — so
//! `--output invalid` produced the text envelope silently and CI scripts
//! that misspelled `--output json` (e.g. `--output josn`) hit
//! text-they-couldn't-parse without seeing a cleanlib-level error. Only
//! `verdict` had a manual guard (exit 4) — the other four verbs did not.
//!
//! Fix — introduce a shared clap `ValueEnum` (`OutputFormat` in `main.rs`)
//! that clap parse-validates for every `--output`-carrying subcommand at
//! argv-parse time. This test exercises all five sites and asserts:
//!
//!   1. exit is non-zero (clap uses 2 for "user error"),
//!   2. stderr contains "invalid value" (clap's canonical error prefix),
//!   3. stderr enumerates the valid options ("text", "json") so the user
//!      immediately sees what to type instead — the ticket's core UX ask.
//!
//! The `--help` sanity block asserts the possible-values enumeration is
//! surfaced in help text too (so `cleanlib verdict --help` teaches the
//! valid set without having to trigger the error).

use std::process::Command;

fn cleanlib_bin() -> std::path::PathBuf {
    let p = std::env!("CARGO_BIN_EXE_cleanlib");
    std::path::PathBuf::from(p)
}

/// Common assertions applied to every `--output <bogus>` invocation.
///
/// Deliberately does NOT assert on the *decimal* exit code (clap uses 2 by
/// convention, but pinning that would over-couple to the library). The
/// non-zero-exit + "invalid value" stderr + valid-options-listed contract
/// is what customers/scripts actually rely on.
fn assert_output_flag_rejects_invalid(args: &[&str], verb: &str) {
    let out = Command::new(cleanlib_bin())
        .args(args)
        .output()
        .unwrap_or_else(|e| panic!("[{verb}] failed to invoke cleanlib: {e}"));

    assert!(
        !out.status.success(),
        "[{verb}] expected non-zero exit for --output <invalid>; got success.\n\
         args:   {args:?}\n\
         stdout: {}\n\
         stderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );

    let stderr = String::from_utf8_lossy(&out.stderr);
    let stderr_lc = stderr.to_lowercase();

    // Clap's canonical error format: `error: invalid value 'X' for
    // '--output <FORMAT>' [possible values: text, json]`. We match on the
    // stable phrases only.
    assert!(
        stderr_lc.contains("invalid value"),
        "[{verb}] expected 'invalid value' in stderr; got:\n{stderr}"
    );

    // The whole point of CLEANLIB-166 is that the error must ENUMERATE the
    // valid values so the user knows what to type instead. All three
    // (`text`, `json`, `sarif` per CLEANLIB-196) must appear.
    assert!(
        stderr_lc.contains("text") && stderr_lc.contains("json") && stderr_lc.contains("sarif"),
        "[{verb}] expected valid options 'text', 'json', 'sarif' listed in stderr; got:\n{stderr}"
    );
}

// --- Per-subcommand rejection cases -------------------------------------
//
// Each verb is invoked with the minimum required args PLUS
// `--output invalid`. Because clap validates argv BEFORE handler dispatch,
// none of these ever hit the network or the filesystem — the invalid
// value short-circuits at parse-time, which is exactly the contract we
// want to lock in.

#[test]
fn verdict_output_flag_rejects_invalid_value() {
    assert_output_flag_rejects_invalid(
        &[
            "verdict",
            "--ecosystem", "npm",
            "--package", "cors",
            "--version", "2.8.4",
            "--output", "invalid",
        ],
        "verdict",
    );
}

#[test]
fn scan_output_flag_rejects_invalid_value() {
    assert_output_flag_rejects_invalid(
        &[
            "scan",
            "--ecosystem", "npm",
            "--packages", "/dev/null",
            "--output", "invalid",
        ],
        "scan",
    );
}

#[test]
fn audit_output_flag_rejects_invalid_value() {
    assert_output_flag_rejects_invalid(
        &["audit", "--output", "invalid"],
        "audit",
    );
}

#[test]
fn policy_preview_output_flag_rejects_invalid_value() {
    assert_output_flag_rejects_invalid(
        &[
            "policy", "preview",
            "--policy", "/dev/null",
            "--packages", "/dev/null",
            "--ecosystem", "npm",
            "--output", "invalid",
        ],
        "policy preview",
    );
}

#[test]
fn fix_output_flag_rejects_invalid_value() {
    assert_output_flag_rejects_invalid(
        &[
            "fix",
            "--lockfile", "/dev/null",
            "--output", "invalid",
        ],
        "fix",
    );
}

// --- Typo case cited in the Jira ticket ---------------------------------
//
// Ticket cites `--output invalid`, but the more customer-realistic
// failure mode is a JSON-format typo (`josn` for `json`). Same guard
// must catch it — this is the case that motivates the whole ticket
// (silent text-fallback breaking downstream JSON parsers).
#[test]
fn verdict_output_flag_rejects_typo_josn() {
    assert_output_flag_rejects_invalid(
        &[
            "verdict",
            "--ecosystem", "npm",
            "--package", "cors",
            "--version", "2.8.4",
            "--output", "josn",
        ],
        "verdict (typo)",
    );
}

// --- Help-surface sanity ------------------------------------------------
//
// Clap surfaces `[possible values: text, json]` in `--help` when the arg
// is a ValueEnum. This asserts the taxonomy is discoverable BEFORE the
// user hits the error path — the customer-education arm of the fix.

#[test]
fn verdict_help_lists_output_possible_values() {
    let out = Command::new(cleanlib_bin())
        .args(["verdict", "--help"])
        .output()
        .expect("failed to invoke cleanlib verdict --help");
    assert!(out.status.success(), "verdict --help must exit zero");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let stdout_lc = stdout.to_lowercase();
    assert!(
        stdout_lc.contains("--output") && stdout_lc.contains("text") && stdout_lc.contains("json"),
        "expected --output + [text, json] enumeration in `verdict --help`; got:\n{stdout}"
    );
}

// --- Valid values still parse ------------------------------------------
//
// Regression guard — the fix must not break `--output text` / `--output
// json`. These invocations will exit non-zero for OTHER reasons (network
// call to a nonexistent endpoint, missing packages file, etc.) so we
// only assert stderr does NOT carry the clap "invalid value" phrase.

fn assert_output_flag_accepts_valid(args: &[&str], verb: &str) {
    let out = Command::new(cleanlib_bin())
        .args(args)
        .output()
        .unwrap_or_else(|e| panic!("[{verb}] failed to invoke cleanlib: {e}"));
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.to_lowercase().contains("invalid value"),
        "[{verb}] --output <valid> must not emit clap 'invalid value' error; got:\n{stderr}"
    );
}

#[test]
fn verdict_output_json_parses() {
    assert_output_flag_accepts_valid(
        &[
            "verdict",
            "--ecosystem", "npm",
            "--package", "cors",
            "--version", "2.8.4",
            "--output", "json",
        ],
        "verdict json",
    );
}

#[test]
fn verdict_output_text_parses() {
    assert_output_flag_accepts_valid(
        &[
            "verdict",
            "--ecosystem", "npm",
            "--package", "cors",
            "--version", "2.8.4",
            "--output", "text",
        ],
        "verdict text",
    );
}