use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use gize_core::naming::{snake_case, table_name};
use gize_core::{Manifest, ModelSpec};
use gize_generator::{Options, Writer, registry, scaffold};
use crate::cli::GenFlags;
impl From<GenFlags> for Options {
fn from(f: GenFlags) -> Self {
Options {
force: f.force,
dry_run: f.dry_run,
}
}
}
fn migration_timestamp() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs:014}")
}
pub fn new_project(name: &str, flags: GenFlags) -> Result<()> {
let root = Path::new(name);
if root.exists() && !flags.force {
bail!("directory `{name}` already exists (use --force to generate into it)");
}
let plan = scaffold::new_project(name);
let report = Writer::new(flags.into())
.apply(root, &plan)
.with_context(|| format!("scaffolding project `{name}`"))?;
println!(
"Created project `{name}`:\n{}",
report.render(flags.dry_run)
);
if !flags.dry_run {
println!("Next:\n cd {name}\n cp .env.example .env\n gize serve");
}
Ok(())
}
pub fn make_app(name: &str, flags: GenFlags) -> Result<()> {
ensure_in_project()?;
let module = snake_case(name);
let plan = scaffold::make_app(&module);
let report = Writer::new(flags.into())
.apply(Path::new("."), &plan)
.context("generating module files")?;
println!(
"Generated module `{module}`:\n{}",
report.render(flags.dry_run)
);
register_in_app_mod(&module, flags)?;
register_in_manifest(&module, flags)?;
if !flags.dry_run {
println!("\nDefine its model with:\n gize make model <Name> field:Type ... --force");
}
Ok(())
}
fn register_in_app_mod(module: &str, flags: GenFlags) -> Result<()> {
let path = Path::new("src/app/mod.rs");
let source = fs::read_to_string(path).context("reading src/app/mod.rs")?;
let edit = registry::register_module(&source, module)?;
if !edit.changed {
println!(" skip src/app/mod.rs (module already registered)");
return Ok(());
}
if flags.dry_run {
println!(" update src/app/mod.rs (would register module + routes)");
} else {
fs::write(path, edit.content).context("writing src/app/mod.rs")?;
println!(" update src/app/mod.rs (registered module + routes)");
}
Ok(())
}
fn register_in_manifest(module: &str, flags: GenFlags) -> Result<()> {
let source = fs::read_to_string("gize.toml").context("reading gize.toml")?;
let mut manifest = Manifest::from_toml(&source)?;
if !manifest.add_module(module) {
println!(" skip gize.toml (module already listed)");
return Ok(());
}
if flags.dry_run {
println!(" update gize.toml (would add module to [modules])");
} else {
fs::write("gize.toml", manifest.to_toml()?).context("writing gize.toml")?;
println!(" update gize.toml (added module to [modules])");
}
Ok(())
}
pub fn make_crud(name: &str, fields: &[String], flags: GenFlags) -> Result<()> {
ensure_in_project()?;
let model = ModelSpec::parse(name, fields).context("invalid model definition")?;
if model.fields.is_empty() {
bail!(
"`gize make crud` needs at least one field, e.g. \
`gize make crud Product name:String price:i32`"
);
}
let module = table_name(&model.name);
let plan = scaffold::make_crud(&model, &migration_timestamp());
let report = Writer::new(flags.into())
.apply(Path::new("."), &plan)
.context("generating CRUD")?;
println!(
"Generated CRUD for `{}`:\n{}",
model.name,
report.render(flags.dry_run)
);
register_in_app_mod(&module, flags)?;
register_in_manifest(&module, flags)?;
if !flags.dry_run {
println!("\nApply the migration with:\n gize migrate");
}
Ok(())
}
pub fn make_model(name: &str, fields: &[String], flags: GenFlags) -> Result<()> {
ensure_in_project()?;
let model = ModelSpec::parse(name, fields).context("invalid model definition")?;
let plan = scaffold::make_model(&model, &migration_timestamp());
let report = Writer::new(flags.into())
.apply(Path::new("."), &plan)
.context("generating model")?;
println!(
"Generated model `{name}`:\n{}",
report.render(flags.dry_run)
);
Ok(())
}
pub fn make_migration(name: Option<&str>, flags: GenFlags) -> Result<()> {
ensure_in_project()?;
let migration_name = slugify(name.unwrap_or("migration"));
if migration_name.is_empty() {
bail!("migration name must contain at least one letter or digit");
}
let plan = scaffold::make_migration(&migration_name, &migration_timestamp());
let report = Writer::new(flags.into())
.apply(Path::new("."), &plan)
.context("generating migration")?;
println!(
"Generated migration `{migration_name}`:\n{}",
report.render(flags.dry_run)
);
if !flags.dry_run {
println!("\nEdit the SQL, then apply it with:\n gize migrate");
}
Ok(())
}
pub fn fmt() -> Result<()> {
ensure_in_project()?;
run_cargo(&["fmt", "--all"], "cargo fmt")
}
pub fn check() -> Result<()> {
ensure_in_project()?;
run_cargo(
&["clippy", "--all-targets", "--", "-D", "warnings"],
"cargo clippy",
)
}
fn slugify(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut pending_sep = false;
for ch in snake_case(input).chars() {
if ch.is_ascii_alphanumeric() {
if pending_sep && !out.is_empty() {
out.push('_');
}
out.push(ch);
pending_sep = false;
} else {
pending_sep = true;
}
}
out
}
fn run_cargo(args: &[&str], label: &str) -> Result<()> {
let status = std::process::Command::new("cargo")
.args(args)
.status()
.with_context(|| format!("failed to launch `{label}` — is cargo installed?"))?;
if !status.success() {
bail!("`{label}` exited with a non-zero status");
}
Ok(())
}
pub fn migrate(show_status: bool) -> Result<()> {
ensure_in_project()?;
let database_url = std::env::var("DATABASE_URL")
.context("DATABASE_URL must be set, e.g. postgres://user:pass@localhost:5432/dbname")?;
let dir = Path::new("migrations");
if show_status {
let status = gize_db::migrate::status(&database_url, dir)?;
if status.applied.is_empty() && status.pending.is_empty() {
println!("No migrations found in ./migrations.");
return Ok(());
}
println!("Applied:");
if status.applied.is_empty() {
println!(" (none)");
}
for m in &status.applied {
println!(" [x] {m}");
}
println!("Pending:");
if status.pending.is_empty() {
println!(" (none)");
}
for m in &status.pending {
println!(" [ ] {m}");
}
return Ok(());
}
let newly = gize_db::migrate::run(&database_url, dir)?;
if newly.is_empty() {
println!("Database is up to date — no pending migrations.");
} else {
println!("Applied {} migration(s):", newly.len());
for m in newly {
println!(" {m}");
}
}
Ok(())
}
pub fn serve() -> Result<()> {
ensure_in_project()?;
println!("Starting the application with `cargo run` (Ctrl-C to stop)...\n");
let status = std::process::Command::new("cargo")
.arg("run")
.status()
.context("failed to launch `cargo run` — is cargo installed?")?;
if !status.success() {
bail!("the application exited with a non-zero status");
}
Ok(())
}
pub fn doctor() -> Result<()> {
println!("gize doctor\n");
report("cargo available", which("cargo"));
report("rustfmt available", which("rustfmt"));
report(
"inside a gize project (gize.toml)",
Path::new("gize.toml").exists(),
);
report("DATABASE_URL set", std::env::var("DATABASE_URL").is_ok());
Ok(())
}
fn report(label: &str, ok: bool) {
let mark = if ok { "ok " } else { "warn" };
println!(" [{mark}] {label}");
}
fn which(bin: &str) -> bool {
std::process::Command::new(bin)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn ensure_in_project() -> Result<()> {
if !Path::new("gize.toml").exists() {
bail!("not a gize project (no gize.toml here). Run `gize new <name>` first.");
}
Ok(())
}
pub fn not_yet(command: &str, planned: &str) -> Result<()> {
println!("`gize {command}` is planned but not implemented in the MVP skeleton yet.");
println!("Planned behaviour: {planned}");
println!("Tracked in BACKLOG.md.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::slugify;
#[test]
fn slugify_handles_pascal_case_and_loose_text() {
assert_eq!(slugify("AddIndexToUsers"), "add_index_to_users");
assert_eq!(slugify("add index to users"), "add_index_to_users");
assert_eq!(slugify(" Add Index "), "add_index");
assert_eq!(slugify("add-index_to.users"), "add_index_to_users");
assert_eq!(slugify("migration"), "migration");
assert_eq!(slugify("!!!"), "");
}
}