src/doctor.rs (13313B)
1 //! `sqzass doctor` — 빌드가 통과시키지만 사람이 알아야 하는 것들.
2 //!
3 //! **빌드가 이미 잡는 건 여기서 다시 잡지 않는다.** 설정 오타, front matter 오타,
4 //! URL 충돌, 해석 안 되는 `@/` 링크, 없는 템플릿·에셋·번역 키는 전부 빌드가 멈추는
5 //! 사유다. 그걸 doctor가 또 보고하면 두 명령이 같은 말을 하게 되고, 그중 하나는
6 //! 언젠가 낡는다.
7 //!
8 //! 남는 건 **틀리지는 않았지만 의도한 게 아닐 가능성이 있는 것들**이다. 그래서
9 //! 경고이고, 그래서 게이팅 기준을 고를 수 있어야 한다.
10
11 use crate::config::Config;
12 use crate::content::Page;
13 use crate::render::Templates;
14 use crate::site::Site;
15 use anyhow::Result;
16 use serde::Serialize;
17 use std::collections::{BTreeMap, BTreeSet};
18 use std::path::Path;
19
20 /// 찾은 게 있을 때의 exit code. `Kind`의 번호들과 겹치지 않는다 — doctor의 지적은
21 /// 빌드 실패와 다른 종류의 사건이고, CI가 둘을 구분할 수 있어야 한다.
22 pub const FINDINGS_EXIT_CODE: i32 = 7;
23
24 /// `init`이 넣어 주는 자리표시자. 이대로 배포하면 canonical URL과 sitemap이
25 /// 남의 도메인을 가리킨다.
26 const PLACEHOLDER_BASE_URL: &str = "https://example.com";
27
28 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
29 #[serde(rename_all = "lowercase")]
30 pub enum Severity {
31 /// 알아 두면 좋은 것. 기본 게이트는 여기서 걸리지 않는다.
32 Note,
33 /// 의도한 게 아닐 가능성이 높은 것.
34 Warn,
35 }
36
37 impl std::fmt::Display for Severity {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 f.write_str(match self {
40 Self::Note => "note",
41 Self::Warn => "warn",
42 })
43 }
44 }
45
46 impl std::str::FromStr for Severity {
47 type Err = String;
48 fn from_str(s: &str) -> std::result::Result<Self, String> {
49 match s {
50 "note" => Ok(Self::Note),
51 "warn" => Ok(Self::Warn),
52 other => Err(format!("알 수 없는 심각도 '{other}' (note | warn)")),
53 }
54 }
55 }
56
57 #[derive(Debug, Clone, Serialize)]
58 pub struct Finding {
59 pub severity: Severity,
60 /// 검사 이름. 안정된 문자열이라 스크립트가 잡아도 된다.
61 pub check: &'static str,
62 pub message: String,
63 /// 관련된 소스 파일이 있으면.
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub file: Option<String>,
66 }
67
68 impl std::fmt::Display for Finding {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 write!(f, "{}: [{}] {}", self.severity, self.check, self.message)?;
71 if let Some(file) = &self.file {
72 write!(f, " ({file})")?;
73 }
74 Ok(())
75 }
76 }
77
78 /// 사이트를 읽고 지적할 것들을 모은다. 아무것도 쓰지 않는다.
79 pub fn run(root: &Path, cfg: &Config, pages: &[Page], site: &Site) -> Result<Vec<Finding>> {
80 let mut out = Vec::new();
81
82 placeholder_base_url(cfg, &mut out);
83 untranslated_pages(cfg, pages, site, &mut out);
84 missing_descriptions(pages, &mut out);
85 drafts(root, cfg, &mut out)?;
86 empty_sections(cfg, site, &mut out);
87 unused_templates(root, pages, site, &mut out)?;
88
89 // 심각도가 높은 것부터, 같으면 검사 이름순. 출력 순서가 빌드처럼 결정적이어야
90 // CI 로그를 diff 할 수 있다.
91 out.sort_by(|a, b| {
92 b.severity
93 .cmp(&a.severity)
94 .then_with(|| a.check.cmp(b.check))
95 .then_with(|| a.message.cmp(&b.message))
96 });
97 Ok(out)
98 }
99
100 fn placeholder_base_url(cfg: &Config, out: &mut Vec<Finding>) {
101 if cfg.base_url_trimmed() == PLACEHOLDER_BASE_URL {
102 out.push(Finding {
103 severity: Severity::Warn,
104 check: "base-url",
105 message: format!(
106 "base_url이 아직 `{PLACEHOLDER_BASE_URL}` 입니다. \
107 canonical URL과 sitemap이 이 주소로 나갑니다."
108 ),
109 file: Some("sqzass.toml".into()),
110 });
111 }
112 }
113
114 /// 어떤 언어에는 있고 어떤 언어에는 없는 페이지. 빌드는 이걸 통과시킨다 —
115 /// 미번역 페이지를 감추는 건 의도된 동작이다. 다만 감췄다는 사실은 알아야 한다.
116 fn untranslated_pages(cfg: &Config, pages: &[Page], site: &Site, out: &mut Vec<Finding>) {
117 if cfg.languages.len() < 2 {
118 return;
119 }
120 let declared: BTreeSet<&str> = cfg.languages.keys().map(String::as_str).collect();
121
122 let mut seen: BTreeSet<&str> = BTreeSet::new();
123 for page in pages {
124 if !seen.insert(&page.translation_key) {
125 continue;
126 }
127 let have: BTreeSet<String> = site
128 .language_set_of(page)
129 .into_iter()
130 .map(|(code, _)| code)
131 .collect();
132 let missing: Vec<&str> = declared
133 .iter()
134 .filter(|code| !have.contains(**code))
135 .copied()
136 .collect();
137 if !missing.is_empty() {
138 out.push(Finding {
139 severity: Severity::Warn,
140 check: "untranslated",
141 message: format!("{} 번역이 없습니다", missing.join(", ")),
142 file: Some(page.rel.to_string_lossy().into_owned()),
143 });
144 }
145 }
146 }
147
148 fn missing_descriptions(pages: &[Page], out: &mut Vec<Finding>) {
149 for page in pages {
150 if page.front.description.trim().is_empty() {
151 out.push(Finding {
152 severity: Severity::Note,
153 check: "description",
154 message: "description이 없습니다. 검색 결과와 <meta>에 쓰입니다.".into(),
155 file: Some(page.rel.to_string_lossy().into_owned()),
156 });
157 }
158 }
159 }
160
161 /// 드래프트는 기본 빌드에서 빠지므로, 사이트에 없는 이유가 여기 있다.
162 fn drafts(root: &Path, cfg: &Config, out: &mut Vec<Finding>) -> Result<()> {
163 let all = crate::content::discover(root, cfg, true)?;
164 for page in all.iter().filter(|p| p.front.draft) {
165 out.push(Finding {
166 severity: Severity::Note,
167 check: "draft",
168 message: "draft = true 이므로 빌드에 포함되지 않습니다".into(),
169 file: Some(page.rel.to_string_lossy().into_owned()),
170 });
171 }
172 Ok(())
173 }
174
175 /// 자식도 본문도 없는 섹션. 내비게이션에 들어가지만 눌러도 아무것도 없다.
176 ///
177 /// 자식이 없어도 `_index.md`에 본문이 있으면 경고하지 않는다 — 눌렀을 때
178 /// 보이는 건 빈 목록이 아니라 그 본문이고, 단일 페이지 섹션은 정당한 구조다.
179 /// 이 규칙이 잡으려는 건 만들다 만 디렉터리다.
180 fn empty_sections(cfg: &Config, site: &Site, out: &mut Vec<Finding>) {
181 for lang in cfg.languages.keys() {
182 let mut stack: Vec<&crate::site::Section> = site.sections(lang).iter().collect();
183 while let Some(section) = stack.pop() {
184 let index_has_body = section
185 .index
186 .map(|i| !site.pages[i].body.trim().is_empty())
187 .unwrap_or(false);
188 if section.pages.is_empty() && section.subsections.is_empty() && !index_has_body {
189 out.push(Finding {
190 severity: Severity::Warn,
191 check: "empty-section",
192 message: format!("'{}' 섹션에 페이지도 본문도 없습니다", section.title),
193 file: Some(section.url.clone()),
194 });
195 }
196 stack.extend(section.subsections.iter());
197 }
198 }
199 }
200
201 /// 어떤 페이지도 고르지 않은 템플릿. 이름을 바꾸고 참조를 안 고쳤을 때 남는다.
202 fn unused_templates(
203 root: &Path,
204 pages: &[Page],
205 site: &Site,
206 out: &mut Vec<Finding>,
207 ) -> Result<()> {
208 let templates = Templates::load(root)?;
209 let mut used: BTreeSet<String> = BTreeSet::new();
210 // 페이지가 고르는 게 아니라 빌드가 직접 렌더하는 템플릿. 이걸 빼면 린트가
211 // 쓰이고 있는 파일을 지우라고 권하게 된다.
212 used.insert(crate::emit::NOT_FOUND_PATH.to_string());
213 for page in pages {
214 if let Ok(name) = crate::select_template(page, site, &templates) {
215 used.insert(name);
216 }
217 }
218
219 // `include`/`extends`로 끌려 들어가는 것들은 페이지가 직접 고르지 않는다.
220 // 그것까지 미사용이라고 하면 이 검사는 매번 틀린 말을 하게 된다.
221 let referenced = referenced_templates(root, &templates)?;
222
223 for name in templates.names() {
224 if !used.contains(name) && !referenced.contains(name) {
225 out.push(Finding {
226 severity: Severity::Note,
227 check: "unused-template",
228 message: "어떤 페이지도 이 템플릿을 고르지 않았습니다".into(),
229 file: Some(format!("templates/{name}")),
230 });
231 }
232 }
233 Ok(())
234 }
235
236 /// 다른 템플릿이 이름으로 언급하는 템플릿들.
237 ///
238 /// 문자열 포함 검사다. 정확한 파서가 아니지만, 여기서 틀리는 방향은 **미사용이라고
239 /// 말하지 않는 쪽**이다 — 지우라고 잘못 권하는 것보다 조용한 게 낫다.
240 fn referenced_templates(root: &Path, templates: &Templates) -> Result<BTreeSet<String>> {
241 let dir = root.join(crate::render::TEMPLATE_DIR);
242 let mut sources = BTreeMap::new();
243 for name in templates.names() {
244 if let Ok(text) = std::fs::read_to_string(dir.join(name)) {
245 sources.insert(name.clone(), text);
246 }
247 }
248
249 let mut out = BTreeSet::new();
250 for name in templates.names() {
251 let quoted = format!("\"{name}\"");
252 if sources
253 .iter()
254 .any(|(owner, text)| owner != name && text.contains("ed))
255 {
256 out.insert(name.clone());
257 }
258 }
259 Ok(out)
260 }
261
262 #[cfg(test)]
263 mod tests {
264 use super::*;
265
266 #[test]
267 fn severity_orders_warn_above_note() {
268 assert!(Severity::Warn > Severity::Note);
269 }
270
271 #[test]
272 fn severity_parses_and_rejects_nonsense() {
273 assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
274 assert!("loud".parse::<Severity>().is_err());
275 }
276
277 #[test]
278 fn a_section_with_a_contentful_index_is_not_empty() {
279 // 단일 페이지 섹션(자식 없음, _index에 본문 있음)은 정당한 구조다.
280 // 본문까지 없는 섹션만 만들다 만 디렉터리로 잡는다.
281 let dir = std::env::temp_dir().join(format!("sqzass-doctor-empty-{}", std::process::id()));
282 let _ = std::fs::remove_dir_all(&dir);
283 for (d, body) in [("full", "충실한 본문이 있다."), ("bare", "")] {
284 let p = dir.join("content").join(d);
285 std::fs::create_dir_all(&p).unwrap();
286 std::fs::write(
287 p.join("_index.md"),
288 format!("+++\ntitle = \"{d}\"\n+++\n{body}"),
289 )
290 .unwrap();
291 }
292 let cfg: Config = toml::from_str(
293 "title = \"t\"\nbase_url = \"https://x.dev\"\n[languages.en]\nname = \"English\"\n",
294 )
295 .unwrap();
296 let pages = crate::content::discover(&dir, &cfg, false).unwrap();
297 let site = Site::build(&pages, &cfg);
298 let mut out = Vec::new();
299 empty_sections(&cfg, &site, &mut out);
300 let flagged: Vec<_> = out.iter().filter(|f| f.check == "empty-section").collect();
301 assert_eq!(flagged.len(), 1, "{out:?}");
302 assert_eq!(flagged[0].file.as_deref(), Some("/bare/"));
303 let _ = std::fs::remove_dir_all(&dir);
304 }
305
306 #[test]
307 fn placeholder_base_url_is_reported() {
308 let cfg: Config =
309 toml::from_str("title = \"t\"\nbase_url = \"https://example.com/\"\n").unwrap();
310 let mut out = Vec::new();
311 placeholder_base_url(&cfg, &mut out);
312 assert_eq!(out.len(), 1, "{out:?}");
313 assert_eq!(out[0].check, "base-url");
314 }
315
316 #[test]
317 fn a_real_base_url_is_not_reported() {
318 let cfg: Config =
319 toml::from_str("title = \"t\"\nbase_url = \"https://sqzass.sqzer.com\"\n").unwrap();
320 let mut out = Vec::new();
321 placeholder_base_url(&cfg, &mut out);
322 assert!(out.is_empty(), "{out:?}");
323 }
324
325 #[test]
326 fn findings_render_with_check_and_file() {
327 let f = Finding {
328 severity: Severity::Warn,
329 check: "untranslated",
330 message: "ko 번역이 없습니다".into(),
331 file: Some("start/x.md".into()),
332 };
333 assert_eq!(
334 f.to_string(),
335 "warn: [untranslated] ko 번역이 없습니다 (start/x.md)"
336 );
337 }
338
339 /// doctor의 exit code는 빌드 실패의 코드들과 겹치면 안 된다. 겹치면 CI가
340 /// "지적이 있었다"와 "빌드가 깨졌다"를 구분할 수 없다.
341 #[test]
342 fn findings_exit_code_is_distinct_from_error_kinds() {
343 use crate::error::Kind;
344 for k in [
345 Kind::Config,
346 Kind::Content,
347 Kind::Template,
348 Kind::Io,
349 Kind::Other,
350 ] {
351 assert_ne!(k.code(), FINDINGS_EXIT_CODE);
352 }
353 }
354 }