에러와 메모리 연습 문서의 문제 1 풀이 코드다.

//! 연습 p1: unwrap으로 가득한 CSV 한 줄 파서를 thiserror 에러 enum과 ?로 고친 풀이.
//! 의존성: thiserror = "2"
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/4.연습문제/30_errors-memory-practice_p1.rs"

use std::num::ParseIntError;

#[derive(Debug, PartialEq)]
pub struct User {
    pub name: String,
    pub age: u8,
}

/// 문제의 원래 코드(고치기 전). 잘못된 입력에서 panic한다.
pub fn parse_user_unwrap(line: &str) -> User {
    let mut parts = line.split(',');
    let name = parts.next().unwrap().trim().to_string();
    let age = parts.next().unwrap().trim().parse().unwrap();
    User { name, age }
}

#[derive(Debug, thiserror::Error, PartialEq)]
#[non_exhaustive]
pub enum UserError {
    #[error("missing field `{0}`")]
    Missing(&'static str),
    #[error("name is empty")]
    EmptyName,
    #[error("invalid age")]
    BadAge(#[from] ParseIntError),
}

/// 풀이.
pub fn parse_user(line: &str) -> Result<User, UserError> {
    let mut parts = line.split(',');
    let name = parts.next().ok_or(UserError::Missing("name"))?.trim();
    if name.is_empty() {
        return Err(UserError::EmptyName);
    }
    let age = parts
        .next()
        .ok_or(UserError::Missing("age"))?
        .trim()
        .parse()?;
    Ok(User {
        name: name.to_string(),
        age,
    })
}

fn source_text(e: &UserError) -> Option<String> {
    std::error::Error::source(e).map(|s| s.to_string())
}

fn main() {
    assert_eq!(
        parse_user("kim, 30"),
        Ok(User {
            name: "kim".into(),
            age: 30
        })
    );
    assert_eq!(parse_user_unwrap("kim, 30").age, 30);

    let cases = ["kim", ", 3", "lee, -1", "park, 300", "choi, x"];
    let got: Vec<String> = cases
        .iter()
        .map(|c| match parse_user(c) {
            Ok(u) => format!("ok {u:?}"),
            Err(e) => format!("{e} / {:?}", source_text(&e)),
        })
        .collect();
    for (c, g) in cases.iter().zip(&got) {
        println!("{c:?} -> {g}");
    }
    assert_eq!(got[0], "missing field `age` / None");
    assert_eq!(got[1], "name is empty / None");
    assert_eq!(
        got[2],
        "invalid age / Some(\"invalid digit found in string\")"
    );
    assert_eq!(
        got[3],
        "invalid age / Some(\"number too large to fit in target type\")"
    );
    assert_eq!(
        got[4],
        "invalid age / Some(\"invalid digit found in string\")"
    );

    // 원래 코드는 같은 입력에서 panic한다
    std::panic::set_hook(Box::new(|_| {}));
    let panics = cases
        .iter()
        .filter(|c| std::panic::catch_unwind(|| parse_user_unwrap(c)).is_err())
        .count();
    let _ = std::panic::take_hook();
    // ", 3"은 이름이 빈 문자열인 채로 통과한다(원래 코드는 빈 이름을 막지 않는다)
    assert_eq!(panics, 4);
    println!("원래 코드: 5개 중 4개 입력에서 panic, 1개는 빈 이름을 그대로 받아들임");

    println!("ALL CHECKS PASSED");
}

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

    #[test]
    fn extra_fields_are_ignored() {
        assert_eq!(parse_user("a,1,zzz").map(|u| u.age), Ok(1));
    }

    #[test]
    fn empty_line_is_empty_name() {
        // "".split(',')는 빈 조각 하나를 준다
        assert_eq!(parse_user(""), Err(UserError::EmptyName));
    }
}