Skip to main content

asimov_module_kit/module/
lint.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Consistency checks for an existing module directory.
4
5use alloc::{format, string::String, vec::Vec};
6use asimov_module::ModuleManifest;
7use std::{
8    fs, io,
9    path::{Path, PathBuf},
10};
11use thiserror::Error;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Severity {
15    Error,
16    Warning,
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum LintCode {
21    /// No `.asimov/module.yaml` found.
22    MissingManifest,
23    /// `provides.programs` lists a program with no matching `[[bin]]`.
24    ProgramNotInCargoToml,
25    /// A `[[bin]]` is absent from `provides.programs`.
26    BinNotInManifest,
27    /// A program source file is unreferenced by any active `[[bin]]`.
28    OrphanProgramSource,
29    /// `handles:` is present but every field is empty.
30    EmptyHandles,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct LintFinding {
35    pub severity: Severity,
36    pub code: LintCode,
37    pub message: String,
38    pub path: Option<PathBuf>,
39}
40
41#[derive(Clone, Debug)]
42pub struct LintOptions {
43    pub module_dir: PathBuf,
44}
45
46impl LintOptions {
47    pub fn new(module_dir: impl Into<PathBuf>) -> Self {
48        Self {
49            module_dir: module_dir.into(),
50        }
51    }
52}
53
54#[derive(Debug, Error)]
55pub enum LintError {
56    #[error("not a module (no Cargo.toml found): {0}")]
57    NotAModule(PathBuf),
58
59    #[error("failed to parse `{0}`: {1}")]
60    CargoToml(PathBuf, #[source] toml::de::Error),
61
62    #[error("failed to parse `{0}`: {1}")]
63    Manifest(PathBuf, #[source] serde_yaml_ng::Error),
64
65    #[error(transparent)]
66    Io(#[from] io::Error),
67}
68
69#[derive(serde::Deserialize)]
70struct CargoManifest {
71    #[serde(default)]
72    bin: Vec<CargoBin>,
73}
74
75#[derive(serde::Deserialize)]
76struct CargoBin {
77    name: String,
78    path: Option<String>,
79}
80
81pub fn lint_module(options: LintOptions) -> Result<Vec<LintFinding>, LintError> {
82    let module_dir = &options.module_dir;
83    let cargo_toml_path = module_dir.join("Cargo.toml");
84    if !cargo_toml_path.exists() {
85        return Err(LintError::NotAModule(module_dir.clone()));
86    }
87
88    let cargo_toml: CargoManifest = toml::from_str(&fs::read_to_string(&cargo_toml_path)?)
89        .map_err(|err| LintError::CargoToml(cargo_toml_path.clone(), err))?;
90
91    let mut findings = Vec::new();
92
93    let manifest_path = module_dir.join(".asimov/module.yaml");
94    let manifest = if !manifest_path.exists() {
95        findings.push(LintFinding {
96            severity: Severity::Warning,
97            code: LintCode::MissingManifest,
98            message: "no `.asimov/module.yaml` found".into(),
99            path: Some(manifest_path.clone()),
100        });
101        None
102    } else {
103        let manifest: ModuleManifest =
104            serde_yaml_ng::from_str(&fs::read_to_string(&manifest_path)?)
105                .map_err(|err| LintError::Manifest(manifest_path.clone(), err))?;
106        Some(manifest)
107    };
108
109    if let Some(manifest) = &manifest {
110        for program in &manifest.provides.programs {
111            if !cargo_toml.bin.iter().any(|bin| &bin.name == program) {
112                findings.push(LintFinding {
113                    severity: Severity::Error,
114                    code: LintCode::ProgramNotInCargoToml,
115                    message: format!(
116                        "`provides.programs` lists `{program}`, but no matching `[[bin]]` was found in Cargo.toml"
117                    ),
118                    path: Some(manifest_path.clone()),
119                });
120            }
121        }
122
123        for bin in &cargo_toml.bin {
124            if !manifest.provides.programs.iter().any(|p| p == &bin.name) {
125                findings.push(LintFinding {
126                    severity: Severity::Error,
127                    code: LintCode::BinNotInManifest,
128                    message: format!(
129                        "`[[bin]]` `{}` is not listed in `provides.programs`",
130                        bin.name
131                    ),
132                    path: Some(cargo_toml_path.clone()),
133                });
134            }
135        }
136
137        if manifest.handles.is_empty() {
138            findings.push(LintFinding {
139                severity: Severity::Warning,
140                code: LintCode::EmptyHandles,
141                message: "`handles:` is present but every field is empty".into(),
142                path: Some(manifest_path.clone()),
143            });
144        }
145    }
146
147    let active_bin_paths: Vec<PathBuf> = cargo_toml
148        .bin
149        .iter()
150        .filter_map(|bin| match &bin.path {
151            Some(path) => Some(PathBuf::from(path)),
152            // Cargo's implicit convention for a `[[bin]]` without an
153            // explicit `path`: `src/bin/<name>.rs`, else `src/bin/<name>/main.rs`.
154            None => {
155                let flat = PathBuf::from(format!("src/bin/{}.rs", bin.name));
156                if module_dir.join(&flat).exists() {
157                    return Some(flat);
158                }
159                let nested = PathBuf::from(format!("src/bin/{}/main.rs", bin.name));
160                module_dir.join(&nested).exists().then_some(nested)
161            },
162        })
163        .collect();
164
165    for source in find_program_sources(&module_dir.join("src")) {
166        let relative = source
167            .strip_prefix(module_dir)
168            .unwrap_or(&source)
169            .to_path_buf();
170        if !active_bin_paths.contains(&relative) {
171            findings.push(LintFinding {
172                severity: Severity::Warning,
173                code: LintCode::OrphanProgramSource,
174                message: format!(
175                    "`{}` is not referenced by any active `[[bin]]`",
176                    relative.display()
177                ),
178                path: Some(source),
179            });
180        }
181    }
182
183    Ok(findings)
184}
185
186/// Finds `src/<kind>/main.rs` and `src/bin/*.rs` program source files.
187fn find_program_sources(src_dir: &Path) -> Vec<PathBuf> {
188    let Ok(entries) = fs::read_dir(src_dir) else {
189        return Vec::new();
190    };
191
192    let mut sources = Vec::new();
193    for entry in entries.flatten() {
194        let path = entry.path();
195        if !path.is_dir() {
196            continue;
197        }
198        if path.file_name().and_then(|n| n.to_str()) == Some("bin") {
199            let Ok(bin_entries) = fs::read_dir(&path) else {
200                continue;
201            };
202            for bin_entry in bin_entries.flatten() {
203                let bin_path = bin_entry.path();
204                if bin_path.is_dir() {
205                    // Nested convention: `src/bin/<name>/main.rs`.
206                    let main_rs = bin_path.join("main.rs");
207                    if main_rs.exists() {
208                        sources.push(main_rs);
209                    }
210                } else if bin_path.extension().and_then(|e| e.to_str()) == Some("rs") {
211                    sources.push(bin_path);
212                }
213            }
214        } else {
215            let main_rs = path.join("main.rs");
216            if main_rs.exists() {
217                sources.push(main_rs);
218            }
219        }
220    }
221    sources
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use tempfile::tempdir;
228
229    fn write(dir: &Path, relative: &str, contents: &str) {
230        let path = dir.join(relative);
231        fs::create_dir_all(path.parent().unwrap()).unwrap();
232        fs::write(path, contents).unwrap();
233    }
234
235    fn clean_module(dir: &Path) {
236        write(
237            dir,
238            "Cargo.toml",
239            r#"
240[package]
241name = "asimov-widget-module"
242
243[[bin]]
244name = "asimov-widget-emitter"
245path = "src/emitter/main.rs"
246"#,
247        );
248        write(dir, "src/emitter/main.rs", "fn main() {}");
249        write(
250            dir,
251            ".asimov/module.yaml",
252            r#"
253name: widget
254provides:
255  programs:
256    - asimov-widget-emitter
257handles:
258  url_protocols:
259    - widget
260  url_prefixes:
261  url_patterns:
262  file_extensions:
263  content_types:
264"#,
265        );
266    }
267
268    #[test]
269    fn clean_module_has_no_findings() {
270        let dir = tempdir().unwrap();
271        clean_module(dir.path());
272
273        let findings = lint_module(LintOptions::new(dir.path())).unwrap();
274        assert!(findings.is_empty(), "unexpected findings: {findings:#?}");
275    }
276
277    #[test]
278    fn missing_manifest_is_a_warning() {
279        let dir = tempdir().unwrap();
280        clean_module(dir.path());
281        fs::remove_dir_all(dir.path().join(".asimov")).unwrap();
282
283        let findings = lint_module(LintOptions::new(dir.path())).unwrap();
284        assert_eq!(findings.len(), 1);
285        assert_eq!(findings[0].severity, Severity::Warning);
286        assert_eq!(findings[0].code, LintCode::MissingManifest);
287    }
288
289    #[test]
290    fn empty_handles_is_a_warning() {
291        let dir = tempdir().unwrap();
292        clean_module(dir.path());
293        write(
294            dir.path(),
295            ".asimov/module.yaml",
296            r#"
297name: widget
298provides:
299  programs:
300    - asimov-widget-emitter
301handles:
302  url_protocols:
303  url_prefixes:
304  url_patterns:
305  file_extensions:
306  content_types:
307"#,
308        );
309
310        let findings = lint_module(LintOptions::new(dir.path())).unwrap();
311        assert_eq!(findings.len(), 1);
312        assert_eq!(findings[0].severity, Severity::Warning);
313        assert_eq!(findings[0].code, LintCode::EmptyHandles);
314    }
315
316    #[test]
317    fn program_missing_from_cargo_toml_is_an_error() {
318        let dir = tempdir().unwrap();
319        clean_module(dir.path());
320        write(
321            dir.path(),
322            ".asimov/module.yaml",
323            r#"
324name: widget
325provides:
326  programs:
327    - asimov-widget-emitter
328    - asimov-widget-fetcher
329handles:
330  url_protocols:
331    - widget
332  url_prefixes:
333  url_patterns:
334  file_extensions:
335  content_types:
336"#,
337        );
338
339        let findings = lint_module(LintOptions::new(dir.path())).unwrap();
340        assert_eq!(findings.len(), 1);
341        assert_eq!(findings[0].severity, Severity::Error);
342        assert_eq!(findings[0].code, LintCode::ProgramNotInCargoToml);
343    }
344
345    #[test]
346    fn bin_missing_from_manifest_is_an_error() {
347        let dir = tempdir().unwrap();
348        clean_module(dir.path());
349        write(
350            dir.path(),
351            "Cargo.toml",
352            r#"
353[package]
354name = "asimov-widget-module"
355
356[[bin]]
357name = "asimov-widget-emitter"
358path = "src/emitter/main.rs"
359
360[[bin]]
361name = "asimov-widget-fetcher"
362path = "src/fetcher/main.rs"
363"#,
364        );
365        write(dir.path(), "src/fetcher/main.rs", "fn main() {}");
366
367        let findings = lint_module(LintOptions::new(dir.path())).unwrap();
368        assert_eq!(findings.len(), 1);
369        assert_eq!(findings[0].severity, Severity::Error);
370        assert_eq!(findings[0].code, LintCode::BinNotInManifest);
371    }
372
373    #[test]
374    fn orphan_program_source_is_a_warning() {
375        let dir = tempdir().unwrap();
376        clean_module(dir.path());
377        write(dir.path(), "src/fetcher/main.rs", "fn main() {}");
378
379        let findings = lint_module(LintOptions::new(dir.path())).unwrap();
380        assert_eq!(findings.len(), 1);
381        assert_eq!(findings[0].severity, Severity::Warning);
382        assert_eq!(findings[0].code, LintCode::OrphanProgramSource);
383    }
384
385    #[test]
386    fn flat_bin_convention_is_recognized() {
387        let dir = tempdir().unwrap();
388        write(
389            dir.path(),
390            "Cargo.toml",
391            r#"
392[package]
393name = "asimov-widget-module"
394
395[[bin]]
396name = "asimov-widget-emitter"
397path = "src/bin/emitter.rs"
398"#,
399        );
400        write(dir.path(), "src/bin/emitter.rs", "fn main() {}");
401        write(
402            dir.path(),
403            ".asimov/module.yaml",
404            r#"
405name: widget
406provides:
407  programs:
408    - asimov-widget-emitter
409handles:
410  url_protocols:
411    - widget
412  url_prefixes:
413  url_patterns:
414  file_extensions:
415  content_types:
416"#,
417        );
418
419        let findings = lint_module(LintOptions::new(dir.path())).unwrap();
420        assert!(findings.is_empty(), "unexpected findings: {findings:#?}");
421    }
422
423    #[test]
424    fn bin_without_explicit_path_is_not_a_false_positive_orphan() {
425        let dir = tempdir().unwrap();
426        write(
427            dir.path(),
428            "Cargo.toml",
429            r#"
430[package]
431name = "asimov-widget-module"
432
433[[bin]]
434name = "asimov-widget-emitter"
435"#,
436        );
437        // No `path` given: Cargo's implicit convention is `src/bin/<name>.rs`.
438        write(
439            dir.path(),
440            "src/bin/asimov-widget-emitter.rs",
441            "fn main() {}",
442        );
443        write(
444            dir.path(),
445            ".asimov/module.yaml",
446            r#"
447name: widget
448provides:
449  programs:
450    - asimov-widget-emitter
451handles:
452  url_protocols:
453    - widget
454  url_prefixes:
455  url_patterns:
456  file_extensions:
457  content_types:
458"#,
459        );
460
461        let findings = lint_module(LintOptions::new(dir.path())).unwrap();
462        assert!(findings.is_empty(), "unexpected findings: {findings:#?}");
463    }
464
465    #[test]
466    fn nested_bin_convention_orphan_is_detected() {
467        let dir = tempdir().unwrap();
468        clean_module(dir.path());
469        // Unreferenced, using the nested `src/bin/<name>/main.rs` convention.
470        write(dir.path(), "src/bin/fetcher/main.rs", "fn main() {}");
471
472        let findings = lint_module(LintOptions::new(dir.path())).unwrap();
473        assert_eq!(findings.len(), 1);
474        assert_eq!(findings[0].severity, Severity::Warning);
475        assert_eq!(findings[0].code, LintCode::OrphanProgramSource);
476    }
477
478    #[test]
479    fn not_a_module_errors() {
480        let dir = tempdir().unwrap();
481        let err = lint_module(LintOptions::new(dir.path())).unwrap_err();
482        assert!(matches!(err, LintError::NotAModule(_)));
483    }
484}