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

//! 50 배포 준비 연습 p1: 공개 API 리뷰. 고치기 전 API에서 rustc·clippy가 잡는 것과 사람이 봐야 하는 것을 가르고,
//! 고친 API는 같은 글자를 라이브러리로 컴파일해 rustc·clippy(pedantic, avoid-breaking-exported-api = false)가 조용한지 본다.
//! 의존성: 없음 (표준 라이브러리만)
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/4.연습문제/50_release-readiness-practice_p1.rs"

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

/// 고치기 전 API(문제에 실은 글자).
const ORIGINAL: &str = r#"pub struct shortLink {
    pub URL: String,
    code: String,
}

impl shortLink {
    pub fn get_code(&self) -> &str {
        &self.code
    }

    pub fn as_url(&self) -> String {
        self.URL.clone()
    }

    pub fn into_parts(&self) -> (String, String) {
        (self.code.clone(), self.URL.clone())
    }
}

pub fn parse_code(s: &str) -> Result<String, ()> {
    if !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) {
        Ok(s.to_string())
    } else {
        Err(())
    }
}

pub fn shorten(url: &str, permanent: bool) -> shortLink {
    let code = if permanent { "p1" } else { "t1" };
    shortLink { URL: url.to_string(), code: code.to_string() }
}
"#;

/// 고친 API. 이 파일에서 그대로 쓰이고, 같은 글자(stringify!)가 라이브러리로 따로 컴파일되어 린트 검사를 받는다.
macro_rules! fixed_api {
    ($($item:item)*) => {
        $($item)*
        const FIXED_SRC: &str = stringify!($($item)*);
    };
}

fixed_api! {
    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
    pub struct ShortLink {
        url: String,
        code: String,
    }

    impl ShortLink {
        #[must_use]
        pub fn code(&self) -> &str {
            &self.code
        }

        #[must_use]
        pub fn url(&self) -> &str {
            &self.url
        }

        #[must_use]
        pub fn into_parts(self) -> (String, String) {
            (self.code, self.url)
        }
    }

    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
    pub enum Lifetime {
        Permanent,
        Expiring,
    }

    #[derive(Debug, Clone, PartialEq, Eq)]
    #[non_exhaustive]
    pub enum ParseCodeError {
        Empty,
        InvalidChar { at: usize, ch: char },
    }

    impl std::fmt::Display for ParseCodeError {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            match self {
                ParseCodeError::Empty => f.write_str("code is empty"),
                ParseCodeError::InvalidChar { at, ch } => {
                    write!(f, "invalid character {ch:?} at byte {at}")
                }
            }
        }
    }

    impl std::error::Error for ParseCodeError {}

    /// 짧은 코드를 검사한다.
    ///
    /// # Errors
    ///
    /// 비어 있으면 `Empty`, ASCII 영숫자가 아닌 글자가 있으면 `InvalidChar`.
    pub fn parse_code(s: &str) -> Result<String, ParseCodeError> {
        if s.is_empty() {
            return Err(ParseCodeError::Empty);
        }
        match s.char_indices().find(|(_, c)| !c.is_ascii_alphanumeric()) {
            Some((at, ch)) => Err(ParseCodeError::InvalidChar { at, ch }),
            None => Ok(s.to_string()),
        }
    }

    #[must_use]
    pub fn shorten(url: &str, lifetime: Lifetime) -> ShortLink {
        let code = match lifetime {
            Lifetime::Permanent => "p1",
            Lifetime::Expiring => "t1",
        };
        ShortLink {
            url: url.to_string(),
            code: code.to_string(),
        }
    }
}

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

/// 라이브러리로 컴파일해 경고 줄(`warning: ...`)의 첫 줄만 모은다. `clippy`면 clippy-driver를 쓴다.
/// clippy 설정은 `STRICT`(avoid-breaking-exported-api = false)를 쓴다.
fn warnings(dir: &Path, src: &str, clippy: bool, extra: &[&str]) -> Vec<String> {
    warnings_with(dir, src, clippy, extra, STRICT)
}

const STRICT: &str = "avoid-breaking-exported-api = false\n";

fn warnings_with(
    dir: &Path,
    src: &str,
    clippy: bool,
    extra: &[&str],
    clippy_toml: &str,
) -> Vec<String> {
    let file = dir.join("api.rs");
    std::fs::write(&file, src).unwrap();
    let conf = dir.join("conf");
    std::fs::create_dir_all(&conf).unwrap();
    std::fs::write(conf.join("clippy.toml"), clippy_toml).unwrap();
    let out = Command::new(if clippy { "clippy-driver" } else { "rustc" })
        .args(["--edition", "2024", "--crate-type=lib", "--emit=metadata"])
        .args(extra)
        .arg("--out-dir")
        .arg(dir)
        .arg(&file)
        .env("CLIPPY_CONF_DIR", &conf)
        .output()
        .expect("컴파일러 실행");
    assert!(out.status.success());
    String::from_utf8_lossy(&out.stderr)
        .lines()
        .filter(|l| l.starts_with("warning: ") && !l.contains("warnings emitted"))
        .filter(|l| !l.contains("warning emitted"))
        .map(str::to_string)
        .collect()
}

fn main() {
    let d = tmp("lint");
    let rustc_found = warnings(
        &d,
        ORIGINAL,
        false,
        &["-W", "missing_debug_implementations"],
    );
    println!("rustc: {rustc_found:#?}");
    assert_eq!(
        rustc_found,
        [
            "warning: type `shortLink` should have an upper camel case name",
            "warning: type does not implement `Debug`; consider adding `#[derive(Debug)]` or a manual implementation",
            "warning: structure field `URL` should have a snake case name",
        ]
    );
    // missing_debug_implementations는 기본으로 꺼져 있다. -W 없이는 대소문자 경고 둘만 나온다.
    let rustc_default = warnings(&d, ORIGINAL, false, &[]);
    assert_eq!(rustc_default.len(), 2, "{rustc_default:?}");
    assert!(!rustc_default.iter().any(|w| w.contains("Debug")));
    // clippy 기본 설정: result_unit_err만 더 나오고, 공개 메서드의 wrong_self_convention은 조용하다.
    let clippy_default = warnings_with(&d, ORIGINAL, true, &[], "");
    assert!(clippy_default.iter().any(|w| w.contains("Result<_, ()>")));
    assert!(!clippy_default.iter().any(|w| w.contains("into_*")));
    assert_eq!(clippy_default.len(), 3, "{clippy_default:?}");
    let clippy_found = warnings(&d, ORIGINAL, true, &[]);
    println!("clippy: {clippy_found:#?}");
    assert!(
        clippy_found
            .iter()
            .any(|w| w == "warning: this returns a `Result<_, ()>`")
    );
    assert!(
        clippy_found
            .iter()
            .any(|w| w == "warning: methods called `into_*` usually take `self` by value")
    );
    // 사람이 봐야 하는 것: get_ 접두사, 돈이 드는 as_, pub 필드, bool 인자에는 린트가 없다.
    for w in &clippy_found {
        assert!(!w.contains("get_code") && !w.contains("as_url") && !w.contains("bool"));
    }
    // 경고 첫 줄에는 항목 이름이 잘 안 나오므로, 위의 검사만으로는 다른 린트가 없다고 못 한다. 네 줄을 그대로 맞춘다.
    assert_eq!(
        clippy_found,
        [
            "warning: type `shortLink` should have an upper camel case name",
            "warning: methods called `into_*` usually take `self` by value",
            "warning: this returns a `Result<_, ()>`",
            "warning: structure field `URL` should have a snake case name",
        ],
        "{clippy_found:?}"
    );

    // 고친 API는 rustc(missing_debug_implementations)와 clippy pedantic 모두 조용하다.
    let fixed_rustc = warnings(
        &d,
        FIXED_SRC,
        false,
        &["-W", "missing_debug_implementations"],
    );
    assert!(fixed_rustc.is_empty(), "{fixed_rustc:?}");
    let fixed_clippy = warnings(&d, FIXED_SRC, true, &["-W", "clippy::pedantic"]);
    assert!(fixed_clippy.is_empty(), "{fixed_clippy:?}");
    println!("고친 API: rustc·clippy pedantic 경고 0");
    // 변형: #[must_use]를 모두 빼면 pedantic의 must_use_candidate가 네 곳(code, url, into_parts, shorten)을 짚는다.
    assert_eq!(FIXED_SRC.matches("#[must_use]").count(), 4);
    let without = FIXED_SRC.replace("#[must_use]", "");
    let w = warnings(&d, &without, true, &["-W", "clippy::pedantic"]);
    assert_eq!(w.len(), 4, "{w:?}");
    assert!(
        w.iter()
            .all(|l| l.contains("could have a `#[must_use]` attribute"))
    );
    println!("#[must_use]를 빼면: {w:?}");

    // 고친 API의 동작.
    let link = shorten("https://example.com", Lifetime::Permanent);
    assert_eq!(link.code(), "p1");
    assert_eq!(link.url(), "https://example.com");
    let (code, url) = link.clone().into_parts();
    assert_eq!((code.as_str(), url.as_str()), ("p1", "https://example.com"));
    assert_eq!(shorten("x", Lifetime::Expiring).code(), "t1");
    assert_eq!(parse_code("abc9"), Ok("abc9".to_string()));
    assert_eq!(parse_code(""), Err(ParseCodeError::Empty));
    let e = parse_code("ab-c").unwrap_err();
    assert_eq!(e, ParseCodeError::InvalidChar { at: 2, ch: '-' });
    assert_eq!(e.to_string(), "invalid character '-' at byte 2");
    let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(e);
    assert_eq!(boxed.to_string(), "invalid character '-' at byte 2");
    println!("ALL CHECKS PASSED");
}

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

    #[test]
    fn non_ascii_is_rejected() {
        assert_eq!(
            parse_code("한"),
            Err(ParseCodeError::InvalidChar { at: 0, ch: '한' })
        );
    }

    #[test]
    fn lifetime_is_clearer_than_bool() {
        assert_ne!(
            shorten("u", Lifetime::Permanent),
            shorten("u", Lifetime::Expiring)
        );
    }
}