Skip to main content

kick_rs_cli/
check.rs

1//! `cargo kick check` — lint an existing kick-rs project for common
2//! misconfigurations the compiler doesn't catch.
3//!
4//! Pure-text scanners, no `syn` round-trip. The patterns we recognize
5//! are the same shapes the scaffold + `cargo kick g` produce:
6//!
7//! - `pub mod X;` in `src/modules/mod.rs`
8//! - `.module(modules::X::define())` in `src/main.rs`
9//! - `pub mod Y;` in `src/modules/<X>/mod.rs`
10//! - `.service::<Pascal>()` / `.contribute(Pascal)` in
11//!   `src/modules/<X>/mod.rs`
12//! - `#[service]` / `#[contributor]` annotations in the corresponding
13//!   `src/modules/<X>/<Y>.rs`
14//!
15//! Findings are reported with the file path + a one-line hint. The
16//! CLI exits non-zero when any finding is non-empty; that makes
17//! `cargo kick check` a useful CI gate after generators have run.
18
19use crate::generate::{find_project_root, to_pascal_case, GenerateError};
20use std::fs;
21use std::path::{Path, PathBuf};
22
23/// Decoded form of the `check` subcommand.
24pub struct CheckArgs {
25    /// Override the project root.
26    pub project_root: Option<PathBuf>,
27}
28
29/// One lint finding. The `code` is a stable identifier — adopters
30/// can pin a CI check against a specific lint.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Finding {
33    /// Stable lint code (e.g. `RK_K_UNMOUNTED_MODULE`).
34    pub code: &'static str,
35    /// One-line human-readable summary.
36    pub message: String,
37    /// File path the finding is anchored to.
38    pub path: PathBuf,
39}
40
41/// Per-run summary returned by [`run`].
42#[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
73/// Walk the project and produce a [`CheckReport`].
74pub 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    // ── M1: modules declared in src/modules/mod.rs but never mounted
83    //        in src/main.rs (or no main.rs found).
84    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        // `.module(modules::<name>::define())` is the only place this
92        // appears in a normal main.rs. Match liberally on the
93        // substring to tolerate spacing / line breaks.
94        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    // ── R1: pub mod X; line whose <X>/ directory (or <X>.rs file)
107    //        doesn't exist on disk. Catches a manual delete that
108    //        left the re-export behind.
109    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    // ── S1: services / contributors declared in `<module>/<file>.rs`
124    //        (via `#[service]` / `#[contributor]`) but not registered
125    //        in `<module>/mod.rs`.
126    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
175/// Helper: read a file; treat missing files as empty so the
176/// individual lints can choose how to interpret absence (M1 skips,
177/// S1 just doesn't fire because there's nothing inside).
178fn 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
189/// Extract every `pub mod <name>;` declared at the top level of the
190/// given source. Whitespace between `pub`, `mod`, name, and `;` is
191/// tolerated; multi-attribute lines aren't (rare in generated code).
192pub(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        // Strip leading `pub mod ` and trailing `;`.
197        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        // Reject anything that isn't a plain identifier — `pub mod x::y;`
205        // is illegal anyway and an `as` rename is rare here.
206        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/// What kind of registration is missing for a given file.
214#[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 (e.g. `email_sender` for `email_sender.rs`).
225    file_stem: String,
226}
227
228/// Scan `module_dir` for .rs files containing `#[service]` or
229/// `#[contributor]` and extract the corresponding type / fn name.
230fn 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        // `mod.rs` and `handlers.rs` are never decls of services or
249        // contributors — skip them up front to keep scans cheap.
250        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        // The macro patterns. We just check for the bare attribute —
258        // the file stem gives us the registration name via
259        // PascalCase, matching how `cargo kick g service|contributor`
260        // emits them.
261        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
279/// Render the report for the CLI.
280pub 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        // Leading whitespace on the line is trimmed before matching.
305        // Extra space between `mod` and the name happens to also work
306        // (we `.trim()` the captured name) — covered here to lock in
307        // the actual behavior.
308        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    /// Build a minimal project skeleton for the integration tests.
319    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        // Append an unmounted module declaration; the directory + an
368        // empty mod.rs are needed to keep R1 happy.
369        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        // Declare a module whose directory + file don't exist.
389        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        // Drop an unregistered service file into the hello module.
407        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        // Add the pub mod entry so the module compiles in principle,
413        // but DON'T add the .service::<EmailSender>() call to mod.rs.
414        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        // mod.rs already declares define() — patch in the registration.
465        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        // The new service file declares pub mod email_sender, no R1 hit.
482        // It IS registered via .service::<EmailSender>(), so no S1.
483        // No other findings either — clean.
484        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}