sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git

src/error.rs (5975B)

      1 //! 에러 분류와 exit code.
      2 //!
      3 //! 분류는 에러가 나는 자리마다 붙이지 않고 **파이프라인 단계 경계**에서 한 번씩만
      4 //! 붙인다. 스무 군데에 종류를 다는 건 스무 번 틀릴 기회이고, 정작 사용자가 알고
      5 //! 싶은 건 "설정이 문제냐, 콘텐츠가 문제냐, 템플릿이 문제냐"뿐이다.
      6 //!
      7 //! exit code는 **CI가 조건을 걸 수 있게** 하려고 나눈다. 사람이 읽는 건 메시지이고,
      8 //! 코드는 스크립트가 읽는다.
      9 
     10 use anyhow::Error;
     11 
     12 /// 무엇이 잘못됐는지. 값은 exit code이기도 하므로 **번호를 재사용하지 말 것** —
     13 /// 기존 스크립트의 조건이 조용히 다른 뜻이 된다.
     14 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     15 pub enum Kind {
     16     /// `sqzass.toml`
     17     Config = 3,
     18     /// `content/` 아래의 마크다운
     19     Content = 4,
     20     /// `templates/`, `i18n/`, 에셋 조회
     21     Template = 5,
     22     /// 읽기/쓰기 실패
     23     Io = 6,
     24     /// 분류되지 않은 것. 새 단계를 추가하고 태그를 잊으면 여기로 온다.
     25     Other = 1,
     26 }
     27 
     28 impl Kind {
     29     /// 문서와 검색에 쓰는 안정된 식별자. 메시지에 같이 나간다.
     30     pub fn id(self) -> &'static str {
     31         match self {
     32             Self::Config => "SQZASS_E_CONFIG",
     33             Self::Content => "SQZASS_E_CONTENT",
     34             Self::Template => "SQZASS_E_TEMPLATE",
     35             Self::Io => "SQZASS_E_IO",
     36             Self::Other => "SQZASS_E",
     37         }
     38     }
     39 
     40     pub fn code(self) -> i32 {
     41         self as i32
     42     }
     43 
     44     /// `.map_err(Kind::Config.tag())` 형태로 단계 경계에 붙인다.
     45     pub fn tag(self) -> impl Fn(Error) -> Error {
     46         move |e| e.context(self)
     47     }
     48 }
     49 
     50 impl std::fmt::Display for Kind {
     51     /// anyhow의 컨텍스트 사슬에 그대로 찍히면 사용자에게 잡음이다. 식별자만 낸다.
     52     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
     53         f.write_str(self.id())
     54     }
     55 }
     56 
     57 /// 사슬에서 `downcast_ref`로 다시 꺼내려면 이게 있어야 한다. anyhow의 사슬은
     58 /// `&dyn std::error::Error`를 내주고, 그쪽 downcast만 타입을 되찾을 수 있다.
     59 impl std::error::Error for Kind {}
     60 
     61 /// 모든 분류. 메시지 정리와 테스트가 쓴다.
     62 const ALL: &[Kind] = &[
     63     Kind::Config,
     64     Kind::Content,
     65     Kind::Template,
     66     Kind::Io,
     67     Kind::Other,
     68 ];
     69 
     70 /// 에러에 붙은 분류를 꺼낸다. 없으면 `Other`.
     71 ///
     72 /// `chain()`을 훑어 `downcast_ref`를 하면 안 된다 — 사슬이 내주는 건 anyhow가 컨텍스트를
     73 /// 감싼 내부 타입이고, 거기서는 `Kind`를 되찾을 수 없다. `Error::downcast_ref`는 컨텍스트
     74 /// 값까지 뒤져 주는 별개의 경로다.
     75 pub fn kind_of(err: &Error) -> Kind {
     76     err.downcast_ref::<Kind>().copied().unwrap_or(Kind::Other)
     77 }
     78 
     79 /// 사람이 읽을 메시지. 분류는 맨 앞에 한 번만 보이고 사슬 중간에서는 지운다.
     80 pub fn message(err: &Error) -> String {
     81     let kind = kind_of(err);
     82     let body = err
     83         .chain()
     84         .map(std::string::ToString::to_string)
     85         // 사슬에 낀 분류 계층은 자기 식별자를 Display로 낸다. 바깥 것만이 아니라
     86         // 어떤 분류든 걸러낸다 — 하나라도 새면 사용자는 그게 메시지의 일부라고 읽는다.
     87         .filter(|s| !ALL.iter().any(|k| k.id() == s))
     88         .collect::<Vec<_>>()
     89         .join(": ");
     90     format!("[{}] {body}", kind.id())
     91 }
     92 
     93 #[cfg(test)]
     94 mod tests {
     95     use super::*;
     96     use anyhow::anyhow;
     97 
     98     #[test]
     99     fn tags_survive_further_context() {
    100         let e = anyhow!("파일이 없습니다");
    101         let e = Kind::Config.tag()(e);
    102         let e = e.context("설정을 읽는 중");
    103         assert_eq!(kind_of(&e), Kind::Config);
    104     }
    105 
    106     #[test]
    107     fn untagged_errors_are_other() {
    108         assert_eq!(kind_of(&anyhow!("무언가")), Kind::Other);
    109         assert_eq!(Kind::Other.code(), 1);
    110     }
    111 
    112     #[test]
    113     fn message_shows_the_id_once_and_keeps_the_chain() {
    114         let e = Kind::Content.tag()(anyhow!("front matter 파싱 실패"));
    115         let e = e.context("start/install.md");
    116         let msg = message(&e);
    117         assert!(msg.starts_with("[SQZASS_E_CONTENT] "), "실제: {msg}");
    118         assert!(msg.contains("start/install.md"), "실제: {msg}");
    119         assert!(msg.contains("front matter 파싱 실패"), "실제: {msg}");
    120         // 분류가 사슬 중간에 한 번 더 끼어들면 안 된다.
    121         assert_eq!(msg.matches("SQZASS_E_CONTENT").count(), 1, "실제: {msg}");
    122     }
    123 
    124     /// 두 번 태그하면 바깥이 이기고 안쪽은 메시지로 샌다. 실제로 한 번 그랬다:
    125     /// 깨진 `@/` 링크가 콘텐츠가 아니라 템플릿 에러로 보고됐다.
    126     #[test]
    127     fn a_nested_tag_never_leaks_into_the_message() {
    128         let e = Kind::Content.tag()(anyhow!("깨진 링크"));
    129         let e = Kind::Template.tag()(e);
    130         let msg = message(&e);
    131         assert!(!msg.contains("SQZASS_E_CONTENT"), "안쪽 분류가 샜다: {msg}");
    132         assert!(msg.contains("깨진 링크"), "실제: {msg}");
    133     }
    134 
    135     /// exit code는 계약이다. 바꾸면 남의 CI 조건이 조용히 뜻을 바꾼다.
    136     #[test]
    137     fn codes_are_stable_and_distinct() {
    138         assert_eq!(
    139             ALL.iter().map(|k| k.code()).collect::<Vec<_>>(),
    140             vec![3, 4, 5, 6, 1],
    141             "exit code가 바뀌었다"
    142         );
    143         // clap이 사용법 오류에 2를 쓰므로 우리는 2를 쓰지 않는다.
    144         assert!(!ALL.iter().any(|k| k.code() == 2));
    145         // 식별자도 계약이다. 문서와 사용자 스크립트가 문자열로 잡는다.
    146         let ids: Vec<&str> = ALL.iter().map(|k| k.id()).collect();
    147         assert_eq!(
    148             ids,
    149             vec![
    150                 "SQZASS_E_CONFIG",
    151                 "SQZASS_E_CONTENT",
    152                 "SQZASS_E_TEMPLATE",
    153                 "SQZASS_E_IO",
    154                 "SQZASS_E"
    155             ]
    156         );
    157     }
    158 }