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` is deliberately not checked here (narrower, Windows-only
22/// concern; left to `guicons-lsp`'s existing editor-side check).
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
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
63/// `None` if `id`'s cached SVG is already on disk (nothing to warn about) -
64/// otherwise a human-readable reason it can't be confirmed to resolve yet.
65fn 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
101/// Relative to the current directory when possible (both shorter and
102/// free of Windows' `\\?\` verbatim-path prefix, which `canonicalize`
103/// adds and which `Path::display()` otherwise shows verbatim). Falls back
104/// to the absolute path (still with the `\\?\` prefix stripped) when
105/// `path` isn't under the current directory at all.
106fn 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        // Deliberately never created.
150        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}