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 }