1use guicons_core::{IconEntrySource, IconManifest};
2use guicons_net::{Lock, RemoteIcon};
3use miette::{LabeledSpan, MietteDiagnostic, NamedSource, Report, Severity};
4use std::collections::HashMap;
5use std::fs;
6use std::ops::Range;
7use std::path::{Path, PathBuf};
8
9pub fn check(manifest_path: &Path) -> (usize, Vec<Report>) {
28 let (manifest, errors) = guicons_core::load_icon_manifest(manifest_path);
29 let mut source_cache: HashMap<PathBuf, String> = HashMap::new();
30
31 let mut reports: Vec<Report> = errors
32 .iter()
33 .map(|error| build_report(&error.file, error.span.clone(), error.message.clone(), Severity::Error, &mut source_cache))
34 .collect();
35 let lock = load_lock(&manifest);
36
37 for entry in manifest.entries() {
38 match entry.source() {
39 IconEntrySource::File(path) if !path.exists() => {
40 reports.push(build_report(
41 entry.file(),
42 Some(entry.span()),
43 format!(
44 "icon manifest entry `{}` has a `file` source that doesn't exist: `{}`",
45 entry.key(),
46 display_path(path)
47 ),
48 Severity::Error,
49 &mut source_cache,
50 ));
51 }
52 IconEntrySource::Iconify(id) => {
53 if let Some((message, severity)) = remote_problem(&manifest, &lock, RemoteIcon::Iconify(id)) {
54 reports.push(build_report(entry.file(), Some(entry.span()), message, severity, &mut source_cache));
55 }
56 }
57 IconEntrySource::Url(url) => {
58 if let Some((message, severity)) = remote_problem(&manifest, &lock, RemoteIcon::Url(url)) {
59 reports.push(build_report(entry.file(), Some(entry.span()), message, severity, &mut source_cache));
60 }
61 }
62 _ => {}
63 }
64 }
65
66 (manifest.entries().len(), reports)
67}
68
69enum LockState {
70 Missing,
71 Present(Lock),
72 Unreadable(String),
73}
74
75fn load_lock(manifest: &IconManifest) -> LockState {
76 let path = guicons_net::lock_path(manifest);
77 if !path.exists() {
78 return LockState::Missing;
79 }
80 match Lock::load(&path) {
81 Ok(lock) => LockState::Present(lock),
82 Err(e) => LockState::Unreadable(e.to_string()),
83 }
84}
85
86fn remote_problem(manifest: &IconManifest, lock: &LockState, icon: RemoteIcon<'_>) -> Option<(String, Severity)> {
89 let label = icon.label();
90 let cache_path = match icon.cache_path(&guicons_net::cache_dir(manifest)) {
91 Ok(path) => path,
92 Err(e) => return Some((format!("{e} - it will never resolve"), Severity::Advice)),
93 };
94 let Ok(bytes) = fs::read(&cache_path) else {
95 return Some((
96 format!(
97 "`{label}` isn't cached locally yet, so `check` can't confirm it resolves - run `icons fetch` (or set `GUICONS_ALLOW_NETWORK=1`) to fetch and verify it"
98 ),
99 Severity::Advice,
100 ));
101 };
102 match lock {
103 LockState::Missing => None,
104 LockState::Unreadable(e) => Some((e.clone(), Severity::Error)),
105 LockState::Present(lock) => match (lock.get(icon), lock.verify(icon, &bytes)) {
106 (_, Err(e)) => Some((e.to_string(), Severity::Error)),
107 (None, Ok(())) => Some((
108 format!("`{label}` isn't in {} yet - run `icons fetch` to lock it", guicons_net::LOCK_FILE),
109 Severity::Advice,
110 )),
111 (Some(_), Ok(())) => None,
112 },
113 }
114}
115
116fn build_report(
117 file: &Path,
118 span: Option<Range<usize>>,
119 message: String,
120 severity: Severity,
121 source_cache: &mut HashMap<PathBuf, String>,
122) -> Report {
123 let mut diagnostic = MietteDiagnostic::new(message).with_severity(severity);
124 if let Some(span) = &span {
125 diagnostic = diagnostic.with_label(LabeledSpan::at(span.start..span.end, "here"));
126 }
127
128 let mut report = Report::new(diagnostic);
129 if span.is_some() {
130 let source = source_cache
131 .entry(file.to_path_buf())
132 .or_insert_with(|| fs::read_to_string(file).unwrap_or_default())
133 .clone();
134 report = report.with_source_code(NamedSource::new(display_path(file), source));
135 }
136 report
137}
138
139fn display_path(path: &Path) -> String {
145 let relative = std::env::current_dir()
146 .ok()
147 .map(|cwd| fs::canonicalize(&cwd).unwrap_or(cwd))
148 .and_then(|cwd| path.strip_prefix(&cwd).ok().map(Path::to_path_buf));
149 let rendered = relative.as_deref().unwrap_or(path).display().to_string();
150 rendered.strip_prefix(r"\\?\").unwrap_or(&rendered).to_string()
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use tempfile::tempdir;
157
158 #[test]
159 fn check_reports_zero_errors_for_a_valid_manifest() {
160 let dir = tempdir().unwrap();
161 fs::write(dir.path().join("docker.svg"), "<svg/>").unwrap();
162 let path = dir.path().join("icons.gui.toml");
163 fs::write(&path, "[docker]\nfile = \"docker.svg\"\n").unwrap();
164
165 let (entry_count, reports) = check(&path);
166 assert_eq!(entry_count, 1);
167 assert!(reports.is_empty());
168 }
169
170 #[test]
171 fn check_reports_a_pretty_diagnostic_for_an_unknown_field() {
172 let dir = tempdir().unwrap();
173 let path = dir.path().join("icons.gui.toml");
174 fs::write(&path, "[docker]\nfile = \"docker.svg\"\nfile1 = \"docker.svg\"\n").unwrap();
175
176 let (_, reports) = check(&path);
177 assert_eq!(reports.len(), 1);
178 let rendered = format!("{:?}", reports[0]);
179 assert!(rendered.contains("unexpected field"), "{rendered}");
180 assert!(rendered.contains("file1"), "{rendered}");
181 }
182
183 #[test]
184 fn check_reports_an_error_for_a_file_source_pointing_at_a_nonexistent_asset() {
185 let dir = tempdir().unwrap();
186 let path = dir.path().join("icons.gui.toml");
187 fs::write(&path, "[docker]\nfile = \"does-not-exist.svg\"\n").unwrap();
189
190 let (entry_count, reports) = check(&path);
191 assert_eq!(entry_count, 1);
192 assert_eq!(reports.len(), 1);
193 assert_eq!(reports[0].severity(), Some(Severity::Error));
194 let rendered = format!("{:?}", reports[0]);
195 assert!(rendered.contains("does-not-exist.svg"), "{rendered}");
196 }
197
198 #[test]
199 fn check_reports_advice_for_an_iconify_id_not_yet_cached() {
200 let dir = tempdir().unwrap();
201 let path = dir.path().join("icons.gui.toml");
202 fs::write(&path, "[docker]\niconify = \"mdi:home\"\n").unwrap();
203
204 let (entry_count, reports) = check(&path);
205 assert_eq!(entry_count, 1);
206 assert_eq!(reports.len(), 1);
207 assert_eq!(reports[0].severity(), Some(Severity::Advice));
208 let rendered = format!("{:?}", reports[0]);
209 assert!(rendered.contains("mdi:home"), "{rendered}");
210 assert!(rendered.contains("isn't cached locally"), "{rendered}");
211 }
212
213 #[test]
214 fn check_reports_nothing_for_an_iconify_id_already_cached() {
215 let dir = tempdir().unwrap();
216 let path = dir.path().join("icons.gui.toml");
217 fs::write(&path, "[docker]\niconify = \"mdi:home\"\n").unwrap();
218 let cache_path = dir.path().join(".cache/guicons/mdi/home.svg");
219 fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
220 fs::write(&cache_path, "<svg/>").unwrap();
221
222 let (_, reports) = check(&path);
223 assert!(reports.is_empty(), "{reports:?}");
224 }
225
226 #[test]
227 fn check_reports_an_error_for_a_cached_icon_that_differs_from_the_lock() {
228 let dir = tempdir().unwrap();
229 let path = dir.path().join("icons.gui.toml");
230 fs::write(&path, "[docker]\niconify = \"mdi:home\"\n").unwrap();
231 let cache_path = dir.path().join(".cache/guicons/mdi/home.svg");
232 fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
233 fs::write(&cache_path, "<svg>tampered</svg>").unwrap();
234 let mut lock = Lock::default();
235 lock.set(RemoteIcon::Iconify("mdi:home"), guicons_net::sha256_hex(b"<svg/>"));
236 lock.save(&dir.path().join(guicons_net::LOCK_FILE)).unwrap();
237
238 let (_, reports) = check(&path);
239 assert_eq!(reports.len(), 1, "{reports:?}");
240 assert_eq!(reports[0].severity(), Some(Severity::Error));
241 assert!(format!("{:?}", reports[0]).contains("doesn't match"));
242 }
243}