jerrycan 0.2.0

The AI-native Rust backend platform: framework, CLI, and MCP server. https://jerrycan.cc
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! 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
/// jobs (needs the db, registered before `.extend(db)` moves it), then db, then
/// validate. Memory/db/validate-only modes keep their exact prior bytes
/// (auth/observe/jobs 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");
    }
    // Jobs need the db: register the wired `Jobs` extension (the generated
    // `crates/jobs` crate's `jobs(db)` fn) with a CLONE, before `.extend(db)`
    // below moves `db`. wants_jobs implies wants_db (questions.rs enforces it).
    if design.wants_jobs() {
        block.push_str("        .extend(jobs::jobs(db.clone()))\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);
    // Tenancy registers the membership-checked `Tenant` guard app-wide (after the
    // extensions it depends on — Auth + Db — and before the modules that consume
    // it via `Dep<shared::Tenant>`).
    let tenant_dep = if design.tenancy.is_some() {
        "        .provide_dep(shared::tenant)\n"
    } else {
        ""
    };

    // 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 {
        ""
    };
    // Jobs need their own tables: run JOBS_MIGRATIONS right after the app
    // migrations (both over the same `db`, before it is moved into the extension
    // block). Absent unless the design declares jobs.
    let jobs_migrations = if design.wants_jobs() {
        "    db.migrate(jerrycan::jobs::JOBS_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}{jobs_migrations}    App::new()\n{extensions}{tenant_dep}{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());

    // 1d. The top-level jobs crate (db + the `Jobs` wiring). Written when the
    // design declares jobs; removed if a prior design declared jobs and no longer
    // does (so a stale `crates/jobs` can't break the workspace build).
    let jobs_dir = app_root.join("crates/jobs");
    if design.wants_jobs() {
        modified.extend(super::jobsgen::write_jobs(app_root, design)?);
    } else if jobs_dir.exists() {
        fs::remove_dir_all(&jobs_dir).map_err(|e| e.to_string())?;
        modified.push("crates/jobs".to_string());
    }

    // 2. workspace members + facade features (kept in sync with the mode). The
    // jobs crate joins the members list (after the route crates) when present.
    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 mut members: String = modules
        .iter()
        .map(|m| format!("    \"crates/routes/{}\",\n", m.name))
        .collect();
    if design.wants_jobs() {
        members.push_str("    \"crates/jobs\",\n");
    }
    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 mut deps: String = modules
        .iter()
        .map(|m| format!("route-{} = {{ path = \"../routes/{}\" }}\n", m.name, m.name))
        .collect();
    if design.wants_jobs() {
        // main.rs references `jobs::jobs(db)`, so app depends on the jobs crate.
        deps.push_str("jobs = { path = \"../jobs\" }\n");
    }
    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)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A minimal db+jobs design exercising the jobs wiring in `expected_main`
    /// without depending on a frozen fixture's shape.
    fn jobs_design() -> Design {
        serde_json::from_str(
            r#"{
                "name": "jobs-app", "contract_version": 1,
                "dependencies": ["db"],
                "jobs": [{ "name": "expire_trials", "schedule": "0 * * * *", "queue": "billing" }],
                "modules": [{ "name": "things",
                    "endpoints": [{ "operation_id": "list_things", "method": "GET", "path": "/",
                        "success": { "status": 200 } }] }]
            }"#,
        )
        .unwrap()
    }

    /// A jobs design wires the `Jobs` extension and runs JOBS_MIGRATIONS in
    /// main.rs. The extension is registered with a db CLONE before `.extend(db)`
    /// moves it (the worker needs the live db), and the jobs tables migrate right
    /// after the app migrations (same `db`, before the move).
    #[test]
    fn expected_main_wires_jobs_extension_and_migrations() {
        let main = expected_main(&jobs_design());
        // JOBS_MIGRATIONS run after the aggregated app migrations, before App::new.
        let app_mig = main.find("db.migrate(migrations::MIGRATIONS)").unwrap();
        let jobs_mig = main
            .find("db.migrate(jerrycan::jobs::JOBS_MIGRATIONS)")
            .unwrap();
        let app_new = main.find("App::new()").unwrap();
        assert!(
            app_mig < jobs_mig && jobs_mig < app_new,
            "jobs migrations run after app migrations, before App::new: {main}"
        );
        // The jobs extension is registered with a clone, BEFORE `.extend(db)` (which
        // moves db). The worker needs the live db, so jobs must precede the move.
        let jobs_ext = main.find(".extend(jobs::jobs(db.clone()))").unwrap();
        let db_ext = main.find(".extend(db)\n").unwrap();
        assert!(
            jobs_ext < db_ext,
            "jobs extension (db.clone()) must come before `.extend(db)` moves db: {main}"
        );
    }

    /// A design with NO jobs is byte-for-byte unchanged: no jobs extension, no
    /// JOBS_MIGRATIONS line. (Guards the wants_jobs gating.)
    #[test]
    fn expected_main_without_jobs_has_no_jobs_wiring() {
        let mut d = jobs_design();
        d.jobs.clear();
        let main = expected_main(&d);
        assert!(!main.contains("jobs::jobs"), "no jobs extension: {main}");
        assert!(
            !main.contains("JOBS_MIGRATIONS"),
            "no jobs migrations: {main}"
        );
    }
}