CI 품질 게이트 문서의 검증 코드다.

//! CI 품질 게이트의 동작을 실제 도구로 확인한다: rustfmt --check 종료 코드, -D warnings,
//! must_use, forbid, rust-version 검사. 그리고 문서의 워크플로와 Cargo.toml 예를 글자로 점검한다.
//! 의존성: 없음 (표준 라이브러리만)
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/3.개념집/48_ci-quality-gates_verify.rs"

use std::path::{Path, PathBuf};
use std::process::{Command, Output};

fn tmp(name: &str) -> PathBuf {
    let d = std::env::temp_dir().join(format!("vr48_{}_{name}", std::process::id()));
    let _ = std::fs::remove_dir_all(&d);
    std::fs::create_dir_all(&d).unwrap();
    d
}

fn rustc_lib(dir: &Path, code: &str, extra: &[&str]) -> Output {
    let file = dir.join("snippet.rs");
    std::fs::write(&file, code).unwrap();
    Command::new("rustc")
        .args(["--edition", "2024", "--crate-type=lib", "--emit=metadata"])
        .args(extra)
        .arg("--out-dir")
        .arg(dir)
        .arg(&file)
        .output()
        .expect("rustc 실행")
}

fn stderr(o: &Output) -> String {
    String::from_utf8_lossy(&o.stderr).into_owned()
}

fn check_rustfmt() {
    let d = tmp("fmt");
    let ugly = d.join("ugly.rs");
    let pretty = d.join("pretty.rs");
    std::fs::write(&ugly, "fn main(){let x=1;println!(\"{}\",x);}\n").unwrap();
    std::fs::write(
        &pretty,
        "fn main() {\n    let x = 1;\n    println!(\"{}\", x);\n}\n",
    )
    .unwrap();
    let code = |p: &Path| {
        Command::new("rustfmt")
            .args(["--edition", "2024", "--check"])
            .arg(p)
            .output()
            .unwrap()
            .status
            .code()
    };
    let (u, p) = (code(&ugly), code(&pretty));
    println!("rustfmt --check 종료 코드: 서식 다름 {u:?}, 서식 맞음 {p:?}");
    assert_eq!(u, Some(1));
    assert_eq!(p, Some(0));
}

fn check_deny_warnings() {
    let d = tmp("warn");
    let code = "pub fn f() { let unused = 1; }\n";
    let plain = rustc_lib(&d, code, &[]);
    let denied = rustc_lib(&d, code, &["-D", "warnings"]);
    assert!(plain.status.success(), "경고만 있으면 빌드는 된다");
    assert!(stderr(&plain).contains("warning: unused variable"));
    assert!(!denied.status.success(), "-D warnings면 경고가 에러가 된다");
    assert!(stderr(&denied).contains("error: unused variable"));
    println!("-D warnings: 경고만 있던 코드가 빌드 실패로 바뀜");
}

fn check_must_use() {
    let d = tmp("mustuse");
    let warn = rustc_lib(
        &d,
        "pub fn f() -> Result<(), ()> { Ok(()) }\npub fn g() { f(); }\n",
        &[],
    );
    assert!(warn.status.success());
    assert!(stderr(&warn).contains("unused `Result` that must be used"));
    let deny = rustc_lib(
        &d,
        "#![deny(unused_must_use)]\n#[must_use]\npub fn token() -> u32 { 7 }\npub fn g() { token(); }\npub fn h() { let _ = token(); }\n",
        &[],
    );
    assert!(!deny.status.success());
    let e = stderr(&deny);
    assert!(
        e.contains("unused return value of `token` that must be used"),
        "{e}"
    );
    assert_eq!(e.matches("must be used").count(), 1, "`let _ =`은 허용된다");
    println!("must_use: 버린 Result는 경고, deny면 에러, `let _ =`은 통과");
}

fn check_forbid() {
    let d = tmp("forbid");
    let o = rustc_lib(
        &d,
        "#![forbid(unsafe_code)]\n#[allow(unsafe_code)]\npub fn f() {}\n",
        &[],
    );
    assert!(!o.status.success());
    assert!(stderr(&o).contains("E0453"), "{}", stderr(&o));
    println!("forbid(unsafe_code) 아래 allow: E0453");
}

fn check_rust_version() {
    let d = tmp("msrv");
    std::fs::create_dir_all(d.join("src")).unwrap();
    std::fs::write(
        d.join("Cargo.toml"),
        "[package]\nname = \"msrv-demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\nrust-version = \"1.200\"\n",
    )
    .unwrap();
    std::fs::write(d.join("src/lib.rs"), "pub fn f() {}\n").unwrap();
    let o = Command::new("cargo")
        .args(["check", "--offline", "-q"])
        .current_dir(&d)
        .env("CARGO_TARGET_DIR", d.join("target"))
        .output()
        .unwrap();
    assert!(!o.status.success());
    let e = stderr(&o);
    println!("rust-version 1.200: {}", e.trim());
    assert!(e.contains("requires rustc 1.200"), "{e}");
    let ignored = Command::new("cargo")
        .args(["check", "--offline", "-q", "--ignore-rust-version"])
        .current_dir(&d)
        .env("CARGO_TARGET_DIR", d.join("target"))
        .output()
        .unwrap();
    assert!(ignored.status.success(), "{}", stderr(&ignored));
    println!("--ignore-rust-version: 통과");
}

/// 문서에 실은 Cargo.toml 예. MSRV와 [lints]가 있는지 본다.
const MANIFEST: &str = r#"[package]
name = "shortener"
version = "0.3.1"
edition = "2024"
rust-version = "1.88"

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
unwrap_used = "warn"
"#;

fn manifest_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> {
    let mut in_section = false;
    for line in text.lines() {
        let line = line.trim();
        if line.starts_with('[') {
            in_section = line == format!("[{section}]");
            continue;
        }
        if in_section
            && let Some((k, v)) = line.split_once('=')
            && k.trim() == key
        {
            return Some(v.trim().trim_matches('"'));
        }
    }
    None
}

fn is_bare_version(v: &str) -> bool {
    !v.is_empty()
        && v.split('.').count() <= 3
        && v.split('.')
            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}

/// 문서에 실은 워크플로(요약 없이 같은 글자).
const WORKFLOW: &str = r#"name: CI

on:
  push:
    branches: [main]
  pull_request:

env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: "-Dwarnings"

jobs:
  fmt:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: rustup update stable && rustup default stable
      - run: rustup component add rustfmt
      - run: cargo fmt --all -- --check

  clippy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: rustup update stable && rustup default stable
      - run: rustup component add clippy
      - uses: Swatinem/rust-cache@v2
      - run: cargo clippy --all-targets --all-features

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: rustup update stable && rustup default stable
      - uses: Swatinem/rust-cache@v2
      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-nextest
      - run: cargo nextest run --all-features
      - run: cargo test --doc --all-features

  msrv:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: taiki-e/install-action@cargo-hack
      - run: cargo hack check --rust-version --workspace --all-targets --ignore-private

  deny:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        checks:
          - advisories
          - bans licenses sources
    continue-on-error: ${{ matrix.checks == 'advisories' }}
    steps:
      - uses: actions/checkout@v6
      - uses: EmbarkStudios/cargo-deny-action@v2
        with:
          command: check ${{ matrix.checks }}

  semver:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: obi1kenobi/cargo-semver-checks-action@v2
"#;

/// 워크플로에 꼭 있어야 할 명령. 하나라도 없으면 게이트가 빠진 것이다.
const GATES: [&str; 9] = [
    "cargo fmt --all -- --check",
    "RUSTFLAGS: \"-Dwarnings\"",
    "cargo clippy --all-targets --all-features",
    "cargo nextest run",
    "cargo test --doc",
    "cargo hack check --rust-version",
    "EmbarkStudios/cargo-deny-action@v2",
    "obi1kenobi/cargo-semver-checks-action@v2",
    "Swatinem/rust-cache@v2",
];

fn missing_gates(workflow: &str) -> Vec<&'static str> {
    GATES
        .iter()
        .copied()
        .filter(|g| !workflow.contains(g))
        .collect()
}

/// `jobs:` 아래 두 칸 들여 쓴 이름들.
fn workflow_jobs(workflow: &str) -> Vec<&str> {
    workflow
        .lines()
        .skip_while(|l| *l != "jobs:")
        .skip(1)
        .filter(|l| l.starts_with("  ") && !l.starts_with("   ") && l.ends_with(':'))
        .map(|l| l.trim().trim_end_matches(':'))
        .collect()
}

fn main() {
    check_rustfmt();
    check_deny_warnings();
    check_must_use();
    check_forbid();
    check_rust_version();

    let msrv = manifest_value(MANIFEST, "package", "rust-version").unwrap();
    assert!(is_bare_version(msrv));
    assert!(!is_bare_version("^1.88"), "semver 연산자는 안 된다");
    assert!(!is_bare_version("1.88.0-nightly"));
    assert_eq!(
        manifest_value(MANIFEST, "lints.rust", "unsafe_code"),
        Some("forbid")
    );
    println!("Cargo.toml 예: rust-version {msrv}, unsafe_code forbid");
    let jobs = workflow_jobs(WORKFLOW);
    println!("워크플로 job: {jobs:?}");
    assert_eq!(jobs, ["fmt", "clippy", "test", "msrv", "deny", "semver"]);
    let missing = missing_gates(WORKFLOW);
    assert!(missing.is_empty(), "빠진 게이트: {missing:?}");
    println!("ALL CHECKS PASSED");
}

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

    #[test]
    fn version_shapes() {
        assert!(is_bare_version("1"));
        assert!(is_bare_version("1.88"));
        assert!(is_bare_version("1.88.0"));
        assert!(!is_bare_version("1.88.0.1"));
        assert!(!is_bare_version(">=1.88"));
    }

    #[test]
    fn detects_missing_gate() {
        let without_doc = WORKFLOW.replace("cargo test --doc", "echo skip");
        assert_eq!(missing_gates(&without_doc), vec!["cargo test --doc"]);
    }

    #[test]
    fn reads_sections() {
        assert_eq!(manifest_value(MANIFEST, "package", "edition"), Some("2024"));
        assert_eq!(manifest_value(MANIFEST, "lints.rust", "missing"), None);
    }
}