1use guicons_core::{IconEntrySource, IconManifest};
2use miette::{LabeledSpan, MietteDiagnostic, NamedSource, Report, Severity};
3use std::collections::HashMap;
4use std::fs;
5use std::ops::Range;
6use std::path::{Path, PathBuf};
7
8pub fn check(manifest_path: &Path) -> (usize, Vec<Report>) {
27 let (manifest, errors) = guicons_core::load_icon_manifest(manifest_path);
28 let mut source_cache: HashMap<PathBuf, String> = HashMap::new();
29
30 let mut reports: Vec<Report> = errors
31 .iter()
32 .map(|error| build_report(&error.file, error.span.clone(), error.message.clone(), Severity::Error, &mut source_cache))
33 .collect();
34
35 for entry in manifest.entries() {
36 match entry.source() {
37 IconEntrySource::File(path) if !path.exists() => {
38 reports.push(build_report(
39 entry.file(),
40 Some(entry.span()),
41 format!(
42 "icon manifest entry `{}` has a `file` source that doesn't exist: `{}`",
43 entry.key(),
44 display_path(path)
45 ),
46 Severity::Error,
47 &mut source_cache,
48 ));
49 }
50 IconEntrySource::Iconify(id) => {
51 if let Some(message) = unresolved_iconify_message(&manifest, id) {
52 reports.push(build_report(entry.file(), Some(entry.span()), message, Severity::Advice, &mut source_cache));
53 }
54 }
55 _ => {}
56 }
57 }
58
59 (manifest.entries().len(), reports)
60}
61
62fn unresolved_iconify_message(manifest: &IconManifest, id: &str) -> Option<String> {
65 if id.split_once(':').is_none() {
66 return Some(format!("iconify id `{id}` isn't in `provider:name` form - it will never resolve"));
67 }
68 let cache_path = guicons_net::iconify_cache_path(manifest.workspace_root(), id);
69 if cache_path.exists() {
70 return None;
71 }
72 Some(format!(
73 "iconify icon `{id}` 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"
74 ))
75}
76
77fn build_report(
78 file: &Path,
79 span: Option<Range<usize>>,
80 message: String,
81 severity: Severity,
82 source_cache: &mut HashMap<PathBuf, String>,
83) -> Report {
84 let mut diagnostic = MietteDiagnostic::new(message).with_severity(severity);
85 if let Some(span) = &span {
86 diagnostic = diagnostic.with_label(LabeledSpan::at(span.start..span.end, "here"));
87 }
88
89 let mut report = Report::new(diagnostic);
90 if span.is_some() {
91 let source = source_cache
92 .entry(file.to_path_buf())
93 .or_insert_with(|| fs::read_to_string(file).unwrap_or_default())
94 .clone();
95 report = report.with_source_code(NamedSource::new(display_path(file), source));
96 }
97 report
98}
99
100fn display_path(path: &Path) -> String {
106 let relative = std::env::current_dir()
107 .ok()
108 .map(|cwd| fs::canonicalize(&cwd).unwrap_or(cwd))
109 .and_then(|cwd| path.strip_prefix(&cwd).ok().map(Path::to_path_buf));
110 let rendered = relative.as_deref().unwrap_or(path).display().to_string();
111 rendered.strip_prefix(r"\\?\").unwrap_or(&rendered).to_string()
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use tempfile::tempdir;
118
119 #[test]
120 fn check_reports_zero_errors_for_a_valid_manifest() {
121 let dir = tempdir().unwrap();
122 fs::write(dir.path().join("docker.svg"), "<svg/>").unwrap();
123 let path = dir.path().join("icons.gui.toml");
124 fs::write(&path, "[docker]\nfile = \"docker.svg\"\n").unwrap();
125
126 let (entry_count, reports) = check(&path);
127 assert_eq!(entry_count, 1);
128 assert!(reports.is_empty());
129 }
130
131 #[test]
132 fn check_reports_a_pretty_diagnostic_for_an_unknown_field() {
133 let dir = tempdir().unwrap();
134 let path = dir.path().join("icons.gui.toml");
135 fs::write(&path, "[docker]\nfile = \"docker.svg\"\nfile1 = \"docker.svg\"\n").unwrap();
136
137 let (_, reports) = check(&path);
138 assert_eq!(reports.len(), 1);
139 let rendered = format!("{:?}", reports[0]);
140 assert!(rendered.contains("unexpected field"), "{rendered}");
141 assert!(rendered.contains("file1"), "{rendered}");
142 }
143
144 #[test]
145 fn check_reports_an_error_for_a_file_source_pointing_at_a_nonexistent_asset() {
146 let dir = tempdir().unwrap();
147 let path = dir.path().join("icons.gui.toml");
148 fs::write(&path, "[docker]\nfile = \"does-not-exist.svg\"\n").unwrap();
150
151 let (entry_count, reports) = check(&path);
152 assert_eq!(entry_count, 1);
153 assert_eq!(reports.len(), 1);
154 assert_eq!(reports[0].severity(), Some(Severity::Error));
155 let rendered = format!("{:?}", reports[0]);
156 assert!(rendered.contains("does-not-exist.svg"), "{rendered}");
157 }
158
159 #[test]
160 fn check_reports_advice_for_an_iconify_id_not_yet_cached() {
161 let dir = tempdir().unwrap();
162 let path = dir.path().join("icons.gui.toml");
163 fs::write(&path, "[docker]\niconify = \"mdi:home\"\n").unwrap();
164
165 let (entry_count, reports) = check(&path);
166 assert_eq!(entry_count, 1);
167 assert_eq!(reports.len(), 1);
168 assert_eq!(reports[0].severity(), Some(Severity::Advice));
169 let rendered = format!("{:?}", reports[0]);
170 assert!(rendered.contains("mdi:home"), "{rendered}");
171 assert!(rendered.contains("isn't cached locally"), "{rendered}");
172 }
173
174 #[test]
175 fn check_reports_nothing_for_an_iconify_id_already_cached() {
176 let dir = tempdir().unwrap();
177 let path = dir.path().join("icons.gui.toml");
178 fs::write(&path, "[docker]\niconify = \"mdi:home\"\n").unwrap();
179 let cache_path = dir.path().join(".cache/guicons/mdi/home.svg");
180 fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
181 fs::write(&cache_path, "<svg/>").unwrap();
182
183 let (_, reports) = check(&path);
184 assert!(reports.is_empty(), "{reports:?}");
185 }
186}