use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use super::dsl::Field;
#[must_use]
pub fn add_mod_declaration(existing: &str, name: &str) -> String {
let line = format!("pub mod {name};");
if existing
.lines()
.any(|l| l.trim() == line || l.trim() == format!("mod {name};"))
{
return existing.to_owned();
}
if existing.is_empty() {
return format!("{line}\n");
}
let trimmed = existing.trim_end();
format!("{trimmed}\n{line}\n")
}
#[must_use]
pub fn schema_table_block(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
out.push_str("diesel::table! {\n");
let _ = writeln!(out, " {table} (id) {{");
out.push_str(" id -> Int8,\n");
for f in fields {
let _ = writeln!(out, " {} -> {},", f.name, f.schema_type());
}
out.push_str(" created_at -> Timestamp,\n");
out.push_str(" }\n");
out.push_str("}\n");
out
}
#[must_use]
pub fn append_schema_table(existing: &str, table: &str, fields: &[Field]) -> String {
if has_table(existing, table) {
return existing.to_owned();
}
let block = schema_table_block(table, fields);
if existing.is_empty() {
return block;
}
let trimmed = existing.trim_end();
format!("{trimmed}\n\n{block}")
}
fn has_table(existing: &str, table: &str) -> bool {
let needle = format!("{table} (");
existing.lines().any(|l| l.trim().starts_with(&needle))
}
#[must_use]
pub fn create_table_sql_with_metadata(
table: &str,
fields: &[Field],
indexes: &BTreeSet<String>,
defaults: &BTreeMap<String, String>,
) -> String {
let mut sql = String::with_capacity(fields.len() * 64 + indexes.len() * 96 + 128);
let _ = writeln!(sql, "CREATE TABLE {table} (");
sql.push_str(" id BIGSERIAL PRIMARY KEY");
for f in fields {
sql.push_str(",\n");
let _ = write!(
sql,
" {} {} {}",
f.name,
f.sql_type(),
f.sql_nullability()
);
if let Some(default) = defaults.get(&f.name) {
let _ = write!(sql, " DEFAULT {default}");
}
}
sql.push_str(",\n created_at TIMESTAMP NOT NULL DEFAULT NOW()\n);\n");
for field_name in indexes {
let _ = writeln!(
sql,
"CREATE INDEX idx_{table}_{field_name} ON {table} ({field_name});"
);
}
sql
}
#[must_use]
pub fn drop_table_sql(table: &str) -> String {
format!("DROP TABLE {table};\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationShape {
AddColumns { table: String },
RemoveColumns { table: String },
Empty,
}
#[must_use]
pub fn detect_migration_shape(pascal_name: &str) -> MigrationShape {
if let Some(rest) = pascal_name.strip_prefix("Add")
&& let Some((_, table)) = split_on_keyword(rest, "To")
{
return MigrationShape::AddColumns {
table: normalize_table_name(&table),
};
}
if let Some(rest) = pascal_name.strip_prefix("Remove")
&& let Some((_, table)) = split_on_keyword(rest, "From")
{
return MigrationShape::RemoveColumns {
table: normalize_table_name(&table),
};
}
MigrationShape::Empty
}
fn normalize_table_name(table_pascal: &str) -> String {
let snake = super::naming::pascal_to_snake(table_pascal);
if snake.ends_with('s') {
snake
} else {
super::naming::pluralize(&snake)
}
}
fn split_on_keyword(s: &str, keyword: &str) -> Option<(String, String)> {
let mut idx = 0;
while let Some(found) = s[idx..].find(keyword) {
let abs = idx + found;
let prev_ok = abs == 0
|| s.as_bytes()[abs - 1].is_ascii_lowercase()
|| s.as_bytes()[abs - 1].is_ascii_digit();
let after_idx = abs + keyword.len();
let after_ok = s
.as_bytes()
.get(after_idx)
.is_some_and(u8::is_ascii_uppercase);
if prev_ok && after_ok {
return Some((s[..abs].to_owned(), s[after_idx..].to_owned()));
}
idx = abs + 1;
}
None
}
#[must_use]
pub fn add_columns_up_sql(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
for f in fields {
let _ = writeln!(
out,
"ALTER TABLE {table} ADD COLUMN {} {} {};",
f.name,
f.sql_type(),
f.sql_nullability()
);
}
out
}
#[must_use]
pub fn add_columns_down_sql(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
for f in fields.iter().rev() {
let _ = writeln!(out, "ALTER TABLE {table} DROP COLUMN {};", f.name);
}
out
}
#[must_use]
pub fn remove_columns_up_sql(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
for f in fields {
let _ = writeln!(out, "ALTER TABLE {table} DROP COLUMN {};", f.name);
}
out
}
#[must_use]
pub fn remove_columns_down_sql(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
for f in fields.iter().rev() {
let _ = writeln!(
out,
"ALTER TABLE {table} ADD COLUMN {} {} {};",
f.name,
f.sql_type(),
f.sql_nullability()
);
}
out
}
#[must_use]
pub fn update_main_rs(existing: &str, mods: &[&str], route_entries: &[String]) -> String {
let with_mods = ensure_mods(existing, mods);
ensure_routes_entries(&with_mods, route_entries)
}
fn ensure_mods(existing: &str, mods: &[&str]) -> String {
use std::fmt::Write;
let mut needed: Vec<&str> = mods
.iter()
.copied()
.filter(|m| !has_mod_declaration(existing, m))
.collect();
if needed.is_empty() {
return existing.to_owned();
}
needed.sort_unstable();
let mut block = String::with_capacity(needed.len() * 15);
for (i, m) in needed.iter().enumerate() {
if i > 0 {
block.push('\n');
}
write!(block, "mod {m};").unwrap();
}
let split = existing
.lines()
.position(|l| {
let t = l.trim_start();
!t.is_empty() && !t.starts_with("//!") && !t.starts_with("#![")
})
.unwrap_or_else(|| existing.lines().count());
if split == 0 {
return format!("{block}\n\n{existing}");
}
let mut out = String::with_capacity(existing.len() + block.len() + 4);
let lines: Vec<&str> = existing.lines().collect();
for line in &lines[..split] {
out.push_str(line);
out.push('\n');
}
out.push_str(&block);
out.push('\n');
if split < lines.len() {
out.push('\n');
for line in &lines[split..] {
out.push_str(line);
out.push('\n');
}
}
if !existing.ends_with('\n') && out.ends_with('\n') {
out.pop();
}
out
}
fn has_mod_declaration(existing: &str, name: &str) -> bool {
let needles = [format!("mod {name};"), format!("pub mod {name};")];
existing
.lines()
.map(str::trim)
.any(|line| needles.iter().any(|n| line == n))
}
fn ensure_routes_entries(existing: &str, entries: &[String]) -> String {
let Some(start) = existing.find("routes![") else {
return existing.to_owned();
};
let body_start = start + "routes![".len();
let mut depth: usize = 1;
let bytes = existing.as_bytes();
let mut i = body_start;
while i < bytes.len() {
match bytes[i] {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
i += 1;
}
if depth != 0 {
return existing.to_owned();
}
let body = &existing[body_start..i];
let new_body = augment_routes_body(body, entries);
let mut out = String::with_capacity(existing.len() + new_body.len());
out.push_str(&existing[..body_start]);
out.push_str(&new_body);
out.push_str(&existing[i..]);
out
}
fn augment_routes_body(body: &str, entries: &[String]) -> String {
let existing_entries: Vec<String> = body
.split([',', '\n'])
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
let mut to_add: Vec<&String> = entries
.iter()
.filter(|e| !existing_entries.iter().any(|ex| ex == e.as_str()))
.collect();
if to_add.is_empty() {
return body.to_owned();
}
let mut seen = std::collections::HashSet::new();
to_add.retain(|s| seen.insert(s.as_str()));
let indent = leading_indent(body);
let trimmed = body.trim_end_matches([' ', '\t']);
let prefix = if trimmed.is_empty() || trimmed.ends_with(',') || trimmed.ends_with('\n') {
""
} else {
","
};
let mut out = String::with_capacity(body.len() + to_add.len() * 32);
out.push_str(trimmed);
out.push_str(prefix);
for entry in to_add {
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&indent);
out.push_str(entry);
out.push(',');
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn leading_indent(body: &str) -> String {
body.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
l.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect::<String>()
})
.max_by_key(String::len)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| " ".to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::dsl::parse_field;
fn fields(tokens: &[&str]) -> Vec<Field> {
tokens.iter().map(|t| parse_field(t).unwrap()).collect()
}
#[test]
fn add_mod_declaration_to_empty() {
assert_eq!(add_mod_declaration("", "post"), "pub mod post;\n");
}
#[test]
fn add_mod_declaration_idempotent() {
let initial = "pub mod post;\n";
assert_eq!(add_mod_declaration(initial, "post"), initial);
}
#[test]
fn add_mod_declaration_appends() {
let initial = "pub mod user;\n";
let after = add_mod_declaration(initial, "post");
assert!(after.contains("pub mod user;"));
assert!(after.contains("pub mod post;"));
}
#[test]
fn add_mod_recognises_private_mod() {
let initial = "mod post;\n";
assert_eq!(add_mod_declaration(initial, "post"), initial);
}
#[test]
fn schema_table_block_minimal() {
let block = schema_table_block("posts", &fields(&["title:String"]));
assert!(block.contains("posts (id)"));
assert!(block.contains("id -> Int8,"));
assert!(block.contains("title -> Text,"));
assert!(block.contains("created_at -> Timestamp,"));
}
#[test]
fn schema_table_block_nullable() {
let block = schema_table_block("posts", &fields(&["body:Option<String>"]));
assert!(block.contains("body -> Nullable<Text>,"));
}
#[test]
fn append_schema_table_idempotent() {
let f = fields(&["title:String"]);
let first = append_schema_table("", "posts", &f);
let second = append_schema_table(&first, "posts", &f);
assert_eq!(first, second);
}
#[test]
fn append_schema_table_to_existing_keeps_old() {
let f1 = fields(&["title:String"]);
let f2 = fields(&["name:String"]);
let first = append_schema_table("", "posts", &f1);
let combined = append_schema_table(&first, "users", &f2);
assert!(combined.contains("posts (id)"));
assert!(combined.contains("users (id)"));
}
#[test]
fn create_table_sql_minimal() {
let sql = create_table_sql_with_metadata(
"posts",
&fields(&["title:String"]),
&BTreeSet::new(),
&BTreeMap::new(),
);
assert!(sql.contains("CREATE TABLE posts ("));
assert!(sql.contains("id BIGSERIAL PRIMARY KEY"));
assert!(sql.contains("title TEXT NOT NULL"));
assert!(sql.contains("created_at TIMESTAMP NOT NULL DEFAULT NOW()"));
}
#[test]
fn create_table_sql_no_extra_fields() {
let sql =
create_table_sql_with_metadata("widgets", &[], &BTreeSet::new(), &BTreeMap::new());
assert!(sql.contains("id BIGSERIAL PRIMARY KEY"));
assert!(sql.contains("created_at"));
}
#[test]
fn create_table_sql_nullable() {
let sql = create_table_sql_with_metadata(
"posts",
&fields(&["body:Option<Text>"]),
&BTreeSet::new(),
&BTreeMap::new(),
);
assert!(sql.contains("body TEXT NULL"));
}
#[test]
fn drop_table_sql_simple() {
assert_eq!(drop_table_sql("posts"), "DROP TABLE posts;\n");
}
#[test]
fn detect_add_migration() {
match detect_migration_shape("AddTitleToPosts") {
MigrationShape::AddColumns { table } => assert_eq!(table, "posts"),
other => panic!("expected AddColumns, got {other:?}"),
}
}
#[test]
fn detect_add_migration_pluralises_singular_subject() {
match detect_migration_shape("AddBodyToPost") {
MigrationShape::AddColumns { table } => assert_eq!(table, "posts"),
other => panic!("expected AddColumns, got {other:?}"),
}
}
#[test]
fn detect_remove_migration() {
match detect_migration_shape("RemoveBodyFromPosts") {
MigrationShape::RemoveColumns { table } => assert_eq!(table, "posts"),
other => panic!("expected RemoveColumns, got {other:?}"),
}
}
#[test]
fn detect_other_migration_is_empty() {
assert!(matches!(
detect_migration_shape("BackfillSomething"),
MigrationShape::Empty
));
}
#[test]
fn detect_does_not_match_partial_keyword() {
assert!(matches!(
detect_migration_shape("AddToolingForBuilds"),
MigrationShape::Empty
));
}
#[test]
fn add_columns_up_sql_emits_alter_per_field() {
let f = fields(&["title:String", "count:i32"]);
let sql = add_columns_up_sql("posts", &f);
assert!(sql.contains("ALTER TABLE posts ADD COLUMN title TEXT NOT NULL;"));
assert!(sql.contains("ALTER TABLE posts ADD COLUMN count INTEGER NOT NULL;"));
}
#[test]
fn add_columns_down_sql_drops_in_reverse() {
let f = fields(&["title:String", "count:i32"]);
let sql = add_columns_down_sql("posts", &f);
let title_pos = sql.find("DROP COLUMN title").unwrap();
let count_pos = sql.find("DROP COLUMN count").unwrap();
assert!(count_pos < title_pos);
}
#[test]
fn update_main_rs_inserts_mod_and_routes() {
let original = r#"use autumn_web::prelude::*;
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#;
let updated = update_main_rs(
original,
&["models", "routes", "schema"],
&["routes::posts::index".to_owned()],
);
assert!(updated.contains("mod models;"));
assert!(updated.contains("mod routes;"));
assert!(updated.contains("mod schema;"));
assert!(updated.contains("routes::posts::index"));
assert!(updated.contains("index,")); }
#[test]
fn update_main_rs_preserves_inner_attributes() {
let original = "#![allow(clippy::needless_pass_by_value)]\n\
#![deny(unsafe_code)]\n\
\n\
use autumn_web::prelude::*;\n\
\n\
#[autumn_web::main]\n\
async fn main() {\n\
autumn_web::app().run().await;\n\
}\n";
let updated = update_main_rs(original, &["models"], &[]);
let attr_pos = updated.find("#![allow").unwrap();
let mod_pos = updated.find("mod models;").unwrap();
assert!(
attr_pos < mod_pos,
"crate inner attributes must stay above mod items:\n{updated}"
);
assert!(updated.contains("#![deny(unsafe_code)]"));
}
#[test]
fn update_main_rs_inserts_after_doc_comment_block() {
let original = "//! Top-level docs.\n\
//! Continuation.\n\
\n\
use autumn_web::prelude::*;\n";
let updated = update_main_rs(original, &["models"], &[]);
let docs_pos = updated.find("//! Top-level docs.").unwrap();
let mod_pos = updated.find("mod models;").unwrap();
assert!(docs_pos < mod_pos);
}
#[test]
fn update_main_rs_idempotent() {
let original = "mod models;\n\
mod routes;\n\
mod schema;\n\
\n\
use autumn_web::prelude::*;\n\
\n\
#[autumn_web::main]\n\
async fn main() {\n\
autumn_web::app()\n\
.routes(routes![\n\
routes::posts::index,\n\
])\n\
.run()\n\
.await;\n\
}\n";
let once = update_main_rs(
original,
&["models", "routes", "schema"],
&["routes::posts::index".to_owned()],
);
let twice = update_main_rs(
&once,
&["models", "routes", "schema"],
&["routes::posts::index".to_owned()],
);
assert_eq!(once, twice);
}
#[test]
fn update_main_rs_no_routes_macro_leaves_file_alone() {
let original = "fn main() {}\n";
let updated = update_main_rs(original, &[], &["foo".into()]);
assert_eq!(updated, original);
}
#[test]
fn ensure_routes_entries_handles_empty_body() {
let original = "fn main() {\n routes![]\n}\n";
let updated = ensure_routes_entries(original, &["foo".into()]);
assert!(updated.contains("foo"));
}
}