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

//! 50 배포 준비 연습 p2: 바뀐 내용으로 다음 버전 번호 정하기. Cargo의 규칙(왼쪽에서 처음 0이 아닌 자리가 바뀌면
//! 호환되지 않음)으로 번호를 계산하고, 판정이 갈리는 변경 셋(비공개 필드 추가, 이름 바꾸기, deprecated로 남기기)은
//! 라이브러리 두 판과 쓰는 쪽 크레이트를 rustc로 컴파일해 확인한다.
//! 의존성: 없음 (표준 라이브러리만)
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/4.연습문제/50_release-readiness-practice_p2.rs"

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

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Change {
    Patch,
    Minor,
    Major,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Version {
    major: u64,
    minor: u64,
    patch: u64,
}

impl Version {
    fn parse(s: &str) -> Option<Version> {
        let mut it = s.split('.').map(|p| p.parse::<u64>().ok());
        let v = Version {
            major: it.next()??,
            minor: it.next()??,
            patch: it.next()??,
        };
        it.next().is_none().then_some(v)
    }

    /// 다음 판 번호. 0.y.z는 y를 큰 변경, z를 작은 변경 자리로 쓴다. 0.0.z는 무엇이든 z를 올린다.
    fn bump(self, c: Change) -> Version {
        let Version {
            major,
            minor,
            patch,
        } = self;
        let v = |major, minor, patch| Version {
            major,
            minor,
            patch,
        };
        match (major, minor, c) {
            (0, 0, _) => v(0, 0, patch + 1),
            (0, _, Change::Major) => v(0, minor + 1, 0),
            (0, _, _) => v(0, minor, patch + 1),
            (_, _, Change::Major) => v(major + 1, 0, 0),
            (_, _, Change::Minor) => v(major, minor + 1, 0),
            (_, _, Change::Patch) => v(major, minor, patch + 1),
        }
    }

    /// Cargo가 `"1.3.0"`처럼 적은 의존성을 `newer`로 올려도 되는가: 왼쪽에서 처음 0이 아닌 자리가 같아야 한다.
    fn caret_accepts(self, newer: Version) -> bool {
        if newer.major != self.major {
            return false;
        }
        if self.major > 0 {
            return (newer.minor, newer.patch) >= (self.minor, self.patch);
        }
        if newer.minor != self.minor {
            return false;
        }
        if self.minor > 0 {
            return newer.patch >= self.patch;
        }
        newer.patch == self.patch
    }
}

impl fmt::Display for Version {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
    }
}

/// 한 판에 여러 변경을 넣으면 가장 큰 변경이 번호를 정한다.
fn release(changes: &[Change]) -> Change {
    changes.iter().copied().max().unwrap_or(Change::Patch)
}

// ---------- 컴파일로 판정 확인 ----------

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

fn rustc(dir: &Path, args: &[&str]) -> Output {
    Command::new("rustc")
        .args(["--edition", "2024"])
        .args(args)
        .current_dir(dir)
        .output()
        .expect("rustc 실행")
}

/// 라이브러리 `store`를 `lib_src`로 만들고 `USER`를 컴파일한 결과(성공 여부, stderr).
fn against(dir: &Path, lib_src: &str) -> (bool, String) {
    std::fs::write(dir.join("store.rs"), lib_src).unwrap();
    let lib = rustc(
        dir,
        &["--crate-type=rlib", "--crate-name", "store", "store.rs"],
    );
    assert!(
        lib.status.success(),
        "{}",
        String::from_utf8_lossy(&lib.stderr)
    );
    std::fs::write(dir.join("user.rs"), USER).unwrap();
    let user = rustc(
        dir,
        &[
            "--crate-type=lib",
            "--emit=metadata",
            "--extern",
            "store=libstore.rlib",
            "-L",
            ".",
            "user.rs",
        ],
    );
    (
        user.status.success(),
        String::from_utf8_lossy(&user.stderr).into_owned(),
    )
}

/// 1.3.0: 비공개 필드가 하나 있는 Link와 resolve 함수.
const V1: &str = "pub struct Link { pub url: String, code: String }\n\
impl Link { pub fn new(url: &str) -> Self { Link { url: url.into(), code: \"a1\".into() } }\n\
            pub fn code(&self) -> &str { &self.code } }\n\
pub fn resolve(code: &str) -> Option<String> { Some(format!(\"https://x/{code}\")) }\n";

const USER: &str = "pub fn f() -> Option<String> { store::resolve(\"a1\") }\n\
pub fn g() -> String { let l = store::Link::new(\"u\"); format!(\"{} {}\", l.url, l.code()) }\n";

fn check_by_compiling() {
    let d = tmp("compile");
    let (ok, _) = against(&d, V1);
    assert!(ok, "1.3.0은 통과");

    // (다) 비공개 필드가 이미 있으면 밖에서 구조체 리터럴로 만들 수 없었다. 하나 더 넣어도 아무도 안 깨진다.
    let private_field = V1
        .replace("code: String }", "code: String, created_at: u64 }")
        .replace(
            "code: \"a1\".into() }",
            "code: \"a1\".into(), created_at: 0 }",
        );
    let (ok, err) = against(&d, &private_field);
    assert!(ok, "{err}");

    // (라) 이름을 바꾸면 쓰는 쪽에서 함수를 못 찾는다.
    let renamed = V1.replace("pub fn resolve", "pub fn lookup");
    let (ok, err) = against(&d, &renamed);
    assert!(!ok);
    assert!(
        err.contains("error[E0425]: cannot find function `resolve` in crate `store`"),
        "{err}"
    );

    // (라') 옛 이름을 deprecated로 남기면 경고만 나고 빌드는 된다.
    let kept = format!(
        "{renamed}#[deprecated(since = \"1.4.0\", note = \"use `lookup` instead\")]\n\
         pub fn resolve(code: &str) -> Option<String> {{ lookup(code) }}\n"
    );
    let (ok, err) = against(&d, &kept);
    assert!(ok, "{err}");
    assert!(
        err.contains("warning: use of deprecated function `store::resolve`: use `lookup` instead"),
        "{err}"
    );
    println!("컴파일: 비공개 필드 추가 통과, 이름 바꾸기 E0425, deprecated로 남기면 경고만");
}

fn main() {
    use Change::{Major, Minor, Patch};
    let v1 = Version::parse("1.3.0").unwrap();
    let v0 = Version::parse("0.4.2").unwrap();
    // (가)~(바)의 판정
    let changes = [
        ("가 버그 고침", Patch),
        ("나 새 공개 함수", Minor),
        ("다 비공개 필드 추가(이미 하나 있음)", Minor),
        ("라 공개 함수 이름 바꾸기", Major),
        ("마 non_exhaustive 없는 enum에 variant", Major),
        ("바 구현하는 트레이트에 필수 메서드", Major),
    ];
    let expect_1x = ["1.3.1", "1.4.0", "1.4.0", "2.0.0", "2.0.0", "2.0.0"];
    let expect_0x = ["0.4.3", "0.4.3", "0.4.3", "0.5.0", "0.5.0", "0.5.0"];
    for (i, (what, c)) in changes.iter().enumerate() {
        let a = v1.bump(*c);
        let b = v0.bump(*c);
        println!("{what}: {v1} → {a}, {v0} → {b}");
        assert_eq!(a.to_string(), expect_1x[i]);
        assert_eq!(b.to_string(), expect_0x[i]);
        // 큰 변경일 때만 Cargo가 저절로 올려 주지 않는다.
        assert_eq!(v1.caret_accepts(a), *c != Major);
        assert_eq!(v0.caret_accepts(b), *c != Major);
    }
    // 가·나·다를 한 판에: 1.4.0. 라까지 넣으면 2.0.0.
    assert_eq!(
        v1.bump(release(&[Patch, Minor, Minor])).to_string(),
        "1.4.0"
    );
    assert_eq!(
        v1.bump(release(&[Patch, Minor, Minor, Major])).to_string(),
        "2.0.0"
    );
    // 0.0.z는 무엇이든 호환되지 않는다.
    let tiny = Version::parse("0.0.3").unwrap();
    assert_eq!(tiny.bump(Patch).to_string(), "0.0.4");
    assert!(!tiny.caret_accepts(tiny.bump(Patch)));

    check_by_compiling();
    println!("ALL CHECKS PASSED");
}

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

    #[test]
    fn parse_rejects_bad_shapes() {
        assert_eq!(Version::parse("1.2"), None);
        assert_eq!(Version::parse("1.2.3.4"), None);
        assert_eq!(Version::parse("1.x.3"), None);
    }

    #[test]
    fn caret_does_not_go_backwards() {
        let v = Version::parse("1.3.0").unwrap();
        assert!(!v.caret_accepts(Version::parse("1.2.9").unwrap()));
        assert!(v.caret_accepts(Version::parse("1.9.0").unwrap()));
    }
}