Skip to main content

guicons_cli/
check.rs

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
9/// Loads `manifest_path` and turns every [`guicons_core::ManifestError`]
10/// (real errors - manifest *shape* is wrong) plus two filesystem-level
11/// checks `guicons_core` itself deliberately doesn't do (it only validates
12/// shape, not what fields point to) into pretty-printable [`miette::Report`]s:
13///
14/// - a `file` source pointing at a path that doesn't exist - a real error,
15///   since nothing will ever be able to load it.
16/// - an `iconify`/`url` source with no cached copy on disk yet - only
17///   *advice*, since resolving it for real needs network access
18///   (`icons fetch`) that `check` deliberately never does itself.
19/// - a cached copy that doesn't match `icons.lock` - an error; one that
20///   isn't in an existing `icons.lock` yet - advice.
21///
22/// `windows-ico` paths are not checked.
23///
24/// Returns the number of entries that parsed successfully alongside the
25/// reports (empty if the manifest is fully valid, though it may still
26/// contain advice-level notes - see [`Severity`] on each report).
27pub 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
86/// Why a remote icon can't be confirmed from the cache and `icons.lock`,
87/// or `None` if it can.
88fn 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
139/// Relative to the current directory when possible (both shorter and
140/// free of Windows' `\\?\` verbatim-path prefix, which `canonicalize`
141/// adds and which `Path::display()` otherwise shows verbatim). Falls back
142/// to the absolute path (still with the `\\?\` prefix stripped) when
143/// `path` isn't under the current directory at all.
144fn 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        // Deliberately never created.
188        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}