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 InvalidModuleName,
23 MissingManifest,
25 ProgramNotInCargoToml,
27 BinNotInManifest,
29 OrphanProgramSource,
31 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 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
197fn 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 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 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 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}