서비스 기초 연습 문서의 문제 1 풀이 코드다.

//! 서비스 기초 연습 p1: 환경 값에서 설정을 읽고, 오류를 모두 모아 보고하고, 관리자 비밀번호를 가린다.
//! 의존성: 없음 (표준 라이브러리만)
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/4.연습문제/45_service-basics-practice_p1.rs"

use std::collections::HashMap;
use std::fmt;

#[derive(Clone)]
pub struct Secret(String);

impl Secret {
    pub fn expose(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for Secret {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("Secret([REDACTED])")
    }
}

#[derive(Debug)]
pub struct Limits {
    pub max_body_kb: u32,
    pub allowed_origins: Vec<String>,
    pub admin_password: Secret,
}

pub fn load(env: &HashMap<&str, &str>) -> Result<Limits, Vec<String>> {
    let mut errs = Vec::new();

    // 하위목표 1: 기본값이 있는 숫자
    let max_body_kb = match env.get("APP_MAX_BODY_KB") {
        None => 64,
        Some(v) => match v.parse::<u32>() {
            Ok(n) if (1..=10_240).contains(&n) => n,
            _ => {
                errs.push(format!("APP_MAX_BODY_KB={v:?}: expected 1..=10240"));
                0
            }
        },
    };

    // 하위목표 2: 쉼표로 나눈 목록, 항목마다 검사
    let allowed_origins: Vec<String> = env
        .get("APP_ALLOWED_ORIGINS")
        .map(|v| {
            v.split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect()
        })
        .unwrap_or_default();
    if allowed_origins.is_empty() {
        errs.push("APP_ALLOWED_ORIGINS is required".to_string());
    }
    for o in &allowed_origins {
        if !(o.starts_with("http://") || o.starts_with("https://")) {
            errs.push(format!(
                "APP_ALLOWED_ORIGINS item {o:?}: must start with http:// or https://"
            ));
        }
    }

    // 하위목표 3: 비밀값. 오류 메시지에 값을 넣지 않는다.
    let admin_password = match env.get("APP_ADMIN_PASSWORD") {
        None => {
            errs.push("APP_ADMIN_PASSWORD is required".to_string());
            None
        }
        Some(p) if p.chars().count() < 12 => {
            errs.push("APP_ADMIN_PASSWORD: must be at least 12 characters".to_string());
            None
        }
        Some(p) => Some(Secret(p.to_string())),
    };

    match admin_password {
        Some(admin_password) if errs.is_empty() => Ok(Limits {
            max_body_kb,
            allowed_origins,
            admin_password,
        }),
        _ => Err(errs),
    }
}

fn main() {
    let good: HashMap<&str, &str> = [
        (
            "APP_ALLOWED_ORIGINS",
            "https://a.example, http://localhost:5173",
        ),
        ("APP_ADMIN_PASSWORD", "correct-horse-battery"),
    ]
    .into();
    let l = load(&good).expect("valid");
    let dbg = format!("{l:?}");
    println!("{dbg}");
    assert_eq!(l.max_body_kb, 64);
    assert_eq!(
        l.allowed_origins,
        ["https://a.example", "http://localhost:5173"]
    );
    assert!(!dbg.contains("correct-horse"));
    assert_eq!(l.admin_password.expose(), "correct-horse-battery");

    let bad: HashMap<&str, &str> = [
        ("APP_MAX_BODY_KB", "0"),
        ("APP_ALLOWED_ORIGINS", "https://a.example,ftp://x"),
        ("APP_ADMIN_PASSWORD", "hunter2"),
    ]
    .into();
    let errs = load(&bad).unwrap_err();
    for e in &errs {
        println!("  - {e}");
    }
    assert_eq!(errs.len(), 3);
    assert!(!errs.join("\n").contains("hunter2"));
    println!("ALL CHECKS PASSED");
}

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

    #[test]
    fn empty_env_reports_two_missing() {
        let errs = load(&HashMap::new()).unwrap_err();
        assert_eq!(
            errs,
            [
                "APP_ALLOWED_ORIGINS is required",
                "APP_ADMIN_PASSWORD is required"
            ]
        );
    }

    #[test]
    fn non_number_body_limit() {
        let env: HashMap<&str, &str> = [
            ("APP_MAX_BODY_KB", "lots"),
            ("APP_ALLOWED_ORIGINS", "https://a"),
            ("APP_ADMIN_PASSWORD", "123456789012"),
        ]
        .into();
        assert_eq!(
            load(&env).unwrap_err(),
            ["APP_MAX_BODY_KB=\"lots\": expected 1..=10240"]
        );
    }
}