1use crate::generate::{find_project_root, to_pascal_case, GenerateError};
20use std::fs;
21use std::path::{Path, PathBuf};
22
23pub struct CheckArgs {
25 pub project_root: Option<PathBuf>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Finding {
33 pub code: &'static str,
35 pub message: String,
37 pub path: PathBuf,
39}
40
41#[derive(Debug, Default)]
43pub struct CheckReport {
44 pub findings: Vec<Finding>,
45}
46
47impl CheckReport {
48 pub fn is_clean(&self) -> bool {
49 self.findings.is_empty()
50 }
51}
52
53#[derive(Debug)]
54pub enum CheckError {
55 ProjectRoot(GenerateError),
56 Io {
57 path: PathBuf,
58 source: std::io::Error,
59 },
60}
61
62impl std::fmt::Display for CheckError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::ProjectRoot(e) => write!(f, "{e}"),
66 Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
67 }
68 }
69}
70
71impl std::error::Error for CheckError {}
72
73pub fn run(args: &CheckArgs) -> Result<CheckReport, CheckError> {
75 let root = match &args.project_root {
76 Some(p) => p.clone(),
77 None => find_project_root(Path::new(".")).map_err(CheckError::ProjectRoot)?,
78 };
79
80 let mut report = CheckReport::default();
81
82 let modules_mod_rs = root.join("src/modules/mod.rs");
85 let modules_mod_body = read_to_string_optional(&modules_mod_rs)?;
86 let declared = pub_mod_names(&modules_mod_body);
87 let main_rs = root.join("src/main.rs");
88 let main_body = read_to_string_optional(&main_rs)?;
89
90 for name in &declared {
91 let needle = format!("modules::{name}::define()");
95 if !main_body.contains(&needle) && !main_body.is_empty() {
96 report.findings.push(Finding {
97 code: "RK_K_UNMOUNTED_MODULE",
98 message: format!(
99 "module `{name}` is declared in src/modules/mod.rs but not mounted in src/main.rs (expected `.module({needle})`)"
100 ),
101 path: main_rs.clone(),
102 });
103 }
104 }
105
106 for name in &declared {
110 let as_dir = root.join("src/modules").join(name);
111 let as_file = root.join("src/modules").join(format!("{name}.rs"));
112 if !as_dir.is_dir() && !as_file.is_file() {
113 report.findings.push(Finding {
114 code: "RK_K_STALE_PUB_MOD",
115 message: format!(
116 "src/modules/mod.rs declares `pub mod {name};` but neither src/modules/{name}/ nor src/modules/{name}.rs exists"
117 ),
118 path: modules_mod_rs.clone(),
119 });
120 }
121 }
122
123 for name in &declared {
127 let module_dir = root.join("src/modules").join(name);
128 if !module_dir.is_dir() {
129 continue;
130 }
131 let module_mod_rs = module_dir.join("mod.rs");
132 let module_body = read_to_string_optional(&module_mod_rs)?;
133
134 let decls = collect_decls(&module_dir)?;
135 for d in decls {
136 let registered = match d.kind {
137 DeclKind::Service => module_body.contains(&format!(".service::<{}>()", d.pascal)),
138 DeclKind::Contributor => {
139 module_body.contains(&format!(".contribute({})", d.pascal))
140 }
141 };
142 if !registered {
143 let (lint_code, builder_method) = match d.kind {
144 DeclKind::Service => (
145 "RK_K_UNREGISTERED_SERVICE",
146 format!(".service::<{}>()", d.pascal),
147 ),
148 DeclKind::Contributor => (
149 "RK_K_UNREGISTERED_CONTRIBUTOR",
150 format!(".contribute({})", d.pascal),
151 ),
152 };
153 report.findings.push(Finding {
154 code: lint_code,
155 message: format!(
156 "{} `{}` declared in src/modules/{}/{}.rs but not registered (expected `{}`)",
157 match d.kind {
158 DeclKind::Service => "service",
159 DeclKind::Contributor => "contributor",
160 },
161 d.pascal,
162 name,
163 d.file_stem,
164 builder_method,
165 ),
166 path: module_mod_rs.clone(),
167 });
168 }
169 }
170 }
171
172 Ok(report)
173}
174
175fn read_to_string_optional(path: &Path) -> Result<String, CheckError> {
179 match fs::read_to_string(path) {
180 Ok(s) => Ok(s),
181 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
182 Err(e) => Err(CheckError::Io {
183 path: path.to_path_buf(),
184 source: e,
185 }),
186 }
187}
188
189pub(crate) fn pub_mod_names(body: &str) -> Vec<String> {
193 let mut out = Vec::new();
194 for line in body.lines() {
195 let trimmed = line.trim();
196 let Some(rest) = trimmed.strip_prefix("pub mod ") else {
198 continue;
199 };
200 let Some(name) = rest.strip_suffix(';') else {
201 continue;
202 };
203 let name = name.trim();
204 if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !name.is_empty() {
207 out.push(name.to_owned());
208 }
209 }
210 out
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215enum DeclKind {
216 Service,
217 Contributor,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
221struct Decl {
222 kind: DeclKind,
223 pascal: String,
224 file_stem: String,
226}
227
228fn collect_decls(module_dir: &Path) -> Result<Vec<Decl>, CheckError> {
231 let mut out = Vec::new();
232 let entries = match fs::read_dir(module_dir) {
233 Ok(e) => e,
234 Err(_) => return Ok(out),
235 };
236 for entry in entries.filter_map(Result::ok) {
237 let p = entry.path();
238 if !p.is_file() {
239 continue;
240 }
241 if p.extension().and_then(|s| s.to_str()) != Some("rs") {
242 continue;
243 }
244 let stem = match p.file_stem().and_then(|s| s.to_str()) {
245 Some(s) => s,
246 None => continue,
247 };
248 if stem == "mod" || stem == "handlers" {
251 continue;
252 }
253 let body = match fs::read_to_string(&p) {
254 Ok(b) => b,
255 Err(_) => continue,
256 };
257 if body.contains("#[service]") {
262 out.push(Decl {
263 kind: DeclKind::Service,
264 pascal: to_pascal_case(stem),
265 file_stem: stem.to_owned(),
266 });
267 }
268 if body.contains("#[contributor]") {
269 out.push(Decl {
270 kind: DeclKind::Contributor,
271 pascal: to_pascal_case(stem),
272 file_stem: stem.to_owned(),
273 });
274 }
275 }
276 Ok(out)
277}
278
279pub fn render(report: &CheckReport) -> String {
281 if report.findings.is_empty() {
282 return "kick-rs check: ✓ clean\n".to_owned();
283 }
284 let mut out = format!("kick-rs check: {} finding(s)\n\n", report.findings.len());
285 for f in &report.findings {
286 out.push_str(&format!(" [{}] {}\n", f.code, f.message));
287 out.push_str(&format!(" → {}\n", f.path.display()));
288 }
289 out
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn pub_mod_names_extracts_identifiers() {
298 let body = "pub mod handlers;\npub mod email_sender;\nuse foo;\n";
299 assert_eq!(pub_mod_names(body), vec!["handlers", "email_sender"]);
300 }
301
302 #[test]
303 fn pub_mod_names_handles_whitespace() {
304 let body = " pub mod posts;\n pub mod users;\n";
309 assert_eq!(pub_mod_names(body), vec!["posts", "users"]);
310 }
311
312 #[test]
313 fn pub_mod_names_skips_comments_and_other_lines() {
314 let body = "// pub mod commented_out;\npub mod real;\n";
315 assert_eq!(pub_mod_names(body), vec!["real"]);
316 }
317
318 fn make_project(dir: &Path) {
320 fs::create_dir_all(dir.join("src/modules/hello")).unwrap();
321 fs::write(
322 dir.join("Cargo.toml"),
323 "[package]\nname = \"x\"\nversion = \"0.0.1\"\n",
324 )
325 .unwrap();
326 fs::write(dir.join("src/modules/mod.rs"), "pub mod hello;\n").unwrap();
327 fs::write(
328 dir.join("src/modules/hello/mod.rs"),
329 "pub mod handlers;\n\
330 use kick_rs::{define_module, Module};\n\
331 pub fn define() -> Module {\n \
332 define_module(\"hello\").prefix(\"/hello\").build()\n\
333 }\n",
334 )
335 .unwrap();
336 fs::write(dir.join("src/modules/hello/handlers.rs"), "// stub\n").unwrap();
337 fs::write(
338 dir.join("src/main.rs"),
339 "use kick_rs::bootstrap;\n\
340 mod modules;\n\
341 #[tokio::main]\n\
342 async fn main() {\n \
343 bootstrap().module(modules::hello::define()).listen(\"0\").await.unwrap();\n\
344 }\n",
345 )
346 .unwrap();
347 }
348
349 #[test]
350 fn clean_project_reports_no_findings() {
351 let tmp = tempfile::tempdir().unwrap();
352 let root = tmp.path().join("proj");
353 make_project(&root);
354
355 let report = run(&CheckArgs {
356 project_root: Some(root.clone()),
357 })
358 .unwrap();
359 assert!(report.is_clean(), "got {:?}", report.findings);
360 }
361
362 #[test]
363 fn unmounted_module_is_flagged() {
364 let tmp = tempfile::tempdir().unwrap();
365 let root = tmp.path().join("proj");
366 make_project(&root);
367 let mut top = fs::read_to_string(root.join("src/modules/mod.rs")).unwrap();
370 top.push_str("pub mod posts;\n");
371 fs::write(root.join("src/modules/mod.rs"), top).unwrap();
372 fs::create_dir_all(root.join("src/modules/posts")).unwrap();
373 fs::write(root.join("src/modules/posts/mod.rs"), "").unwrap();
374
375 let report = run(&CheckArgs {
376 project_root: Some(root.clone()),
377 })
378 .unwrap();
379 let codes: Vec<_> = report.findings.iter().map(|f| f.code).collect();
380 assert!(codes.contains(&"RK_K_UNMOUNTED_MODULE"), "got {codes:?}");
381 }
382
383 #[test]
384 fn stale_pub_mod_is_flagged() {
385 let tmp = tempfile::tempdir().unwrap();
386 let root = tmp.path().join("proj");
387 make_project(&root);
388 let mut top = fs::read_to_string(root.join("src/modules/mod.rs")).unwrap();
390 top.push_str("pub mod ghosts;\n");
391 fs::write(root.join("src/modules/mod.rs"), top).unwrap();
392
393 let report = run(&CheckArgs {
394 project_root: Some(root.clone()),
395 })
396 .unwrap();
397 let codes: Vec<_> = report.findings.iter().map(|f| f.code).collect();
398 assert!(codes.contains(&"RK_K_STALE_PUB_MOD"), "got {codes:?}");
399 }
400
401 #[test]
402 fn unregistered_service_is_flagged() {
403 let tmp = tempfile::tempdir().unwrap();
404 let root = tmp.path().join("proj");
405 make_project(&root);
406 fs::write(
408 root.join("src/modules/hello/email_sender.rs"),
409 "use kick_rs::service;\n#[service]\npub struct EmailSender;\n",
410 )
411 .unwrap();
412 let mut mod_rs = fs::read_to_string(root.join("src/modules/hello/mod.rs")).unwrap();
415 mod_rs.insert_str(0, "pub mod email_sender;\n");
416 fs::write(root.join("src/modules/hello/mod.rs"), mod_rs).unwrap();
417
418 let report = run(&CheckArgs {
419 project_root: Some(root.clone()),
420 })
421 .unwrap();
422 let codes: Vec<_> = report.findings.iter().map(|f| f.code).collect();
423 assert!(
424 codes.contains(&"RK_K_UNREGISTERED_SERVICE"),
425 "got {codes:?}"
426 );
427 }
428
429 #[test]
430 fn unregistered_contributor_is_flagged() {
431 let tmp = tempfile::tempdir().unwrap();
432 let root = tmp.path().join("proj");
433 make_project(&root);
434 fs::write(
435 root.join("src/modules/hello/load_user.rs"),
436 "use kick_rs::contributor;\n#[contributor]\npub async fn LoadUser() {}\n",
437 )
438 .unwrap();
439 let mut mod_rs = fs::read_to_string(root.join("src/modules/hello/mod.rs")).unwrap();
440 mod_rs.insert_str(0, "pub mod load_user;\n");
441 fs::write(root.join("src/modules/hello/mod.rs"), mod_rs).unwrap();
442
443 let report = run(&CheckArgs {
444 project_root: Some(root.clone()),
445 })
446 .unwrap();
447 let codes: Vec<_> = report.findings.iter().map(|f| f.code).collect();
448 assert!(
449 codes.contains(&"RK_K_UNREGISTERED_CONTRIBUTOR"),
450 "got {codes:?}"
451 );
452 }
453
454 #[test]
455 fn registered_service_passes() {
456 let tmp = tempfile::tempdir().unwrap();
457 let root = tmp.path().join("proj");
458 make_project(&root);
459 fs::write(
460 root.join("src/modules/hello/email_sender.rs"),
461 "use kick_rs::service;\n#[service]\npub struct EmailSender;\n",
462 )
463 .unwrap();
464 fs::write(
466 root.join("src/modules/hello/mod.rs"),
467 "pub mod handlers;\n\
468 pub mod email_sender;\n\
469 use kick_rs::{define_module, Module};\n\
470 use email_sender::EmailSender;\n\
471 pub fn define() -> Module {\n \
472 define_module(\"hello\").prefix(\"/hello\").service::<EmailSender>().build()\n\
473 }\n",
474 )
475 .unwrap();
476
477 let report = run(&CheckArgs {
478 project_root: Some(root.clone()),
479 })
480 .unwrap();
481 assert!(report.is_clean(), "got {:?}", report.findings);
485 }
486
487 #[test]
488 fn render_shows_findings_or_clean_marker() {
489 let mut r = CheckReport::default();
490 assert!(render(&r).contains("✓ clean"));
491 r.findings.push(Finding {
492 code: "RK_K_UNMOUNTED_MODULE",
493 message: "x".into(),
494 path: PathBuf::from("src/main.rs"),
495 });
496 let out = render(&r);
497 assert!(out.contains("RK_K_UNMOUNTED_MODULE"));
498 assert!(out.contains("src/main.rs"));
499 }
500}