라이브러리 에러 설계 문서의 검증 코드다.

//! thiserror 2로 만든 라이브러리 에러: #[error] 메시지, #[from]이 만드는 From과 source,
//! #[source], #[error(transparent)], Send + Sync + 'static, 의존성 타입을 숨기는 불투명 에러,
//! 그리고 #[non_exhaustive]가 다른 크레이트에서 match에 와일드카드를 요구하는 것(E0004)을 확인한다.
//! 의존성: thiserror = "2", serde_json = "1"
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/3.개념집/24_library-errors_verify.rs"

use std::error::Error as StdError;

// ---------- 1. 실패 방식마다 variant 하나 ----------

/// 작은 "설정 읽기" 라이브러리의 공개 에러.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
    /// 파일을 읽지 못했다. 원인(io::Error)은 source()로 꺼낸다.
    #[error("failed to read config file `{path}`")]
    Read {
        path: String,
        #[source]
        source: std::io::Error,
    },
    /// 키가 없다.
    #[error("missing key `{0}`")]
    MissingKey(String),
    /// 값의 범위가 틀렸다.
    #[error("`{key}` must be between {min} and {max}, got {value}")]
    OutOfRange {
        key: &'static str,
        value: i64,
        min: i64,
        max: i64,
    },
    /// 숫자가 아니다. #[from]이 From<ParseIntError>를 만들고 source로도 쓴다.
    #[error("value is not an integer")]
    NotInteger(#[from] std::num::ParseIntError),
}

pub fn port_from(text: &str) -> Result<u16, ConfigError> {
    let raw = text
        .lines()
        .find_map(|l| l.strip_prefix("port="))
        .ok_or_else(|| ConfigError::MissingKey("port".into()))?;
    let v: i64 = raw.trim().parse()?; // #[from] 덕분에 ?가 바로 변환한다
    if !(1..=65535).contains(&v) {
        return Err(ConfigError::OutOfRange {
            key: "port",
            value: v,
            min: 1,
            max: 65535,
        });
    }
    Ok(v as u16)
}

pub fn load(path: &str) -> Result<u16, ConfigError> {
    let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
        path: path.to_string(),
        source,
    })?;
    port_from(&text)
}

// ---------- 2. 의존성 타입을 공개 API에 내보내지 않기 ----------

/// 공개 에러는 불투명한 구조체 하나. 안쪽 enum과 serde_json::Error는 비공개다.
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub struct ParseError(#[from] Repr);

#[derive(Debug, thiserror::Error)]
enum Repr {
    #[error("invalid JSON")]
    Json(#[source] serde_json::Error),
    #[error("field `name` is empty")]
    EmptyName,
}

/// 호출자가 분기할 수 있게 공개하는 종류. 여기에는 의존성 타입이 없다.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseErrorKind {
    Syntax,
    EmptyName,
}

impl ParseError {
    pub fn kind(&self) -> ParseErrorKind {
        match &self.0 {
            Repr::Json(_) => ParseErrorKind::Syntax,
            Repr::EmptyName => ParseErrorKind::EmptyName,
        }
    }
}

pub fn parse_name(json: &str) -> Result<String, ParseError> {
    let v: serde_json::Value = serde_json::from_str(json).map_err(Repr::Json)?;
    let name = v["name"].as_str().unwrap_or_default();
    if name.is_empty() {
        return Err(Repr::EmptyName.into());
    }
    Ok(name.to_string())
}

// ---------- 도우미 ----------

fn chain(e: &(dyn StdError + 'static)) -> Vec<String> {
    let mut out = vec![e.to_string()];
    let mut cur = e.source();
    while let Some(x) = cur {
        out.push(x.to_string());
        cur = x.source();
    }
    out
}

fn assert_good_error<E: StdError + Send + Sync + 'static>() {}

fn rustc(args: &[&str], dir: &std::path::Path) -> String {
    let out = std::process::Command::new("rustc")
        .args(args)
        .current_dir(dir)
        .output()
        .expect("rustc 실행");
    String::from_utf8_lossy(&out.stderr).into_owned()
}

/// 크레이트 둘을 만들어 #[non_exhaustive]의 효과를 본다.
fn non_exhaustive_cross_crate() -> (String, String, String) {
    let dir = std::env::temp_dir().join(format!("vr24_{}", std::process::id()));
    std::fs::create_dir_all(&dir).expect("임시 폴더");
    std::fs::write(
        dir.join("mylib.rs"),
        "#[non_exhaustive]\npub enum Kind { A, B }\n\
         pub fn inside(k: Kind) -> u8 { match k { Kind::A => 1, Kind::B => 2 } }\n",
    )
    .expect("쓰기");
    let lib = rustc(
        &[
            "--edition",
            "2024",
            "--crate-type=rlib",
            "--crate-name",
            "mylib",
            "mylib.rs",
        ],
        &dir,
    );
    std::fs::write(
        dir.join("user_bad.rs"),
        "pub fn f(k: mylib::Kind) -> u8 { match k { mylib::Kind::A => 1, mylib::Kind::B => 2 } }\n",
    )
    .expect("쓰기");
    std::fs::write(
        dir.join("user_ok.rs"),
        "pub fn f(k: mylib::Kind) -> u8 { match k { mylib::Kind::A => 1, mylib::Kind::B => 2, _ => 0 } }\n",
    )
    .expect("쓰기");
    let common = [
        "--edition",
        "2024",
        "--crate-type=lib",
        "--emit=metadata",
        "--extern",
        "mylib=libmylib.rlib",
        "-L",
        ".",
    ];
    let mut bad = common.to_vec();
    bad.push("user_bad.rs");
    let mut ok = common.to_vec();
    ok.push("user_ok.rs");
    (lib, rustc(&bad, &dir), rustc(&ok, &dir))
}

fn main() {
    assert_good_error::<ConfigError>();
    assert_good_error::<ParseError>();

    // #[error] 메시지
    assert_eq!(port_from("port=8080").ok(), Some(8080));
    let e = port_from("host=x").unwrap_err();
    assert_eq!(e.to_string(), "missing key `port`");
    assert!(e.source().is_none());
    let e = port_from("port=70000").unwrap_err();
    assert_eq!(
        e.to_string(),
        "`port` must be between 1 and 65535, got 70000"
    );
    // #[from]: From 구현 + source
    let e = port_from("port=abc").unwrap_err();
    assert!(matches!(e, ConfigError::NotInteger(_)));
    assert_eq!(
        chain(&e),
        vec!["value is not an integer", "invalid digit found in string"]
    );
    let direct: ConfigError = "x".parse::<i64>().unwrap_err().into();
    assert!(matches!(direct, ConfigError::NotInteger(_)));
    // #[source]가 붙은 필드
    let e = load("/no/such/dir/app.conf").unwrap_err();
    let c = chain(&e);
    println!("Read 사슬: {c:?}");
    assert_eq!(c[0], "failed to read config file `/no/such/dir/app.conf`");
    assert_eq!(c[1], "No such file or directory (os error 2)");
    // 같은 크레이트 안에서는 non_exhaustive라도 와일드카드 없이 match할 수 있다
    let code = match e {
        ConfigError::Read { .. } => 1,
        ConfigError::MissingKey(_) => 2,
        ConfigError::OutOfRange { .. } => 3,
        ConfigError::NotInteger(_) => 4,
    };
    assert_eq!(code, 1);
    println!("1. thiserror 메시지·From·source 확인");

    // 불투명 에러: transparent는 Display와 source를 안쪽에 넘긴다
    assert_eq!(parse_name(r#"{"name":"kim"}"#).ok().as_deref(), Some("kim"));
    let e = parse_name("{oops").unwrap_err();
    assert_eq!(e.kind(), ParseErrorKind::Syntax);
    let c = chain(&e);
    println!("불투명 에러 사슬: {c:?}");
    assert_eq!(c[0], "invalid JSON");
    assert_eq!(c.len(), 2);
    let e = parse_name(r#"{"name":""}"#).unwrap_err();
    assert_eq!(e.kind(), ParseErrorKind::EmptyName);
    assert_eq!(e.to_string(), "field `name` is empty");
    println!("2. 불투명 에러: 의존성 타입 없이 kind()로 분기");

    // non_exhaustive (다른 크레이트)
    let (lib, bad, ok) = non_exhaustive_cross_crate();
    assert!(lib.is_empty(), "{lib}");
    assert!(bad.contains("error[E0004]"), "{bad}");
    assert!(
        bad.contains("non-exhaustive patterns: `_` not covered"),
        "{bad}"
    );
    assert!(ok.is_empty(), "{ok}");
    println!("3. non_exhaustive: 다른 크레이트에서 `_` 없는 match는 E0004");

    // 4. clippy::missing_errors_doc는 pedantic 묶음에 들어 있다
    let dir = std::env::temp_dir().join(format!("vr24c_{}", std::process::id()));
    std::fs::create_dir_all(&dir).expect("임시 폴더");
    std::fs::write(
        dir.join("doc.rs"),
        "/// Parses.\npub fn f(s: &str) -> Result<i32, std::num::ParseIntError> { s.parse() }\n",
    )
    .expect("쓰기");
    let out = std::process::Command::new("clippy-driver")
        .args([
            "--edition",
            "2024",
            "--crate-type=lib",
            "--emit=metadata",
            "-W",
            "clippy::pedantic",
            "doc.rs",
        ])
        .current_dir(&dir)
        .output()
        .expect("clippy-driver 실행");
    let msg = String::from_utf8_lossy(&out.stderr);
    assert!(msg.contains("missing `# Errors` section"), "{msg}");
    assert!(
        msg.contains("`-W clippy::missing-errors-doc` implied by `-W clippy::pedantic`"),
        "{msg}"
    );
    println!("4. clippy::missing_errors_doc (pedantic)");

    println!("ALL CHECKS PASSED");
}

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

    #[test]
    fn display_is_lowercase_without_period() {
        let msgs = [
            port_from("x").unwrap_err().to_string(),
            port_from("port=0").unwrap_err().to_string(),
            port_from("port=a").unwrap_err().to_string(),
        ];
        for m in msgs {
            assert!(!m.ends_with('.'), "{m}");
            assert!(!m.starts_with(|c: char| c.is_uppercase()), "{m}");
        }
    }
}