배포 준비 연습 문서의 문제 4 풀이 코드다.

//! 50 배포 준비 연습 p4: 커밋 해시를 실행 파일에 넣는 build.rs. 우선순위는 환경 변수 GIT_HASH → git → "unknown".
//! 임시 cargo 패키지와 임시 git 저장소를 만들어 네 경우(git 없음, 환경 변수, 커밋, 다음 커밋)와
//! HEAD 파일만 지켜보면 다음 커밋에서 해시가 낡는 것을 확인한다.
//! 의존성: 없음 (표준 라이브러리만). git과 cargo가 PATH에 있어야 한다.
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/4.연습문제/50_release-readiness-practice_p4.rs"

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

/// 문제의 답: 패키지 맨 위에 두는 build.rs.
const BUILD_RS: &str = r#"use std::process::Command;

fn main() {
    // 1. 밖에서 준 값이 먼저다. .git이 없는 Docker 빌드에서 쓴다.
    println!("cargo::rerun-if-env-changed=GIT_HASH");
    let hash = std::env::var("GIT_HASH")
        .ok()
        .filter(|h| !h.is_empty())
        .or_else(git_hash)
        .unwrap_or_else(|| "unknown".to_string());
    println!("cargo::rustc-env=GIT_HASH={hash}");
}

fn git(args: &[&str]) -> Option<String> {
    let dir = std::env::var("CARGO_MANIFEST_DIR").ok()?;
    let out = Command::new("git").args(args).current_dir(dir).output().ok()?;
    if !out.status.success() {
        return None;
    }
    Some(String::from_utf8(out.stdout).ok()?.trim().to_string())
}

fn git_hash() -> Option<String> {
    let hash = git(&["rev-parse", "--short=7", "HEAD"])?;
    // 2. 커밋이 바뀌면 다시 돈다: HEAD(브랜치를 바꿀 때)와 HEAD가 가리키는 브랜치 파일(커밋할 때).
    let git_dir = git(&["rev-parse", "--git-dir"])?;
    println!("cargo::rerun-if-changed={git_dir}/HEAD");
    if let Some(branch) = git(&["symbolic-ref", "-q", "HEAD"]) {
        println!("cargo::rerun-if-changed={git_dir}/{branch}");
    }
    Some(hash)
}
"#;

const MAIN_RS: &str = "fn main() {\n    println!(\"{}\", env!(\"GIT_HASH\"));\n}\n";

const MANIFEST: &str = "[package]\nname = \"hash-demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n";

fn root() -> PathBuf {
    std::env::temp_dir().join(format!("vr50p4_{}", std::process::id()))
}

fn package(name: &str, build_rs: &str) -> PathBuf {
    let d = root().join(name);
    let _ = std::fs::remove_dir_all(&d);
    std::fs::create_dir_all(d.join("src")).unwrap();
    std::fs::write(d.join("Cargo.toml"), MANIFEST).unwrap();
    std::fs::write(d.join("build.rs"), build_rs).unwrap();
    std::fs::write(d.join("src/main.rs"), MAIN_RS).unwrap();
    std::fs::write(d.join("README"), "v1\n").unwrap();
    d
}

/// 사용자의 git 설정(서명, 훅)이 끼어들지 않게 하고, 임시 폴더 위쪽의 저장소를 찾지 않게 한다.
fn isolate(cmd: &mut Command) -> &mut Command {
    cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_NOSYSTEM", "1")
        .env("GIT_CEILING_DIRECTORIES", root())
        .env_remove("GIT_DIR")
        .env_remove("GIT_WORK_TREE")
}

fn git(dir: &Path, args: &[&str]) -> String {
    let out = isolate(&mut Command::new("git"))
        .args(["-c", "user.name=demo", "-c", "user.email=demo@example.com"])
        .args(args)
        .current_dir(dir)
        .output()
        .expect("git 실행");
    assert!(
        out.status.success(),
        "git {args:?}: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

fn commit(dir: &Path, readme: &str) -> String {
    std::fs::write(dir.join("README"), readme).unwrap();
    git(dir, &["add", "Cargo.toml", "build.rs", "src", "README"]);
    git(dir, &["commit", "-q", "-m", readme.trim()]);
    git(dir, &["rev-parse", "--short=7", "HEAD"])
}

fn run(dir: &Path, git_hash: Option<&str>) -> String {
    let mut cmd = Command::new("cargo");
    isolate(&mut cmd)
        .args(["run", "-q", "--offline"])
        .current_dir(dir)
        .env("CARGO_TARGET_DIR", dir.join("target"))
        .env_remove("GIT_HASH");
    if let Some(h) = git_hash {
        cmd.env("GIT_HASH", h);
    }
    let out: Output = cmd.output().expect("cargo 실행");
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

fn main() {
    let _ = std::fs::remove_dir_all(root());

    // (가) git 저장소가 아니다: unknown. (나) 환경 변수를 주면 그 값.
    let plain = package("plain", BUILD_RS);
    assert_eq!(run(&plain, None), "unknown");
    assert_eq!(run(&plain, Some("deadbee")), "deadbee");
    assert_eq!(run(&plain, None), "unknown");
    println!("git 없음: unknown, GIT_HASH=deadbee: deadbee");

    // (다) 커밋이 있다: git이 준 7글자. (라) 다음 커밋을 하면 다시 빌드되어 새 해시.
    let repo = package("repo", BUILD_RS);
    git(&repo, &["init", "-q", "-b", "main"]);
    let first = commit(&repo, "v1\n");
    assert_eq!(first.len(), 7);
    assert_eq!(run(&repo, None), first);
    let second = commit(&repo, "v2\n");
    assert_ne!(first, second);
    assert_eq!(run(&repo, None), second);
    // 환경 변수가 git보다 먼저다.
    assert_eq!(run(&repo, Some("cafe123")), "cafe123");
    println!("커밋 {first} → {second}: 실행 파일의 해시도 따라 바뀜");

    // 변형: HEAD 파일만 지켜보면 같은 브랜치의 다음 커밋을 놓친다.
    let head_only_rs = BUILD_RS.replace(
        "    if let Some(branch) = git(&[\"symbolic-ref\", \"-q\", \"HEAD\"]) {\n        println!(\"cargo::rerun-if-changed={git_dir}/{branch}\");\n    }\n",
        "",
    );
    assert_ne!(head_only_rs, BUILD_RS, "바꿀 줄을 찾았다");
    let stale = package("stale", &head_only_rs);
    git(&stale, &["init", "-q", "-b", "main"]);
    let a = commit(&stale, "v1\n");
    assert_eq!(run(&stale, None), a);
    let b = commit(&stale, "v2\n");
    assert_eq!(
        std::fs::read_to_string(stale.join(".git/HEAD")).unwrap(),
        "ref: refs/heads/main\n"
    );
    let shown = run(&stale, None);
    assert_eq!(shown, a, "HEAD 파일은 그대로라 build.rs가 다시 돌지 않았다");
    assert_ne!(shown, b);
    println!("HEAD만 지켜봄: 실제 {b}, 실행 파일은 여전히 {shown}");

    let _ = std::fs::remove_dir_all(root());
    println!("ALL CHECKS PASSED");
}

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

    #[test]
    fn build_rs_emits_both_instructions() {
        assert!(BUILD_RS.contains("cargo::rerun-if-env-changed=GIT_HASH"));
        assert!(BUILD_RS.contains("cargo::rustc-env=GIT_HASH={hash}"));
        assert!(MAIN_RS.contains("env!(\"GIT_HASH\")"));
    }
}