jerrycan 0.1.0

The AI-native Rust backend platform: framework, CLI, and MCP server. https://jerrycan.cc
Documentation
//! The deterministic mounting regenerator: app/src/main.rs (whole file),
//! workspace members, app route-deps. Sorted, idempotent, byte-stable —
//! JL0003 compares against exactly this output.

use super::design::Design;
use super::genroute::crate_ident;
use super::templates::set_features;
use std::fs;
use std::path::Path;

/// Rewrite the workspace's `jerrycan = { … }` dependency line so its facade
/// features (`db`/`validate`) match the design's mode. Leaves other lines and
/// the path/version form untouched.
fn sync_facade_features(ws: &str, design: &Design) -> String {
    let features = design.facade_features();
    ws.lines()
        .map(|line| {
            if line.trim_start().starts_with("jerrycan = {") {
                set_features(line, &features)
            } else {
                line.to_string()
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
        + if ws.ends_with('\n') { "\n" } else { "" }
}

/// The ordered `.extend(...)` block for `main`. Order is load-bearing: Auth
/// FIRST so the session/role guards resolve their extension, then Observe, then
/// db, then validate. Memory/db/validate-only modes keep their exact prior bytes
/// (auth/observe absent → no extra lines, db before validate as before).
fn extension_block(design: &Design) -> String {
    let mut block = String::new();
    if design.wants_auth() {
        block.push_str("        .extend(jerrycan::auth::Auth::from_env()?)\n");
    }
    if design.wants_observe() {
        block.push_str("        .extend(jerrycan::observe::Observe::new())\n");
    }
    if design.wants_db() {
        block.push_str("        .extend(db)\n");
    }
    if design.wants_validate() {
        block.push_str("        .extend(jerrycan::validate::OpenApi::new(include_str!(\"../../../openapi.json\")))\n");
    }
    block
}

/// The complete, tool-owned app/src/main.rs for this design.
pub fn expected_main(design: &Design) -> String {
    let mut modules: Vec<_> = design.modules.iter().collect();
    modules.sort_by(|a, b| a.name.cmp(&b.name));

    let mut mounts = String::new();
    for dep in design
        .dependencies
        .iter()
        .filter(|d| !matches!(d.as_str(), "db" | "validate" | "auth" | "observe"))
    {
        mounts.push_str(&format!(
            "        // app dependency `{dep}`: provide here once its extension lands\n"
        ));
    }
    for m in &modules {
        mounts.push_str(&format!(
            "        .mount(\"{}\", {}::module())\n",
            m.effective_mount(),
            crate_ident(&m.name)
        ));
    }
    let extensions = extension_block(design);

    // observe initializes logging before the App is built; db needs a module
    // decl + a connect/migrate preamble inside main. Both are absent otherwise.
    let logging = if design.wants_observe() {
        "    jerrycan::observe::init_logging();\n"
    } else {
        ""
    };
    let migrations_mod = if design.wants_db() {
        "mod migrations;\n\n"
    } else {
        ""
    };
    let db_preamble = if design.wants_db() {
        "    let db = jerrycan::db::Db::from_env().await?;\n    db.migrate(migrations::MIGRATIONS).await?;\n"
    } else {
        ""
    };

    format!(
        "//! GENERATED by jerrycan — do not hand-edit; `jerrycan generate` rewrites this file.\nuse jerrycan::prelude::*;\n\n{migrations_mod}#[jerrycan::main]\nasync fn main() -> Result<()> {{\n{logging}{db_preamble}    App::new()\n{extensions}{mounts}        .serve()\n        .await\n}}\n"
    )
}

/// One scanned migration: owning module and file stem (twin existence verified).
pub(crate) struct ScannedMigration {
    pub(crate) module: String,
    pub(crate) file_stem: String,
}

/// Scan `crates/routes/*/migrations/sqlite/*.sql` for every module-owned
/// migration, sorted by module name then filename, requiring each one's
/// postgres twin to exist (missing → loud error). Shared by the aggregated
/// `migrations.rs` generator and the CLI's runtime loader.
pub(crate) fn scan_migrations(app_root: &Path) -> Result<Vec<ScannedMigration>, String> {
    let routes = app_root.join("crates/routes");
    let mut modules: Vec<String> = Vec::new();
    if let Ok(entries) = fs::read_dir(&routes) {
        for entry in entries.flatten() {
            if entry.path().join("migrations/sqlite").is_dir() {
                modules.push(entry.file_name().to_string_lossy().into_owned());
            }
        }
    }
    modules.sort();

    let mut out = Vec::new();
    for module in modules {
        let sqlite_dir = routes.join(&module).join("migrations/sqlite");
        let mut files: Vec<String> = fs::read_dir(&sqlite_dir)
            .map_err(|e| format!("read {}: {e}", sqlite_dir.display()))?
            .flatten()
            .filter_map(|e| {
                let p = e.path();
                if p.extension().is_some_and(|x| x == "sql") {
                    p.file_name().map(|n| n.to_string_lossy().into_owned())
                } else {
                    None
                }
            })
            .collect();
        files.sort();
        for file in files {
            let postgres_path = routes.join(&module).join("migrations/postgres").join(&file);
            if !postgres_path.exists() {
                return Err(format!(
                    "migration `{module}/migrations/sqlite/{file}` has no postgres twin at {} — both dialects are required",
                    postgres_path.display()
                ));
            }
            let file_stem = file.trim_end_matches(".sql").to_string();
            out.push(ScannedMigration {
                module: module.clone(),
                file_stem,
            });
        }
    }
    Ok(out)
}

/// The tool-owned `app/src/migrations.rs` aggregating module-owned migrations,
/// or None when the design has no `db` dependency.
pub fn expected_migrations_rs(app_root: &Path, design: &Design) -> Result<Option<String>, String> {
    if !design.wants_db() {
        return Ok(None);
    }
    let scanned = scan_migrations(app_root)?;
    let mut entries = String::new();
    for m in &scanned {
        let module_snake = m.module.replace('-', "_");
        entries.push_str(&format!(
            "    Migration {{\n        name: \"{module_snake}_{stem}\",\n        sqlite: include_str!(\"../../routes/{module}/migrations/sqlite/{stem}.sql\"),\n        postgres: include_str!(\"../../routes/{module}/migrations/postgres/{stem}.sql\"),\n    }},\n",
            stem = m.file_stem,
            module = m.module,
        ));
    }
    Ok(Some(format!(
        "//! GENERATED by jerrycan — aggregates module-owned migrations; do not hand-edit.\nuse jerrycan::db::Migration;\n\npub const MIGRATIONS: &[Migration] = &[\n{entries}];\n"
    )))
}

/// Load every module-owned migration's contents (the SAME scan as the
/// aggregated `migrations.rs`, twin-required, module-sorted). Consumed by
/// `jerrycan db migrate` to apply migrations from disk at runtime.
pub fn collect_migrations(app_root: &Path) -> Result<Vec<crate::db::OwnedMigration>, String> {
    let routes = app_root.join("crates/routes");
    let mut out = Vec::new();
    for m in scan_migrations(app_root)? {
        let module_snake = m.module.replace('-', "_");
        let sqlite_path = routes
            .join(&m.module)
            .join(format!("migrations/sqlite/{}.sql", m.file_stem));
        let postgres_path = routes
            .join(&m.module)
            .join(format!("migrations/postgres/{}.sql", m.file_stem));
        let sqlite = fs::read_to_string(&sqlite_path)
            .map_err(|e| format!("read {}: {e}", sqlite_path.display()))?;
        let postgres = fs::read_to_string(&postgres_path)
            .map_err(|e| format!("read {}: {e}", postgres_path.display()))?;
        out.push(crate::db::OwnedMigration {
            name: format!("{module_snake}_{}", m.file_stem),
            sqlite,
            postgres,
        });
    }
    Ok(out)
}

/// Replace the lines between marker lines (markers stay). Fails loud if markers vanished.
fn splice(content: &str, begin: &str, end: &str, replacement: &str) -> Result<String, String> {
    let b = content.find(begin).ok_or_else(|| {
        format!("marker `{begin}` missing — file was hand-edited; restore it or re-scaffold")
    })?;
    let line_end = content[b..]
        .find('\n')
        .map(|i| b + i + 1)
        .unwrap_or(content.len());
    let e = content.find(end).ok_or_else(|| {
        format!("marker `{end}` missing — file was hand-edited; restore it or re-scaffold")
    })?;
    if e < line_end {
        return Err(format!("marker `{end}` precedes `{begin}`"));
    }
    let e_line_start = content[..e].rfind('\n').map(|i| i + 1).unwrap_or(0);
    Ok(format!(
        "{}{}{}",
        &content[..line_end],
        replacement,
        &content[e_line_start..]
    ))
}

/// Regenerate every generator-owned mounting surface. Returns modified files.
pub fn regenerate(app_root: &Path, design: &Design) -> Result<Vec<String>, String> {
    let mut modules: Vec<_> = design.modules.iter().collect();
    modules.sort_by(|a, b| a.name.cmp(&b.name));
    let mut modified = Vec::new();

    // 1. app/src/main.rs — whole file.
    let main_path = app_root.join("crates/app/src/main.rs");
    fs::create_dir_all(main_path.parent().expect("parent")).map_err(|e| e.to_string())?;
    fs::write(&main_path, expected_main(design)).map_err(|e| e.to_string())?;
    modified.push("crates/app/src/main.rs".to_string());

    // 1b. app/src/migrations.rs — aggregated module migrations (db mode only).
    let migrations_path = app_root.join("crates/app/src/migrations.rs");
    match expected_migrations_rs(app_root, design)? {
        Some(content) => {
            fs::write(&migrations_path, content).map_err(|e| e.to_string())?;
            modified.push("crates/app/src/migrations.rs".to_string());
        }
        None => {
            // Memory mode: remove a stale migrations.rs if a prior db mode left one.
            if migrations_path.exists() {
                fs::remove_file(&migrations_path).map_err(|e| e.to_string())?;
                modified.push("crates/app/src/migrations.rs".to_string());
            }
        }
    }

    // 1c. openapi.json — tool-owned, emitted in every mode (the validate
    // extension includes it; harmless otherwise).
    let openapi_path = app_root.join("openapi.json");
    fs::write(&openapi_path, super::openapi::document_json(design)).map_err(|e| e.to_string())?;
    modified.push("openapi.json".to_string());

    // 2. workspace members + facade features (kept in sync with the mode).
    let ws_path = app_root.join("Cargo.toml");
    let ws =
        fs::read_to_string(&ws_path).map_err(|e| format!("read {}: {e}", ws_path.display()))?;
    let members: String = modules
        .iter()
        .map(|m| format!("    \"crates/routes/{}\",\n", m.name))
        .collect();
    let ws2 = splice(
        &ws,
        "# jerrycan:members:begin",
        "# jerrycan:members:end",
        &members,
    )?;
    let ws3 = sync_facade_features(&ws2, design);
    if ws3 != ws {
        fs::write(&ws_path, &ws3).map_err(|e| e.to_string())?;
        modified.push("Cargo.toml".to_string());
    }

    // 3. app route-deps.
    let app_cargo_path = app_root.join("crates/app/Cargo.toml");
    let ac = fs::read_to_string(&app_cargo_path)
        .map_err(|e| format!("read {}: {e}", app_cargo_path.display()))?;
    let deps: String = modules
        .iter()
        .map(|m| format!("route-{} = {{ path = \"../routes/{}\" }}\n", m.name, m.name))
        .collect();
    let ac2 = splice(
        &ac,
        "# jerrycan:route-deps:begin",
        "# jerrycan:route-deps:end",
        &deps,
    )?;
    if ac2 != ac {
        fs::write(&app_cargo_path, &ac2).map_err(|e| e.to_string())?;
        modified.push("crates/app/Cargo.toml".to_string());
    }

    Ok(modified)
}