jerrycan 0.1.0

The AI-native Rust backend platform: framework, CLI, and MCP server. https://jerrycan.cc
Documentation
//! jerrycan-specific lints (spec ยง5.3 ring 3). v0 set:
//! JL0001 route-crate lib.rs exports more than `module()`
//! JL0002 handler names don't match design operation_ids
//! JL0003 a generated (tool-owned) file was hand-edited
//! JL0004 an auth design leaves a mutating route unguarded

use super::checkpipe::Diagnostic;
use super::design::{Design, HttpMethod, ModuleDesign};
use super::mounting;
use std::path::Path;

fn d(
    code: &str,
    file: Option<String>,
    line: Option<u64>,
    message: String,
    suggestion: &str,
    doc: &str,
) -> Diagnostic {
    Diagnostic {
        code: code.into(),
        file,
        line,
        message,
        suggestion: Some(suggestion.into()),
        doc_url: Some(doc.into()),
    }
}

pub fn run(root: &Path, design: &Design) -> Vec<Diagnostic> {
    let mut out = Vec::new();
    for m in &design.modules {
        lint_public_surface(root, m, &mut out);
        lint_handlers(root, m, &format!("crates/routes/{}/src", m.name), &mut out);
    }
    lint_generated_drift(root, design, &mut out);
    lint_unguarded_mutations(design, &mut out);
    out
}

/// JL0004: in an auth design, a mutating route (POST/PUT/PATCH/DELETE) whose
/// design endpoint is NOT guarded (no auth_required, no required_roles).
fn lint_unguarded_mutations(design: &Design, out: &mut Vec<Diagnostic>) {
    if !design.wants_auth() {
        return;
    }
    fn walk(m: &ModuleDesign, out: &mut Vec<Diagnostic>) {
        for ep in &m.endpoints {
            let mutating = matches!(
                ep.method,
                HttpMethod::POST | HttpMethod::PUT | HttpMethod::PATCH | HttpMethod::DELETE
            );
            if mutating && !ep.is_guarded() {
                out.push(d(
                    "JL0004",
                    Some("design.json".into()),
                    None,
                    format!(
                        "mutating route `{}` in module `{}` has no auth guard (design declares auth)",
                        ep.operation_id, m.name
                    ),
                    "set auth_required: true or required_roles in design.json",
                    "jerrycan docs auth",
                ));
            }
        }
        for sub in &m.subroutes {
            walk(sub, out);
        }
    }
    for m in &design.modules {
        walk(m, out);
    }
}

/// JL0001: scan a route crate's lib.rs for public items besides `pub fn module()`.
fn lint_public_surface(root: &Path, m: &ModuleDesign, out: &mut Vec<Diagnostic>) {
    let rel = format!("crates/routes/{}/src/lib.rs", m.name);
    let Ok(content) = std::fs::read_to_string(root.join(&rel)) else {
        return;
    };
    for (i, line) in content.lines().enumerate() {
        let t = line.trim_start();
        if !t.starts_with("pub ") || t.starts_with("pub(") {
            continue;
        }
        if t.starts_with("pub fn module(") {
            continue;
        }
        out.push(d(
            "JL0001",
            Some(rel.clone()),
            Some(i as u64 + 1),
            format!(
                "route crate `{}` exports more than `module()`: `{}`",
                m.name,
                t.trim_end()
            ),
            "make it pub(crate), move shared types to the shared crate, or expose via module()",
            "jerrycan docs modules#anti-patterns",
        ));
    }
}

/// JL0002: every design endpoint needs `async fn <operation_id>(` in its unit's handlers.rs.
fn lint_handlers(root: &Path, m: &ModuleDesign, src_rel: &str, out: &mut Vec<Diagnostic>) {
    let rel = format!("{src_rel}/handlers.rs");
    let content = std::fs::read_to_string(root.join(&rel)).unwrap_or_default();
    for ep in &m.endpoints {
        // Substring match can be fooled by commented-out handlers; the build class is the real guarantee (lib.rs references handlers::<op>).
        if !content.contains(&format!("async fn {}(", ep.operation_id)) {
            out.push(d(
                "JL0002",
                Some(rel.clone()),
                None,
                format!(
                    "handler `{}` (from design.json) is missing in {rel}",
                    ep.operation_id
                ),
                "add the handler with that exact name, or fix the design's operation_id",
                "jerrycan docs modules",
            ));
        }
    }
    for sub in &m.subroutes {
        lint_handlers(
            root,
            sub,
            &format!("{src_rel}/subroutes/{}", sub.name.replace('-', "_")),
            out,
        );
    }
}

/// JL0003: tool-owned app/src/main.rs (and, in db mode, app/src/migrations.rs)
/// must equal the regenerator's output exactly.
fn lint_generated_drift(root: &Path, design: &Design, out: &mut Vec<Diagnostic>) {
    let drift = d(
        "JL0003",
        Some("crates/app/src/main.rs".into()),
        None,
        "generated file drifted from the design (hand-edited, or design.json changed without regenerating)".into(),
        "run `jerrycan generate route <module>` to regenerate mounting; never hand-edit GENERATED files",
        "jerrycan docs app#anti-patterns",
    );
    let main_rel = "crates/app/src/main.rs";
    let on_disk = std::fs::read_to_string(root.join(main_rel)).unwrap_or_default();
    if on_disk != mounting::expected_main(design) {
        out.push(drift);
    }

    if design.wants_db()
        && let Ok(Some(expected)) = mounting::expected_migrations_rs(root, design)
    {
        let mig_rel = "crates/app/src/migrations.rs";
        let on_disk = std::fs::read_to_string(root.join(mig_rel)).unwrap_or_default();
        if on_disk != expected {
            out.push(d(
                "JL0003",
                Some(mig_rel.into()),
                None,
                "generated file drifted from the design (hand-edited, or migrations changed without regenerating)".into(),
                "run `jerrycan generate route <module>` to regenerate the migration aggregate; never hand-edit GENERATED files",
                "jerrycan docs app#anti-patterns",
            ));
        }
    }
}