use super::design::Design;
use super::genroute::crate_ident;
use super::templates::set_features;
use std::fs;
use std::path::Path;
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 { "" }
}
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
}
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);
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"
)
}
pub(crate) struct ScannedMigration {
pub(crate) module: String,
pub(crate) file_stem: String,
}
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)
}
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"
)))
}
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)
}
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..]
))
}
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();
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());
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 => {
if migrations_path.exists() {
fs::remove_file(&migrations_path).map_err(|e| e.to_string())?;
modified.push("crates/app/src/migrations.rs".to_string());
}
}
}
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());
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());
}
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)
}