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