Skip to main content

guicons_cli/
check.rs

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
8/// Loads `manifest_path` and turns every [`guicons_core::ManifestError`]
9/// (real errors - manifest *shape* is wrong) plus two filesystem-level
10/// checks `guicons_core` itself deliberately doesn't do (it only validates
11/// shape, not what fields point to) into pretty-printable [`miette::Report`]s:
12///
13/// - a `file` source pointing at a path that doesn't exist - a real error,
14///   since nothing will ever be able to load it.
15/// - an `iconify = "provider:name"` source with no cached SVG on disk yet -
16///   only *advice* (informational, not a warning/error), since resolving it
17///   for real needs network access (`icons fetch`) that `check` deliberately
18///   never does itself; not being cached yet doesn't mean the id is wrong,
19///   just unconfirmed.
20///
21/// `windows-ico` paths are not checked.
22///
23/// Returns the number of entries that parsed successfully alongside the
24/// reports (empty if the manifest is fully valid, though it may still
25/// contain advice-level notes - see [`Severity`] on each report).
26pub 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
62/// `None` if `id`'s cached SVG is already on disk (nothing to warn about) -
63/// otherwise a human-readable reason it can't be confirmed to resolve yet.
64fn 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
100/// Relative to the current directory when possible (both shorter and
101/// free of Windows' `\\?\` verbatim-path prefix, which `canonicalize`
102/// adds and which `Path::display()` otherwise shows verbatim). Falls back
103/// to the absolute path (still with the `\\?\` prefix stripped) when
104/// `path` isn't under the current directory at all.
105fn 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        // Deliberately never created.
149        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}