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