asimov_module_kit/module/
lint.rs1use 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 MissingManifest,
23 ProgramNotInCargoToml,
25 BinNotInManifest,
27 OrphanProgramSource,
29 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 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
186fn 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 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 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 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}