use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use sha2::{Digest, Sha256};
use autumn_web::config::DatabaseBackend;
use super::GenerateError;
use super::dsl::{Field, FieldConstraints, FieldKind, IdType};
#[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 remove_mod_declaration(existing: &str, name: &str) -> String {
let line = format!("pub mod {name};");
let lines: Vec<&str> = existing.lines().collect();
let Some(idx) = lines.iter().position(|l| l.trim() == line) else {
return existing.to_owned();
};
remove_single_line(&lines, idx, existing.ends_with('\n'))
}
#[cfg(test)]
#[must_use]
pub fn schema_table_block_with_id(table: &str, fields: &[Field], id_type: IdType) -> String {
schema_table_block_with_id_for(DatabaseBackend::Postgres, table, fields, id_type)
}
#[must_use]
pub fn schema_table_block_with_id_for(
backend: DatabaseBackend,
table: &str,
fields: &[Field],
id_type: IdType,
) -> String {
let mut out = String::with_capacity(fields.len() * 40 + 128);
out.push_str("diesel::table! {\n");
let _ = writeln!(out, " {table} (id) {{");
let _ = writeln!(out, " id -> {},", id_type.schema_type_for(backend));
for f in fields {
let _ = writeln!(out, " {} -> {},", f.name, f.schema_type_for(backend));
}
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 {
append_schema_table_with_id(existing, table, fields, IdType::BigSerial)
}
#[must_use]
pub fn append_schema_table_with_id(
existing: &str,
table: &str,
fields: &[Field],
id_type: IdType,
) -> String {
append_schema_table_with_id_for(DatabaseBackend::Postgres, existing, table, fields, id_type)
}
#[must_use]
pub fn append_schema_table_with_id_for(
backend: DatabaseBackend,
existing: &str,
table: &str,
fields: &[Field],
id_type: IdType,
) -> String {
if has_table(existing, table) {
return existing.to_owned();
}
let block = schema_table_block_with_id_for(backend, table, fields, id_type);
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 remove_schema_table(existing: &str, table: &str, expected_block: &str) -> String {
if !has_table(existing, table) {
return existing.to_owned();
}
let lines: Vec<&str> = existing.lines().collect();
let needle = format!("{table} (");
let Some(table_line_idx) = lines.iter().position(|l| l.trim().starts_with(&needle)) else {
return existing.to_owned();
};
if table_line_idx == 0 || lines[table_line_idx - 1].trim() != "diesel::table! {" {
return existing.to_owned();
}
let open_idx = table_line_idx - 1;
let Some(inner_close_offset) = lines[table_line_idx + 1..]
.iter()
.position(|l| l.trim() == "}")
else {
return existing.to_owned();
};
let inner_close_idx = table_line_idx + 1 + inner_close_offset;
let Some(outer_close_line) = lines.get(inner_close_idx + 1) else {
return existing.to_owned();
};
if outer_close_line.trim() != "}" {
return existing.to_owned();
}
let outer_close_idx = inner_close_idx + 1;
let found_block = format!("{}\n", lines[open_idx..=outer_close_idx].join("\n"));
if found_block != expected_block {
return existing.to_owned();
}
let mut start = open_idx;
if start > 0 && lines[start - 1].trim().is_empty() {
start -= 1;
}
let mut new_lines: Vec<&str> = Vec::with_capacity(lines.len());
new_lines.extend_from_slice(&lines[..start]);
if outer_close_idx + 1 < lines.len() {
new_lines.extend_from_slice(&lines[outer_close_idx + 1..]);
}
let mut out = new_lines.join("\n");
if !out.is_empty() {
out.push('\n');
}
out
}
#[must_use]
pub fn schema_has_table(schema: &str, table: &str) -> bool {
has_table(schema, table)
}
fn existing_schema_columns(schema: &str, table: &str) -> Vec<String> {
let needle = format!("{table} (");
let Some(start) = schema.lines().position(|l| l.trim().starts_with(&needle)) else {
return Vec::new();
};
schema
.lines()
.skip(start + 1)
.take_while(|l| !l.trim().starts_with('}'))
.filter_map(|l| l.trim().trim_end_matches(',').split_once(" -> "))
.map(|(name, _)| name.to_owned())
.collect()
}
#[must_use]
pub fn create_table_sql_with_metadata_and_id(
table: &str,
fields: &[Field],
indexes: &BTreeSet<String>,
defaults: &BTreeMap<String, String>,
id_type: IdType,
) -> String {
create_table_sql_with_metadata_and_id_for(
DatabaseBackend::Postgres,
table,
fields,
indexes,
defaults,
id_type,
)
}
#[must_use]
pub fn create_table_sql_with_metadata_and_id_for(
backend: DatabaseBackend,
table: &str,
fields: &[Field],
indexes: &BTreeSet<String>,
defaults: &BTreeMap<String, String>,
id_type: IdType,
) -> String {
let mut sql = String::with_capacity(fields.len() * 64 + indexes.len() * 96 + 256);
if let Some(comment) = id_type.migration_comment_for(backend) {
sql.push_str(comment);
sql.push('\n');
}
let _ = writeln!(sql, "CREATE TABLE {table} (");
let _ = write!(sql, " id {}", id_type.pk_sql_for(backend));
for f in fields {
sql.push_str(",\n");
let _ = write!(
sql,
" {} {} {}",
f.name,
f.sql_column_type_for(backend),
f.sql_nullability()
);
if let Some(target) = f.reference_table() {
let _ = write!(sql, " REFERENCES {target}(id)");
}
if let Some(default) = defaults.get(&f.name) {
let _ = write!(sql, " DEFAULT {default}");
}
if let Some(check) = enum_check_suffix(f) {
let _ = write!(sql, " {check}");
}
}
match backend {
DatabaseBackend::Postgres => {
sql.push_str(",\n created_at TIMESTAMP NOT NULL DEFAULT NOW()\n);\n");
}
DatabaseBackend::Sqlite => {
sql.push_str(",\n created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n");
}
}
let unique_fields: BTreeSet<&str> = fields
.iter()
.filter(|f| f.unique)
.map(|f| f.name.as_str())
.collect();
let mut index_fields = indexes.clone();
for f in fields {
if f.kind.is_reference() {
index_fields.insert(f.name.clone());
}
}
index_fields.retain(|name| !unique_fields.contains(name.as_str()));
for field_name in &index_fields {
let _ = writeln!(
sql,
"CREATE INDEX idx_{table}_{field_name} ON {table} ({field_name});"
);
}
for field_name in &unique_fields {
sql.push_str(&unique_index_sql(table, field_name, fields));
}
sql
}
#[must_use]
pub fn drop_table_sql(table: &str) -> String {
format!("DROP TABLE {table};\n")
}
const POSTGRES_MAX_IDENTIFIER_LEN: usize = 63;
#[must_use]
pub fn unique_index_name(table: &str, field: &str, fields: &[Field]) -> String {
let full = format!("idx_{table}_{field}_unique");
let collides_with_plain_index = fields.iter().any(|f| f.name == format!("{field}_unique"));
if full.len() <= POSTGRES_MAX_IDENTIFIER_LEN && !collides_with_plain_index {
return full;
}
let digest = hex::encode(Sha256::digest(full.as_bytes()));
let suffix = format!("_{}", &digest[..8]);
let prefix_len = (POSTGRES_MAX_IDENTIFIER_LEN.saturating_sub(suffix.len())).min(full.len());
format!("{}{suffix}", &full[..prefix_len])
}
#[must_use]
pub fn unique_index_sql(table: &str, field: &str, fields: &[Field]) -> String {
let name = unique_index_name(table, field, fields);
format!("CREATE UNIQUE INDEX {name} ON {table} ({field});\n")
}
fn enum_check_suffix(field: &Field) -> Option<String> {
if !field.kind.is_enum() {
return None;
}
let quoted = field
.variants
.iter()
.map(|v| format!("'{v}'"))
.collect::<Vec<_>>()
.join(", ");
Some(format!("CHECK ({} IN ({quoted}))", field.name))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationShape {
AddColumns { table: String },
RemoveColumns { table: String },
AddSearch { table: String },
EncryptColumns { table: String, columns: Vec<String> },
Empty,
}
#[must_use]
pub fn detect_migration_shape(pascal_name: &str) -> MigrationShape {
if let Some(rest) = pascal_name.strip_prefix("AddSearchTo")
&& rest.chars().next().is_some_and(char::is_uppercase)
{
return MigrationShape::AddSearch {
table: normalize_table_name(rest),
};
}
if let Some(rest) = pascal_name.strip_prefix("AddSearchableTo")
&& rest.chars().next().is_some_and(char::is_uppercase)
{
return MigrationShape::AddSearch {
table: normalize_table_name(rest),
};
}
if let Some(rest) = pascal_name.strip_prefix("AddSearchVectorTo")
&& rest.chars().next().is_some_and(char::is_uppercase)
{
return MigrationShape::AddSearch {
table: normalize_table_name(rest),
};
}
if let Some(rest) = pascal_name.strip_prefix("Encrypt")
&& rest.chars().next().is_some_and(char::is_uppercase)
&& let Some((cols, table)) = split_on_keyword(rest, "On")
{
return MigrationShape::EncryptColumns {
table: normalize_table_name(&table),
columns: vec![super::naming::pascal_to_snake(&cols)],
};
}
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
}
fn fields_with_existing_schema_columns(
fields: &[Field],
existing_schema: &str,
table: &str,
) -> Vec<Field> {
let mut combined = fields.to_vec();
for name in existing_schema_columns(existing_schema, table) {
if !combined.iter().any(|f| f.name == name) {
combined.push(Field {
name,
kind: FieldKind::String,
nullable: false,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
});
}
}
combined
}
#[cfg(test)]
#[must_use]
pub fn add_columns_up_sql(table: &str, fields: &[Field], existing_schema: &str) -> String {
add_columns_up_sql_for(DatabaseBackend::Postgres, table, fields, existing_schema)
.expect("Postgres ADD COLUMN generation never rejects")
}
pub fn add_columns_up_sql_for(
backend: DatabaseBackend,
table: &str,
fields: &[Field],
existing_schema: &str,
) -> Result<String, GenerateError> {
let collision_fields = fields_with_existing_schema_columns(fields, existing_schema, table);
let mut out = String::new();
for f in fields {
if backend == DatabaseBackend::Sqlite && !f.nullable {
return Err(super::sqlite_add_not_null_without_default_error(
table, &f.name,
));
}
if !f.nullable {
let _ = writeln!(
out,
"-- autumn-safety: potentially-blocking \
-- add a DEFAULT or backfill existing rows before enforcing NOT NULL"
);
}
let _ = write!(
out,
"ALTER TABLE {table} ADD COLUMN {} {} {}",
f.name,
f.sql_column_type_for(backend),
f.sql_nullability()
);
if let Some(target) = f.reference_table() {
let _ = write!(out, " REFERENCES {target}(id)");
}
if let Some(check) = enum_check_suffix(f) {
let _ = write!(out, " {check}");
}
out.push_str(";\n");
if f.kind.is_reference() && !f.unique {
let _ = writeln!(
out,
"CREATE INDEX idx_{table}_{} ON {table} ({});",
f.name, f.name
);
}
if f.unique {
out.push_str(&unique_index_sql(table, &f.name, &collision_fields));
}
}
Ok(out)
}
#[cfg(test)]
#[must_use]
pub fn add_columns_down_sql(table: &str, fields: &[Field]) -> String {
add_columns_down_sql_for(DatabaseBackend::Postgres, table, fields, "")
}
#[must_use]
pub fn add_columns_down_sql_for(
backend: DatabaseBackend,
table: &str,
fields: &[Field],
existing_schema: &str,
) -> String {
let collision_fields = fields_with_existing_schema_columns(fields, existing_schema, table);
let mut out = String::new();
for f in fields.iter().rev() {
if backend == DatabaseBackend::Sqlite {
if f.unique {
let name = unique_index_name(table, &f.name, &collision_fields);
let _ = writeln!(out, "DROP INDEX {name};");
} else if f.kind.is_reference() {
let _ = writeln!(out, "DROP INDEX idx_{table}_{};", f.name);
}
}
let _ = writeln!(out, "ALTER TABLE {table} DROP COLUMN {};", f.name);
}
out
}
fn write_widen_bounded_columns_note(out: &mut String, table: &str, columns: &[String]) {
let _ = writeln!(
out,
"-- The envelope is base64 text. An UNBOUNDED `TEXT` column needs no type"
);
let _ = writeln!(
out,
"-- change, but a BOUNDED `VARCHAR(n)` column almost certainly does: the"
);
let _ = writeln!(
out,
"-- envelope adds a 20-byte header + 16-byte GCM tag and is then base64-"
);
let _ = writeln!(
out,
"-- encoded (~1.37x), so e.g. a VARCHAR(255) value can grow past 255 chars"
);
let _ = writeln!(
out,
"-- and the backfill (or later writes) will fail with a length violation."
);
let _ = writeln!(
out,
"-- Widen bounded columns to TEXT (or a sufficiently larger limit) FIRST:"
);
for col in columns {
let _ = writeln!(
out,
"-- ALTER TABLE {table} ALTER COLUMN {col} TYPE TEXT;"
);
}
}
#[must_use]
pub fn encrypt_columns_up_sql(table: &str, columns: &[String]) -> String {
let mut out = String::with_capacity(1024);
let _ = writeln!(
out,
"-- autumn-safety: backfill \
-- run the offline encryption backfill BEFORE deploying readers that \
expect ciphertext"
);
let _ = writeln!(out, "--");
let _ = writeln!(
out,
"-- Convert plaintext column(s) on `{table}` to at-rest encryption (#805)."
);
write_widen_bounded_columns_note(&mut out, table, columns);
let _ = writeln!(out, "--");
let _ = writeln!(out, "-- 1. Configure keys (once). The salt is required:");
let _ = writeln!(out, "-- autumn credentials edit");
let _ = writeln!(out, "-- [active_record_encryption]");
let _ = writeln!(
out,
"-- primary_key = \"<openssl rand -hex 32>\""
);
let _ = writeln!(
out,
"-- key_derivation_salt = \"<openssl rand -hex 16>\""
);
let _ = writeln!(
out,
"-- # deterministic_key = \"<openssl rand -hex 32>\" # for deterministic / versioned_ciphertext"
);
let _ = writeln!(out, "--");
let _ = writeln!(
out,
"-- 2. Backfill BEFORE adding `#[encrypted]` to the model field. Once the"
);
let _ = writeln!(
out,
"-- attribute is present the column's reader decrypts on load, so any"
);
let _ = writeln!(
out,
"-- still-plaintext row would fail with a malformed-envelope error."
);
let _ = writeln!(
out,
"-- Run a one-off task over a TEMPORARY plaintext model (no `#[encrypted]`)"
);
let _ = writeln!(
out,
"-- that reads each row's plaintext and writes the envelope produced by"
);
let _ = writeln!(
out,
"-- autumn_web::encryption::encrypt_text(<mode>, &plaintext), where <mode> is"
);
let _ = writeln!(
out,
"-- Mode::Deterministic for columns you will deploy as"
);
let _ = writeln!(
out,
"-- `#[encrypted(deterministic)]` (so existing rows are found by equality"
);
let _ = writeln!(out, "-- lookups) and Mode::Randomized otherwise:");
for col in columns {
let _ = writeln!(
out,
"-- UPDATE {table} SET {col} = <encrypt_text({col})>;"
);
}
let _ = writeln!(out, "--");
let _ = writeln!(
out,
"-- 3. Only after every row is ciphertext, add `#[encrypted]` to the field"
);
let _ = writeln!(out, "-- and deploy the encrypted reader.");
let _ = writeln!(out, "--");
let _ = writeln!(
out,
"-- Take a backup first: a row encrypted with a lost key is unrecoverable."
);
out
}
#[must_use]
pub fn encrypt_columns_down_sql(table: &str, columns: &[String]) -> String {
let mut out = String::with_capacity(512);
let _ = writeln!(
out,
"-- Rollback: restore plaintext from ciphertext on `{table}` (#805)."
);
let _ = writeln!(
out,
"-- Run a one-off task that decrypts each row with the configured keys via"
);
let _ = writeln!(
out,
"-- autumn_web::encryption::decrypt_text(&envelope) and writes plaintext back:"
);
for col in columns {
let _ = writeln!(out, "-- UPDATE {table} SET {col} = <decrypt({col})>;");
}
let _ = writeln!(
out,
"-- Then remove the `#[encrypted]` attribute from the model field."
);
out
}
#[cfg(test)]
#[must_use]
pub fn remove_columns_up_sql(table: &str, fields: &[Field]) -> String {
remove_columns_up_sql_for(DatabaseBackend::Postgres, table, fields, "")
}
#[must_use]
pub fn remove_columns_up_sql_for(
backend: DatabaseBackend,
table: &str,
fields: &[Field],
existing_schema: &str,
) -> String {
let collision_fields = fields_with_existing_schema_columns(fields, existing_schema, table);
let mut out = String::new();
for f in fields {
let _ = writeln!(
out,
"-- autumn-safety: destructive \
-- old replicas that reference this column will fail until restarted; \
use expand/contract"
);
if backend == DatabaseBackend::Sqlite {
let _ = writeln!(out, "DROP INDEX IF EXISTS idx_{table}_{};", f.name);
if f.unique {
let name = unique_index_name(table, &f.name, &collision_fields);
let _ = writeln!(out, "DROP INDEX IF EXISTS {name};");
}
}
let _ = writeln!(out, "ALTER TABLE {table} DROP COLUMN {};", f.name);
}
out
}
#[cfg(test)]
#[must_use]
pub fn remove_columns_down_sql(table: &str, fields: &[Field], existing_schema: &str) -> String {
remove_columns_down_sql_for(DatabaseBackend::Postgres, table, fields, existing_schema)
.expect("Postgres ADD COLUMN generation never rejects")
}
pub fn remove_columns_down_sql_for(
backend: DatabaseBackend,
table: &str,
fields: &[Field],
existing_schema: &str,
) -> Result<String, GenerateError> {
let collision_fields = fields_with_existing_schema_columns(fields, existing_schema, table);
let mut out = String::new();
for f in fields.iter().rev() {
if backend == DatabaseBackend::Sqlite && !f.nullable {
return Err(super::sqlite_add_not_null_without_default_error(
table, &f.name,
));
}
let _ = write!(
out,
"ALTER TABLE {table} ADD COLUMN {} {} {}",
f.name,
f.sql_column_type_for(backend),
f.sql_nullability()
);
if let Some(target) = f.reference_table() {
let _ = write!(out, " REFERENCES {target}(id)");
}
if let Some(check) = enum_check_suffix(f) {
let _ = write!(out, " {check}");
}
out.push_str(";\n");
if f.kind.is_reference() && !f.unique {
let _ = writeln!(
out,
"CREATE INDEX idx_{table}_{} ON {table} ({});",
f.name, f.name
);
}
if f.unique {
out.push_str(&unique_index_sql(table, &f.name, &collision_fields));
}
}
Ok(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))
}
#[must_use]
pub fn remove_main_mod_declarations(existing: &str, names: &[&str]) -> String {
let lines: Vec<&str> = existing.lines().collect();
let patterns: Vec<String> = names.iter().map(|n| format!("mod {n};")).collect();
let matches_any = |line: &str| patterns.iter().any(|p| line.trim() == p);
if !lines.iter().any(|l| matches_any(l)) {
return existing.to_owned();
}
let kept: Vec<&str> = lines.into_iter().filter(|l| !matches_any(l)).collect();
let mut collapsed: Vec<&str> = Vec::with_capacity(kept.len());
for line in kept {
if line.trim().is_empty() && collapsed.last().is_some_and(|l: &&str| l.trim().is_empty()) {
continue;
}
collapsed.push(line);
}
while collapsed.first().is_some_and(|l| l.trim().is_empty()) {
collapsed.remove(0);
}
let mut out = collapsed.join("\n");
if existing.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
out
}
#[must_use]
pub fn link_models_into_seed_bin(existing: &str) -> String {
let entries: [(&str, &str); 2] = [
("schema", "#[path = \"../schema.rs\"]\nmod schema;"),
("models", "#[path = \"../models/mod.rs\"]\nmod models;"),
];
let needed: Vec<&str> = entries
.iter()
.filter(|(name, _)| !has_mod_declaration(existing, name))
.map(|(_, decl)| *decl)
.collect();
if needed.is_empty() {
return existing.to_owned();
}
let block = needed.join("\n");
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
}
#[must_use]
pub fn unlink_models_from_seed_bin(existing: &str) -> String {
let blocks: [[&str; 2]; 2] = [
["#[path = \"../schema.rs\"]", "mod schema;"],
["#[path = \"../models/mod.rs\"]", "mod models;"],
];
let mut lines: Vec<String> = existing.lines().map(str::to_owned).collect();
for [attr, decl] in blocks {
let mut i = 0;
while i + 1 < lines.len() {
if lines[i].trim() == attr && lines[i + 1].trim() == decl {
lines.drain(i..i + 2);
} else {
i += 1;
}
}
}
let mut out_lines: Vec<String> = Vec::with_capacity(lines.len());
let mut prev_blank = true; for line in lines {
let is_blank = line.trim().is_empty();
if is_blank && prev_blank {
continue;
}
prev_blank = is_blank;
out_lines.push(line);
}
let mut out = out_lines.join("\n");
if !out.is_empty() && existing.ends_with('\n') {
out.push('\n');
}
out
}
fn is_on_comment_line(text: &str, pos: usize) -> bool {
let line_start = text[..pos].rfind('\n').map_or(0, |nl| nl + 1);
let mut in_string = false;
let mut chars = text[line_start..pos].chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' if in_string => {
chars.next();
}
'"' => in_string = !in_string,
'/' if !in_string && chars.peek() == Some(&'/') => return true,
_ => {}
}
}
false
}
fn find_routes_body_range(existing: &str) -> Option<(usize, usize)> {
let mut search_from = 0;
let start = loop {
let pos = search_from + existing[search_from..].find("routes![")?;
if !is_on_comment_line(existing, pos) {
break pos;
}
search_from = pos + "routes![".len();
};
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 None;
}
Some((body_start, i))
}
fn ensure_routes_entries(existing: &str, entries: &[String]) -> String {
let Some((body_start, body_end)) = find_routes_body_range(existing) else {
return existing.to_owned();
};
let body = &existing[body_start..body_end];
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[body_end..]);
out
}
#[must_use]
pub fn remove_routes_entries_with_prefix(existing: &str, prefix: &str) -> String {
let Some((body_start, body_end)) = find_routes_body_range(existing) else {
return existing.to_owned();
};
let body = &existing[body_start..body_end];
let original_entries: Vec<&str> = body
.split([',', '\n'])
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
let kept: Vec<&str> = original_entries
.iter()
.copied()
.filter(|s| !s.starts_with(prefix))
.collect();
if kept.len() == original_entries.len() {
return existing.to_owned();
}
let indent = leading_indent(body);
let mut new_body = String::with_capacity(body.len());
for entry in &kept {
new_body.push_str(&indent);
new_body.push_str(entry);
new_body.push_str(",\n");
}
let mut out = String::with_capacity(existing.len());
out.push_str(&existing[..body_start]);
out.push_str(&new_body);
out.push_str(&existing[body_end..]);
out
}
#[must_use]
pub fn remove_routes_entries(existing: &str, entries: &[String]) -> String {
if entries.is_empty() {
return existing.to_owned();
}
let Some((body_start, body_end)) = find_routes_body_range(existing) else {
return existing.to_owned();
};
let body = &existing[body_start..body_end];
let present_entries: Vec<String> = body
.split([',', '\n'])
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
if !entries.iter().any(|e| present_entries.contains(e)) {
return existing.to_owned();
}
let kept: Vec<&String> = present_entries
.iter()
.filter(|e| !entries.contains(e))
.collect();
let spans = entry_spans(body);
let first_removed_at = entries
.iter()
.filter_map(|e| {
spans
.iter()
.find(|(_, _, text)| text == e)
.map(|(start, ..)| *start)
})
.min()
.unwrap_or(body.len());
let original_segment = &body[..first_removed_at];
let original_core = original_segment.trim_end_matches([' ', '\t', '\n', ',']);
let multiline = original_core.contains('\n');
let new_body = if multiline {
let indent = leading_indent(body);
let mut out = String::with_capacity(body.len());
for entry in &kept {
out.push_str(&indent);
out.push_str(entry);
out.push_str(",\n");
}
out
} else {
kept.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
};
let mut out = String::with_capacity(existing.len());
out.push_str(&existing[..body_start]);
out.push_str(&new_body);
out.push_str(&existing[body_end..]);
out
}
fn entry_spans(body: &str) -> Vec<(usize, usize, &str)> {
let mut spans = Vec::new();
let mut offset = 0usize;
for piece in body.split_inclusive([',', '\n']) {
let piece_start = offset;
offset += piece.len();
let trimmed = piece.trim_matches([',', '\n', ' ', '\t', '\r']);
if trimmed.is_empty() {
continue;
}
let Some(inner_offset) = piece.find(trimmed) else {
continue;
};
let start = piece_start + inner_offset;
spans.push((start, start + trimmed.len(), trimmed));
}
spans
}
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())
}
#[must_use]
pub fn add_mail_preview_to_app(existing: &str, mailer_type: &str) -> String {
const PREVIEW_MACRO: &str = "mail_previews![";
existing.find(PREVIEW_MACRO).map_or_else(
|| insert_mail_previews_call(existing, mailer_type),
|macro_start| {
augment_mail_previews_list(existing, macro_start + PREVIEW_MACRO.len(), mailer_type)
},
)
}
fn splice_into_macro_body(existing: &str, body_start: usize, entry: &str) -> String {
let rest = &existing[body_start..];
let Some(end_offset) = rest.find(']') else {
return existing.to_owned();
};
let body = &rest[..end_offset];
if body.split(',').map(str::trim).any(|t| t == entry) {
return existing.to_owned();
}
let trimmed_body = body.trim().trim_end_matches(',');
let separator = if trimmed_body.is_empty() { "" } else { ", " };
let new_body = format!("{trimmed_body}{separator}{entry}");
[
&existing[..body_start],
&new_body,
&existing[body_start + end_offset..],
]
.concat()
}
fn augment_mail_previews_list(existing: &str, body_start: usize, mailer_type: &str) -> String {
splice_into_macro_body(existing, body_start, mailer_type)
}
fn insert_before_run_call(existing: &str, line_to_insert: &str) -> String {
let mut out = String::with_capacity(existing.len() + line_to_insert.len() + 4);
let mut inserted = false;
for line in existing.lines() {
let trimmed = line.trim_start();
if !inserted && trimmed.starts_with(".run()") {
let indent_len = line.len() - trimmed.len();
let indent = &line[..indent_len];
out.push_str(indent);
out.push_str(line_to_insert);
out.push('\n');
inserted = true;
}
out.push_str(line);
out.push('\n');
}
if !existing.ends_with('\n') && out.ends_with('\n') {
out.pop();
}
out
}
const REMEMBER_LAYER_CALL: &str = ".layer(axum::middleware::from_fn(routes::auth::remember_me))";
const REMEMBER_STARTUP_CALL: &str = ".on_startup(routes::auth::remember_me_startup)";
#[must_use]
pub fn add_remember_middleware_to_app(existing: &str) -> String {
if existing.contains(REMEMBER_LAYER_CALL) {
return existing.to_owned();
}
let with_startup = insert_before_run_call(existing, REMEMBER_STARTUP_CALL);
insert_before_run_call(&with_startup, REMEMBER_LAYER_CALL)
}
#[must_use]
pub fn remove_remember_middleware_from_app(existing: &str) -> String {
let is_injected = |l: &str| {
let t = l.trim();
t == REMEMBER_LAYER_CALL || t == REMEMBER_STARTUP_CALL
};
if !existing.lines().any(is_injected) {
return existing.to_owned();
}
let kept: Vec<&str> = existing.lines().filter(|l| !is_injected(l)).collect();
let mut out = kept.join("\n");
if existing.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
out
}
fn insert_mail_previews_call(existing: &str, mailer_type: &str) -> String {
insert_before_run_call(
existing,
&format!(".mail_previews(mail_previews![{mailer_type}])"),
)
}
#[must_use]
pub fn remove_mail_preview_from_app(existing: &str, mailer_type: &str) -> String {
const PREVIEW_MACRO: &str = "mail_previews![";
const CALL_PREFIX: &str = ".mail_previews(";
let Some((spliced, now_empty)) =
remove_entry_from_bracketed_list(existing, PREVIEW_MACRO, mailer_type)
else {
return existing.to_owned();
};
if !now_empty {
return spliced;
}
let Some(call_pos) = existing.find(CALL_PREFIX) else {
return existing.to_owned();
};
let Some(call_end) = find_balanced_close_paren(existing, call_pos + CALL_PREFIX.len()) else {
return existing.to_owned();
};
let start_line = existing[..call_pos].rfind('\n').map_or(0, |i| i + 1);
let end_line = existing[call_end..]
.find('\n')
.map_or(existing.len(), |i| call_end + i + 1);
let mut out = String::with_capacity(existing.len());
out.push_str(&existing[..start_line]);
out.push_str(&existing[end_line..]);
out
}
fn find_balanced_close_paren(src: &str, start: usize) -> Option<usize> {
let bytes = src.as_bytes();
let mut depth = 1usize;
let mut i = start;
while i < bytes.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some(i + 1);
}
}
_ => {}
}
i += 1;
}
None
}
fn remove_entry_from_bracketed_list(
existing: &str,
macro_literal: &str,
entry: &str,
) -> Option<(String, bool)> {
let macro_start = existing.find(macro_literal)?;
let body_start = macro_start + macro_literal.len();
let rest = &existing[body_start..];
let end_offset = rest.find(']')?;
let body = &rest[..end_offset];
let items: Vec<&str> = body
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if !items.contains(&entry) {
return None;
}
let remaining: Vec<&str> = items.into_iter().filter(|s| s != &entry).collect();
let now_empty = remaining.is_empty();
let new_body = remaining.join(", ");
let mut out = String::with_capacity(existing.len());
out.push_str(&existing[..body_start]);
out.push_str(&new_body);
out.push_str(&existing[body_start + end_offset..]);
Some((out, now_empty))
}
#[must_use]
pub fn add_jobs_registration_to_app(existing: &str) -> String {
const JOBS_CALL: &str = ".jobs(jobs::registered_jobs())";
if existing.contains(JOBS_CALL) {
return existing.to_owned();
}
insert_jobs_call(existing)
}
fn insert_jobs_call(existing: &str) -> String {
insert_before_run_call(existing, ".jobs(jobs::registered_jobs())")
}
#[must_use]
pub fn augment_registered_jobs(existing: &str, entry: &str) -> String {
const JOBS_MACRO: &str = "jobs![";
existing.find(JOBS_MACRO).map_or_else(
|| {
let trimmed = existing.trim_end();
let sep = if trimmed.is_empty() { "" } else { "\n\n" };
format!(
"{trimmed}{sep}#[must_use]\npub fn registered_jobs() -> Vec<autumn_web::job::JobInfo> {{\n autumn_web::jobs![{entry}]\n}}\n"
)
},
|macro_start| {
let body_start = macro_start + JOBS_MACRO.len();
splice_jobs_list(existing, body_start, entry)
},
)
}
fn splice_jobs_list(existing: &str, body_start: usize, entry: &str) -> String {
splice_into_macro_body(existing, body_start, entry)
}
#[must_use]
pub fn remove_job_entry(existing: &str, entry: &str) -> String {
const JOBS_MACRO: &str = "jobs![";
let Some((spliced, now_empty)) = remove_entry_from_bracketed_list(existing, JOBS_MACRO, entry)
else {
return existing.to_owned();
};
if now_empty {
remove_registered_jobs_fn(existing)
} else {
spliced
}
}
fn remove_registered_jobs_fn(existing: &str) -> String {
let lines: Vec<&str> = existing.lines().collect();
let Some(fn_line_idx) = lines
.iter()
.position(|l| l.trim_start().starts_with("pub fn registered_jobs("))
else {
return existing.to_owned();
};
let start = if fn_line_idx > 0 && lines[fn_line_idx - 1].trim() == "#[must_use]" {
fn_line_idx - 1
} else {
fn_line_idx
};
let Some(close_offset) = lines[fn_line_idx..].iter().position(|l| l.trim() == "}") else {
return existing.to_owned();
};
let end = fn_line_idx + close_offset;
let mut effective_start = start;
if effective_start > 0 && lines[effective_start - 1].trim().is_empty() {
effective_start -= 1;
}
let mut new_lines: Vec<&str> = Vec::with_capacity(lines.len());
new_lines.extend_from_slice(&lines[..effective_start]);
if end + 1 < lines.len() {
new_lines.extend_from_slice(&lines[end + 1..]);
}
let mut out = new_lines.join("\n");
if !out.is_empty() {
out.push('\n');
}
out
}
#[must_use]
pub fn remove_jobs_registration_from_app(existing: &str) -> String {
const JOBS_CALL: &str = ".jobs(jobs::registered_jobs())";
let lines: Vec<&str> = existing.lines().collect();
let Some(idx) = lines.iter().position(|l| l.trim() == JOBS_CALL) else {
return existing.to_owned();
};
remove_single_line(&lines, idx, existing.ends_with('\n'))
}
fn policy_registration_call(model_path: &str, pascal: &str, snake: &str) -> String {
format!(".policy::<{model_path}, _>(crate::policies::{snake}::{pascal}Policy::default())")
}
fn scope_registration_call(model_path: &str, pascal: &str, snake: &str) -> String {
format!(".scope::<{model_path}, _>(crate::policies::{snake}::{pascal}Scope::default())")
}
fn is_policy_registration_line(line: &str, pascal: &str, snake: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with(".policy::<")
&& trimmed.ends_with(&format!(
"(crate::policies::{snake}::{pascal}Policy::default())"
))
}
fn is_scope_registration_line(line: &str, pascal: &str, snake: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with(".scope::<")
&& trimmed.ends_with(&format!(
"(crate::policies::{snake}::{pascal}Scope::default())"
))
}
#[must_use]
pub fn add_policy_registration_to_app(
existing: &str,
model_path: &str,
pascal: &str,
snake: &str,
) -> String {
if existing
.lines()
.any(|l| is_policy_registration_line(l, pascal, snake))
{
return existing.to_owned();
}
let with_policy = insert_before_run_call(
existing,
&policy_registration_call(model_path, pascal, snake),
);
insert_before_run_call(
&with_policy,
&scope_registration_call(model_path, pascal, snake),
)
}
#[must_use]
pub fn remove_policy_registration_from_app(existing: &str, pascal: &str, snake: &str) -> String {
let is_reg = |l: &str| {
is_policy_registration_line(l, pascal, snake)
|| is_scope_registration_line(l, pascal, snake)
};
if !existing.lines().any(is_reg) {
return existing.to_owned();
}
let kept: Vec<&str> = existing.lines().filter(|l| !is_reg(l)).collect();
let mut out = kept.join("\n");
if existing.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
out
}
fn split_toml_inline_comment(s: &str) -> (&str, &str) {
let mut in_string = false;
let mut prev_was_backslash = false;
for (i, c) in s.char_indices() {
if in_string {
if c == '\\' && !prev_was_backslash {
prev_was_backslash = true;
continue;
} else if c == '"' && !prev_was_backslash {
in_string = false;
}
} else if c == '"' {
in_string = true;
} else if c == '#' {
return (&s[..i], &s[i..]);
}
prev_was_backslash = false;
}
(s, "")
}
fn splice_feature_at(
lines: &[&str],
feat_idx: usize,
new_feat_line: &str,
original_line: &str,
feature_quoted: &str,
ends_with_newline: bool,
) -> String {
let mut out = String::with_capacity(lines.len() * 40);
if new_feat_line == original_line {
let close_idx = lines[feat_idx..]
.iter()
.position(|l| {
l.trim()
.split_once('#')
.map_or_else(|| l.trim(), |(before, _)| before.trim())
== "]"
})
.map_or(feat_idx, |p| feat_idx + p);
let indent = lines
.get(feat_idx + 1)
.filter(|l| !l.trim().is_empty() && l.trim() != "]")
.map_or(" ", |l| &l[..l.len() - l.trim_start().len()]);
let new_entry = format!("{indent}{feature_quoted},");
for (k, &l) in lines.iter().enumerate() {
if k == close_idx {
out.push_str(&new_entry);
out.push('\n');
out.push_str(l);
} else if k + 1 == close_idx && k > feat_idx {
let (code, comment) = split_toml_inline_comment(l);
let code_trimmed = code.trim_end();
if !code_trimmed.is_empty() && !code_trimmed.ends_with(',') {
out.push_str(code_trimmed);
out.push(',');
if !comment.is_empty() {
out.push(' ');
out.push_str(comment.trim_start());
}
} else {
out.push_str(l);
}
} else {
out.push_str(l);
}
out.push('\n');
}
} else {
for (k, &l) in lines.iter().enumerate() {
out.push_str(if k == feat_idx { new_feat_line } else { l });
out.push('\n');
}
}
if !ends_with_newline {
out.pop();
}
out
}
fn add_feature_to_multiline_inline_table(
lines: &[&str],
open_idx: usize,
existing: &str,
feature: &str,
feature_quoted: &str,
) -> Option<String> {
let close_idx = lines[open_idx + 1..]
.iter()
.position(|l| l.trim_start().starts_with('}'))
.map(|p| open_idx + 1 + p)?;
if lines[open_idx..=close_idx].iter().any(|l| {
let line_code = l.split_once('#').map_or(*l, |(before, _)| before);
line_code.contains(feature_quoted)
}) {
return Some(existing.to_owned());
}
for (j, &sec_line) in lines[open_idx + 1..close_idx].iter().enumerate() {
if sec_line.trim_start().starts_with("features") {
return Some(splice_feature_at(
lines,
open_idx + 1 + j,
&rewrite_features_line(sec_line, feature),
sec_line,
feature_quoted,
existing.ends_with('\n'),
));
}
}
let indent = lines[close_idx]
.chars()
.take_while(char::is_ascii_whitespace)
.collect::<String>();
let new_feat = format!("{indent}features = [{feature_quoted}],");
let last_entry_idx = lines[open_idx + 1..close_idx]
.iter()
.rposition(|l| !l.trim().is_empty())
.map(|p| open_idx + 1 + p);
let mut out = String::with_capacity(existing.len() + 32);
for (k, &l) in lines.iter().enumerate() {
if k == close_idx {
out.push_str(&new_feat);
out.push('\n');
}
if Some(k) == last_entry_idx && !l.trim_end().ends_with(',') {
out.push_str(l.trim_end());
out.push(',');
} else {
out.push_str(l);
}
out.push('\n');
}
if !existing.ends_with('\n') {
out.pop();
}
Some(out)
}
fn patch_dotted_dep(
lines: &[&str],
dep_line_idx: usize,
existing: &str,
dep_name: &str,
feature: &str,
feature_quoted: &str,
section: &str,
) -> String {
let section_end = lines[dep_line_idx + 1..]
.iter()
.position(|l| is_section_boundary(l.trim(), section))
.map_or(lines.len(), |p| dep_line_idx + 1 + p);
if lines[dep_line_idx..section_end].iter().any(|l| {
let line_code = l.split_once('#').map_or(*l, |(before, _)| before);
line_code
.trim_start()
.strip_prefix(dep_name)
.is_some_and(|rest| rest.starts_with('.'))
&& line_code.contains(feature_quoted)
}) {
return existing.to_owned();
}
let features_key = format!("{dep_name}.features");
for (j, &sec_line) in lines[dep_line_idx..section_end].iter().enumerate() {
if sec_line.trim_start().starts_with(&features_key) {
let new_line = rewrite_features_line(sec_line, feature);
let mut out = String::with_capacity(existing.len() + 32);
for (k, &l) in lines.iter().enumerate() {
out.push_str(if k == dep_line_idx + j { &new_line } else { l });
out.push('\n');
}
if !existing.ends_with('\n') {
out.pop();
}
return out;
}
}
let new_feat = format!("{features_key} = [{feature_quoted}]");
let mut out = String::with_capacity(existing.len() + new_feat.len() + 2);
for (k, &l) in lines.iter().enumerate() {
out.push_str(l);
out.push('\n');
if k == dep_line_idx {
out.push_str(&new_feat);
out.push('\n');
}
}
if !existing.ends_with('\n') {
out.pop();
}
out
}
#[must_use]
pub fn ensure_autumn_web_feature(existing: &str, feature: &str) -> String {
ensure_autumn_web_feature_status(existing, feature).0
}
#[must_use]
pub fn remove_autumn_web_feature(existing: &str, feature: &str) -> String {
let dep_key = resolve_autumn_web_dep_key(existing, "dependencies");
remove_dep_feature_in_section(existing, dep_key, feature, "dependencies", true)
}
#[must_use]
pub fn remove_autumn_web_dev_dependency_feature(existing: &str, feature: &str) -> String {
let dep_key = resolve_autumn_web_dep_key(existing, "dev-dependencies");
remove_dep_feature_in_section(existing, dep_key, feature, "dev-dependencies", false)
}
fn resolve_autumn_web_dep_key(existing: &str, section: &str) -> &'static str {
let lines: Vec<&str> = existing.lines().collect();
let mut in_section = false;
for &line in &lines {
let trimmed = line.trim();
if is_section_header(trimmed, section) {
in_section = true;
continue;
}
if in_section && is_section_boundary(trimmed, section) {
in_section = false;
continue;
}
if !in_section || trimmed.starts_with('#') {
continue;
}
let after_ws = line.trim_start();
if let Some(rest) = after_ws.strip_prefix("autumn-web") {
if rest.starts_with('.') || rest.trim_start().starts_with('=') {
return "autumn-web";
}
continue;
}
let Some((key, val)) = after_ws.split_once('=') else {
continue;
};
let key_trimmed = key.trim();
let alias = key_trimmed
.split_once('.')
.map_or(key_trimmed, |(base, _)| base);
if alias.replace('-', "_") == "autumn_web" && declares_package(val, "autumn-web") {
return "autumn_web";
}
}
let literal_subtable_key = format!("[{section}.autumn-web]");
if lines
.iter()
.any(|l| l.trim().split('#').next().unwrap_or("").trim() == literal_subtable_key)
{
return "autumn-web";
}
let renamed_subtable_key = format!("[{section}.autumn_web]");
if find_section_start_with_autumn_web_package(&lines, &renamed_subtable_key).is_some() {
return "autumn_web";
}
"autumn-web"
}
enum FeatureRemovalEdit {
Replace(String),
Delete,
Unchanged,
}
fn remove_dep_feature_in_section(
existing: &str,
dep_name: &str,
feature: &str,
section: &str,
collapse_to_bare: bool,
) -> String {
let feature_quoted = format!("\"{feature}\"");
let lines: Vec<&str> = existing.lines().collect();
let dotted_features_key = format!("{dep_name}.features");
let mut in_section = false;
for (i, &line) in lines.iter().enumerate() {
let trimmed = line.trim();
if is_section_header(trimmed, section) {
in_section = true;
continue;
}
if in_section && is_section_boundary(trimmed, section) {
in_section = false;
continue;
}
if !in_section {
continue;
}
if line.trim_start().starts_with(&dotted_features_key)
&& let Some((new_line, now_empty)) =
remove_feature_from_features_line(line, &feature_quoted)
{
return if now_empty {
remove_single_line(&lines, i, existing.ends_with('\n'))
} else {
splice_single_line(&lines, i, &new_line, existing.ends_with('\n'))
};
}
let edit = parse_feature_removal(line, dep_name, &feature_quoted, collapse_to_bare);
match edit {
FeatureRemovalEdit::Replace(new_line) => {
return splice_single_line(&lines, i, &new_line, existing.ends_with('\n'));
}
FeatureRemovalEdit::Delete => {
return remove_single_line(&lines, i, existing.ends_with('\n'));
}
FeatureRemovalEdit::Unchanged => {
if let Some(result) = remove_feature_from_open_multiline_inline_table(
&lines,
i,
existing,
dep_name,
&feature_quoted,
) {
return result;
}
}
}
}
let subtable_key = format!("[{section}.{dep_name}]");
for (i, &line) in lines.iter().enumerate() {
let key_part = line.trim().split('#').next().unwrap_or("").trim();
if key_part != subtable_key {
continue;
}
let section_start = i + 1;
let section_end = lines[section_start..]
.iter()
.position(|l| {
let t = l.trim();
t.starts_with('[') && !t.is_empty()
})
.map_or(lines.len(), |p| section_start + p);
if let Some(result) = remove_feature_from_deps_section(
&lines,
section_start,
section_end,
existing,
&feature_quoted,
) {
return result;
}
}
existing.to_owned()
}
fn remove_feature_from_features_line(line: &str, feature_quoted: &str) -> Option<(String, bool)> {
let open = line.find('[')?;
let close_rel = line[open..].find(']')?;
let abs_end = open + close_rel;
let body = &line[open + 1..abs_end];
let items: Vec<&str> = body
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if !items.contains(&feature_quoted) {
return None;
}
let remaining: Vec<&str> = items
.into_iter()
.filter(|it| *it != feature_quoted)
.collect();
let now_empty = remaining.is_empty();
let new_line = format!(
"{}{}{}",
&line[..=open],
remaining.join(", "),
&line[abs_end..]
);
Some((new_line, now_empty))
}
fn remove_feature_from_open_multiline_inline_table(
lines: &[&str],
open_idx: usize,
existing: &str,
dep_name: &str,
feature_quoted: &str,
) -> Option<String> {
let after_ws = lines[open_idx].trim_start();
let rest = after_ws.strip_prefix(dep_name)?;
let rest = rest.trim_start().strip_prefix('=')?;
let rest = rest.trim_start().strip_prefix('{')?;
if rest.contains('}') {
return None; }
let close_idx = lines[open_idx + 1..]
.iter()
.position(|l| l.trim_start().starts_with('}'))
.map(|p| open_idx + 1 + p)?;
remove_feature_from_deps_section(lines, open_idx + 1, close_idx, existing, feature_quoted)
}
fn remove_feature_from_deps_section(
lines: &[&str],
section_start: usize,
section_end: usize,
existing: &str,
feature_quoted: &str,
) -> Option<String> {
for (j, §_line) in lines[section_start..section_end].iter().enumerate() {
if !sect_line.trim_start().starts_with("features") {
continue;
}
let idx = section_start + j;
let (new_line, now_empty) = remove_feature_from_features_line(sect_line, feature_quoted)?;
return Some(if now_empty {
remove_single_line(lines, idx, existing.ends_with('\n'))
} else {
splice_single_line(lines, idx, &new_line, existing.ends_with('\n'))
});
}
None
}
fn find_features_key(body: &str) -> Option<usize> {
let mut search_from = 0;
while let Some(rel) = body[search_from..].find("features") {
let idx = search_from + rel;
let is_key_start = idx == 0
|| !matches!(body.as_bytes()[idx - 1], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-');
let is_key_end = body[idx + "features".len()..].trim_start().starts_with('=');
if is_key_start && is_key_end {
return Some(idx);
}
search_from = idx + "features".len();
}
None
}
fn parse_feature_removal(
line: &str,
dep_name: &str,
feature_quoted: &str,
collapse_to_bare: bool,
) -> FeatureRemovalEdit {
let after_ws = line.trim_start();
let indent = &line[..line.len() - after_ws.len()];
let Some(rest) = after_ws.strip_prefix(dep_name) else {
return FeatureRemovalEdit::Unchanged;
};
let Some(rest) = rest.trim_start().strip_prefix('=') else {
return FeatureRemovalEdit::Unchanged;
};
let Some(rest) = rest.trim_start().strip_prefix('{') else {
return FeatureRemovalEdit::Unchanged;
};
let Some(body_end) = rest.rfind('}') else {
return FeatureRemovalEdit::Unchanged;
};
let body = rest[..body_end].trim();
let Some(features_kw) = find_features_key(body) else {
return FeatureRemovalEdit::Unchanged;
};
let before_features = body[..features_kw].trim().trim_end_matches(',').trim();
let after_kw = &body[features_kw + "features".len()..];
let Some(bracket_open) = after_kw.find('[') else {
return FeatureRemovalEdit::Unchanged;
};
let Some(bracket_close) = after_kw.find(']') else {
return FeatureRemovalEdit::Unchanged;
};
if bracket_close < bracket_open {
return FeatureRemovalEdit::Unchanged;
}
let list_body = &after_kw[bracket_open + 1..bracket_close];
let after_list = after_kw[bracket_close + 1..]
.trim()
.trim_start_matches(',')
.trim();
let items: Vec<&str> = list_body
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if !items.contains(&feature_quoted) {
return FeatureRemovalEdit::Unchanged;
}
let remaining: Vec<&str> = items.into_iter().filter(|s| *s != feature_quoted).collect();
if !remaining.is_empty() {
let sep = if before_features.is_empty() { "" } else { ", " };
let after_sep = if after_list.is_empty() { "" } else { ", " };
return FeatureRemovalEdit::Replace(format!(
"{indent}{dep_name} = {{ {before_features}{sep}features = [{}]{after_sep}{after_list} }}",
remaining.join(", "),
));
}
if collapse_to_bare {
if !after_list.is_empty() {
return FeatureRemovalEdit::Unchanged;
}
if let Some(v) = before_features
.strip_prefix("version")
.map(str::trim_start)
.and_then(|r| r.strip_prefix('='))
{
return FeatureRemovalEdit::Replace(format!("{indent}{dep_name} = {}", v.trim()));
}
if before_features.is_empty() {
return FeatureRemovalEdit::Unchanged;
}
return FeatureRemovalEdit::Replace(format!(
"{indent}{dep_name} = {{ {before_features} }}"
));
}
FeatureRemovalEdit::Delete
}
pub(super) fn splice_single_line(
lines: &[&str],
idx: usize,
new_line: &str,
ends_with_newline: bool,
) -> String {
let mut out = String::with_capacity(lines.len() * 24 + new_line.len());
for (i, &l) in lines.iter().enumerate() {
out.push_str(if i == idx { new_line } else { l });
out.push('\n');
}
if !ends_with_newline {
out.pop();
}
out
}
pub(super) fn remove_single_line(lines: &[&str], idx: usize, ends_with_newline: bool) -> String {
let mut out = String::with_capacity(lines.len() * 24);
for (i, &l) in lines.iter().enumerate() {
if i == idx {
continue;
}
out.push_str(l);
out.push('\n');
}
if !ends_with_newline && out.ends_with('\n') {
out.pop();
}
out
}
#[must_use]
pub fn ensure_autumn_web_feature_status(existing: &str, feature: &str) -> (String, bool) {
ensure_autumn_web_feature_status_in_section(existing, feature, "dependencies")
}
fn ensure_autumn_web_feature_status_in_section(
existing: &str,
feature: &str,
section: &str,
) -> (String, bool) {
let feature_quoted = format!("\"{feature}\"");
let lines: Vec<&str> = existing.lines().collect();
let mut in_section = false;
for (i, &line) in lines.iter().enumerate() {
let trimmed = line.trim();
if is_section_header(trimmed, section) {
in_section = true;
continue;
}
if in_section && is_section_boundary(trimmed, section) {
in_section = false;
continue;
}
if !in_section {
continue;
}
if trimmed.starts_with('#') {
continue;
}
let after_ws = line.trim_start();
let dep_prefix = if let Some(rest) = after_ws.strip_prefix("autumn-web") {
if rest.starts_with('.') {
return (
patch_dotted_dep(
&lines,
i,
existing,
"autumn-web",
feature,
&feature_quoted,
section,
),
true,
);
}
if rest.starts_with(|c: char| c != '=' && !c.is_whitespace()) {
continue;
}
"autumn-web"
} else {
let val = after_ws.split_once('=').map_or("", |x| x.1);
if !declares_package(val, "autumn-web") {
continue;
}
let alias = after_ws.split_once('=').map_or("", |(k, _)| k.trim());
if alias.replace('-', "_") != "autumn_web" {
continue;
}
alias
};
let line_code = line.split_once('#').map_or(line, |(before, _)| before);
if line_code.contains(&feature_quoted) {
return (existing.to_owned(), true);
}
let new_line = rewrite_dep_with_feature(line, dep_prefix, feature);
if new_line == line {
match add_feature_to_multiline_inline_table(
&lines,
i,
existing,
feature,
&feature_quoted,
) {
None => continue,
Some(result) => return (result, true),
}
}
let mut out = String::with_capacity(existing.len() + 32);
for (j, &l) in lines.iter().enumerate() {
out.push_str(if j == i { &new_line } else { l });
out.push('\n');
}
if !existing.ends_with('\n') {
out.pop();
}
return (out, true);
}
let subtable_key = format!("[{section}.autumn-web]");
for (i, &line) in lines.iter().enumerate() {
let key_part = line.trim().split('#').next().unwrap_or("").trim();
if key_part != subtable_key {
continue;
}
return (
add_feature_to_deps_section(&lines, i + 1, existing, feature, &feature_quoted),
true,
);
}
let renamed_subtable_key = format!("[{section}.autumn_web]");
if let Some(start) = find_section_start_with_autumn_web_package(&lines, &renamed_subtable_key) {
return (
add_feature_to_deps_section(&lines, start, existing, feature, &feature_quoted),
true,
);
}
(existing.to_owned(), false)
}
fn ensure_dep_feature_status_in_section(
existing: &str,
dep_name: &str,
feature: &str,
section: &str,
) -> (String, bool) {
let feature_quoted = format!("\"{feature}\"");
let lines: Vec<&str> = existing.lines().collect();
let mut in_section = false;
for (i, &line) in lines.iter().enumerate() {
let trimmed = line.trim();
if is_section_header(trimmed, section) {
in_section = true;
continue;
}
if in_section && is_section_boundary(trimmed, section) {
in_section = false;
continue;
}
if !in_section || trimmed.starts_with('#') {
continue;
}
let after_ws = line.trim_start();
let Some(rest) = after_ws.strip_prefix(dep_name) else {
continue;
};
if rest.starts_with('.') {
return (
patch_dotted_dep(
&lines,
i,
existing,
dep_name,
feature,
&feature_quoted,
section,
),
true,
);
}
if rest.starts_with(|c: char| c != '=' && !c.is_whitespace()) {
continue;
}
let line_code = line.split_once('#').map_or(line, |(before, _)| before);
if line_code.contains(&feature_quoted) {
return (existing.to_owned(), true);
}
let new_line = rewrite_dep_with_feature(line, dep_name, feature);
if new_line == line {
match add_feature_to_multiline_inline_table(
&lines,
i,
existing,
feature,
&feature_quoted,
) {
None => continue,
Some(result) => return (result, true),
}
}
let mut out = String::with_capacity(existing.len() + 32);
for (j, &l) in lines.iter().enumerate() {
out.push_str(if j == i { &new_line } else { l });
out.push('\n');
}
if !existing.ends_with('\n') {
out.pop();
}
return (out, true);
}
let subtable_key = format!("[{section}.{dep_name}]");
for (i, &line) in lines.iter().enumerate() {
let key_part = line.trim().split('#').next().unwrap_or("").trim();
if key_part != subtable_key {
continue;
}
return (
add_feature_to_deps_section(&lines, i + 1, existing, feature, &feature_quoted),
true,
);
}
(existing.to_owned(), false)
}
#[must_use]
pub(super) fn ensure_dependency_feature(existing: &str, dep_name: &str, feature: &str) -> String {
ensure_dep_feature_status_in_section(existing, dep_name, feature, "dependencies").0
}
#[must_use]
pub fn ensure_dev_dependency_tokio_test_features(existing: &str) -> String {
let (updated, found_rt) =
ensure_dep_feature_status_in_section(existing, "tokio", "rt", "dev-dependencies");
let (updated, found_macros) =
ensure_dep_feature_status_in_section(&updated, "tokio", "macros", "dev-dependencies");
if found_rt && found_macros {
return updated;
}
let lines: Vec<&str> = existing.lines().collect();
let source = detect_dependencies_source(existing, "tokio")
.unwrap_or_else(|| "version = \"1\"".to_string());
let new_dep_line = format!("tokio = {{ {source}, features = [\"rt\", \"macros\"] }}");
let Some(header_idx) = lines
.iter()
.position(|l| is_section_header(l.trim(), "dev-dependencies"))
else {
let mut out = existing.to_owned();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str("[dev-dependencies]\n");
let _ = writeln!(out, "{new_dep_line}");
return out;
};
let mut out = String::with_capacity(existing.len() + 96);
for (j, &l) in lines.iter().enumerate() {
out.push_str(l);
out.push('\n');
if j == header_idx {
let _ = writeln!(out, "{new_dep_line}");
}
}
if !existing.ends_with('\n') {
out.pop();
}
out
}
#[must_use]
pub fn ensure_dev_dependency_test_support(existing: &str, autumn_version: &str) -> String {
let (updated, found) =
ensure_autumn_web_feature_status_in_section(existing, "test-support", "dev-dependencies");
if found {
return updated;
}
let feature_quoted = "\"test-support\"";
let source = detect_dependencies_autumn_web_source(existing)
.unwrap_or_else(|| format!("version = \"{autumn_version}\""));
let new_dep_line = format!("autumn-web = {{ {source}, features = [{feature_quoted}] }}");
let lines: Vec<&str> = existing.lines().collect();
let Some(header_idx) = lines
.iter()
.position(|l| is_section_header(l.trim(), "dev-dependencies"))
else {
let mut out = existing.to_owned();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str("[dev-dependencies]\n");
let _ = writeln!(out, "{new_dep_line}");
return out;
};
let mut out = String::with_capacity(existing.len() + 96);
for (j, &l) in lines.iter().enumerate() {
out.push_str(l);
out.push('\n');
if j == header_idx {
let _ = writeln!(out, "{new_dep_line}");
}
}
if !existing.ends_with('\n') {
out.pop();
}
out
}
const SOURCE_KEYS: &[&str] = &[
"workspace",
"path",
"git",
"branch",
"tag",
"rev",
"registry",
];
fn is_source_key(key: &str) -> bool {
SOURCE_KEYS.contains(&key)
}
fn split_top_level_commas(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut depth = 0i32;
let mut in_str = false;
for (i, c) in s.char_indices() {
match c {
'"' => in_str = !in_str,
'[' | '{' if !in_str => depth += 1,
']' | '}' if !in_str => depth -= 1,
',' if !in_str && depth == 0 => {
parts.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&s[start..]);
parts
}
fn declares_package(text: &str, target: &str) -> bool {
let body = match (text.find('{'), text.rfind('}')) {
(Some(open), Some(close)) if close > open => &text[open + 1..close],
_ => text,
};
split_top_level_commas(body).into_iter().any(|part| {
part.split_once('=').is_some_and(|(k, v)| {
k.trim() == "package" && v.trim().trim_matches(['"', '\'']) == target
})
})
}
fn extract_source_keys<'a>(pairs: impl Iterator<Item = &'a str>) -> Option<String> {
let items: Vec<(&str, &str)> = pairs
.filter_map(|part| {
let (key, value) = part.split_once('=')?;
Some((key.trim(), value.trim()))
})
.collect();
let mut found: Vec<String> = items
.iter()
.filter(|(key, _)| is_source_key(key))
.map(|(key, value)| format!("{key} = {value}"))
.collect();
let version = items.iter().find(|(key, _)| *key == "version");
if found.iter().any(|f| f.starts_with("registry"))
&& let Some((_, value)) = version
{
found.insert(0, format!("version = {value}"));
}
if found.is_empty() {
return version.map(|(_, value)| format!("version = {value}"));
}
Some(found.join(", "))
}
fn extract_source_from_inline_table(line: &str) -> Option<String> {
let open = line.find('{')?;
let close = line.rfind('}')?;
if close <= open {
return None;
}
extract_source_keys(split_top_level_commas(&line[open + 1..close]).into_iter())
}
fn extract_source_from_subtable_lines(lines: &[&str]) -> Option<String> {
extract_source_keys(
lines
.iter()
.map(|l| l.split_once('#').map_or(*l, |(code, _)| code).trim())
.filter(|l| !l.is_empty()),
)
}
fn extract_plain_string_version(line: &str, dep_name: &str) -> Option<String> {
let rest = line.trim().strip_prefix(dep_name)?.trim_start();
let rest = rest.strip_prefix('=')?.trim_start();
let rest = rest.split('#').next().unwrap_or(rest).trim();
let is_quoted = rest.len() >= 2
&& ((rest.starts_with('"') && rest.ends_with('"'))
|| (rest.starts_with('\'') && rest.ends_with('\'')));
is_quoted.then(|| format!("version = {rest}"))
}
fn detect_dependencies_source(existing: &str, dep_name: &str) -> Option<String> {
let lines: Vec<&str> = existing.lines().collect();
let mut in_section = false;
let mut dotted_pairs: Vec<String> = Vec::new();
for &line in &lines {
let trimmed = line.trim();
if is_section_header(trimmed, "dependencies") {
in_section = true;
continue;
}
if in_section && is_section_boundary(trimmed, "dependencies") {
in_section = false;
continue;
}
if !in_section || trimmed.starts_with('#') {
continue;
}
let after_ws = line.trim_start();
let Some(rest) = after_ws.strip_prefix(dep_name) else {
continue;
};
if let Some(dotted) = rest.strip_prefix('.') {
let code = dotted.split_once('#').map_or(dotted, |(before, _)| before);
if let Some((key, value)) = code.split_once('=') {
dotted_pairs.push(format!("{} = {}", key.trim(), value.trim()));
}
continue;
}
if rest.starts_with(|c: char| c != '=' && !c.is_whitespace()) {
continue;
}
if let Some(version) = extract_plain_string_version(line, dep_name) {
return Some(version);
}
return extract_source_from_inline_table(line);
}
if !dotted_pairs.is_empty() {
return extract_source_keys(dotted_pairs.iter().map(String::as_str));
}
let subtable_key = format!("[dependencies.{dep_name}]");
let section_start = lines
.iter()
.position(|l| l.trim().split('#').next().unwrap_or("").trim() == subtable_key)
.map(|p| p + 1)?;
let section_end = lines[section_start..]
.iter()
.position(|l| {
let t = l.trim();
t.starts_with('[') && !t.is_empty()
})
.map_or(lines.len(), |p| section_start + p);
extract_source_from_subtable_lines(&lines[section_start..section_end])
}
fn detect_dependencies_autumn_web_source(existing: &str) -> Option<String> {
if let Some(source) = detect_dependencies_source(existing, "autumn-web") {
return Some(source);
}
let lines: Vec<&str> = existing.lines().collect();
let mut in_section = false;
let mut alias_dotted_pairs: Vec<String> = Vec::new();
let mut alias_confirmed = false;
for &line in &lines {
let trimmed = line.trim();
if is_section_header(trimmed, "dependencies") {
in_section = true;
continue;
}
if in_section && is_section_boundary(trimmed, "dependencies") {
in_section = false;
continue;
}
if !in_section || trimmed.starts_with('#') {
continue;
}
let after_ws = line.trim_start();
if after_ws.strip_prefix("autumn-web").is_some() {
continue;
}
let Some((key, val)) = after_ws.split_once('=') else {
continue;
};
let key = key.trim();
let (alias, dotted_sub) = key
.split_once('.')
.map_or((key, None), |(b, r)| (b, Some(r)));
if alias.replace('-', "_") != "autumn_web" {
continue;
}
let val_code = val.split('#').next().unwrap_or(val).trim();
let Some(sub) = dotted_sub else {
if declares_package(val_code, "autumn-web") {
return extract_source_from_inline_table(line);
}
continue;
};
if sub == "package" && val_code.trim_matches(['"', '\'']) == "autumn-web" {
alias_confirmed = true;
}
alias_dotted_pairs.push(format!("{sub} = {val_code}"));
}
if alias_confirmed {
return extract_source_keys(alias_dotted_pairs.iter().map(String::as_str));
}
let section_start =
find_section_start_with_autumn_web_package(&lines, "[dependencies.autumn_web]")?;
let section_end = lines[section_start..]
.iter()
.position(|l| {
let t = l.trim();
t.starts_with('[') && !t.is_empty()
})
.map_or(lines.len(), |p| section_start + p);
extract_source_from_subtable_lines(&lines[section_start..section_end])
}
fn find_section_start_with_autumn_web_package(lines: &[&str], key: &str) -> Option<usize> {
for (i, &line) in lines.iter().enumerate() {
let key_part = line.trim().split('#').next().unwrap_or("").trim();
if key_part != key {
continue;
}
let section_start = i + 1;
let section_end = lines[section_start..]
.iter()
.position(|l| {
let t = l.trim();
t.starts_with('[') && !t.is_empty()
})
.map_or(lines.len(), |p| section_start + p);
let has_pkg = lines[section_start..section_end].iter().any(|l| {
let code = l.split_once('#').map_or(*l, |(b, _)| b);
declares_package(code, "autumn-web")
});
if has_pkg {
return Some(section_start);
}
}
None
}
fn add_feature_to_deps_section(
lines: &[&str],
section_start: usize,
existing: &str,
feature: &str,
feature_quoted: &str,
) -> String {
let section_end = lines[section_start..]
.iter()
.position(|l| {
let t = l.trim();
t.starts_with('[') && !t.is_empty()
})
.map_or(lines.len(), |p| section_start + p);
if lines[section_start..section_end].iter().any(|l| {
let line_code = l.split_once('#').map_or(*l, |(before, _)| before);
line_code.contains(feature_quoted)
}) {
return existing.to_owned();
}
for (j, §_line) in lines[section_start..section_end].iter().enumerate() {
if sect_line.trim_start().starts_with("features") {
return splice_feature_at(
lines,
section_start + j,
&rewrite_features_line(sect_line, feature),
sect_line,
feature_quoted,
existing.ends_with('\n'),
);
}
}
let feat_line = format!("features = [{feature_quoted}]");
let mut out = String::with_capacity(existing.len() + feat_line.len() + 2);
for (k, &l) in lines.iter().enumerate() {
if k == section_end {
out.push_str(&feat_line);
out.push('\n');
}
out.push_str(l);
out.push('\n');
}
if section_end == lines.len() {
out.push_str(&feat_line);
out.push('\n');
}
if !existing.ends_with('\n') && out.ends_with('\n') {
out.pop();
}
out
}
fn rewrite_features_line(line: &str, feature: &str) -> String {
let feature_quoted = format!("\"{feature}\"");
if let Some(open) = line.find('[')
&& let Some(close_rel) = line[open..].find(']')
{
let abs_end = open + close_rel;
let body = &line[open + 1..abs_end];
let body_trimmed = body.trim();
let separator = if body_trimmed.is_empty() {
""
} else if body_trimmed.ends_with(',') {
" "
} else {
", "
};
return format!(
"{}{}{}{}",
&line[..abs_end],
separator,
feature_quoted,
&line[abs_end..]
);
}
line.to_owned()
}
fn rewrite_dep_with_feature(line: &str, dep_name: &str, feature: &str) -> String {
let feature_quoted = format!("\"{feature}\"");
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(dep_name) {
let rest = rest.trim_start_matches([' ', '=', '\t']);
if let Some(quote) = rest.chars().next().filter(|&c| c == '"' || c == '\'') {
let value_str = rest.split('#').next().unwrap_or(rest).trim_end();
if let Some(version) = value_str
.strip_prefix(quote)
.and_then(|r| r.strip_suffix(quote))
{
let indent_len = line.len() - line.trim_start().len();
let indent = &line[..indent_len];
return format!(
"{indent}{dep_name} = {{ version = {quote}{version}{quote}, features = [{feature_quoted}] }}"
);
}
}
}
let (code, comment) = line
.split_once('#')
.map_or((line, String::new()), |(c, rest)| (c, format!("#{rest}")));
if let Some(open) = code.find("features")
&& let Some(bracket_start) = code[open..].find('[')
{
let abs_start = open + bracket_start;
if let Some(bracket_end_rel) = code[abs_start..].find(']') {
let abs_end = abs_start + bracket_end_rel;
let body = &code[abs_start + 1..abs_end];
let body_trimmed = body.trim();
let separator = if body_trimmed.is_empty() {
""
} else if body_trimmed.ends_with(',') {
" "
} else {
", "
};
return format!(
"{}{}{}{}{}",
&code[..abs_end],
separator,
feature_quoted,
&code[abs_end..],
comment
);
}
}
if let Some(close) = code.rfind('}') {
let before = code[..close].trim_end();
let after = &code[close..];
return format!("{before}, features = [{feature_quoted}]{after}{comment}");
}
line.to_owned()
}
fn is_section_header(trimmed: &str, section: &str) -> bool {
let header = format!("[{section}]");
trimmed == header
|| (trimmed.starts_with(&header) && trimmed[header.len()..].trim_start().starts_with('#'))
}
fn is_section_boundary(trimmed: &str, section: &str) -> bool {
trimmed.starts_with('[') && !trimmed.starts_with(&format!("[{section}."))
}
pub fn add_search_up_sql_for(
backend: DatabaseBackend,
table: &str,
language: &str,
fields: &[(String, char)],
) -> Result<String, GenerateError> {
match backend {
DatabaseBackend::Sqlite => {
reject_reserved_sqlite_search_columns(table, fields)?;
Ok(sqlite_add_search_up_sql(table, fields))
}
DatabaseBackend::Postgres => {
let mut out = String::new();
let _ = writeln!(
out,
"-- autumn-safety: potentially-blocking \n\
-- adding stored generated column will backfill existing rows"
);
let safe_lang: String = language
.chars()
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '.')
.collect();
let safe_lang = if safe_lang.is_empty() {
"simple".to_string()
} else {
safe_lang
};
let mut expr = String::new();
for (i, (field, weight)) in fields.iter().enumerate() {
if i > 0 {
expr.push_str(" || ");
}
let _ = write!(
expr,
"setweight(to_tsvector('{safe_lang}'::regconfig, coalesce(\"{field}\"::text, '')), '{weight}')"
);
}
let _ = writeln!(
out,
"ALTER TABLE {table} ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ({expr}) STORED;"
);
let _ = writeln!(
out,
"CREATE INDEX idx_{table}_search_vector ON {table} USING gin(search_vector);"
);
Ok(out)
}
}
}
#[must_use]
fn is_fts5_reserved_search_column(column: &str, fts_table: &str) -> bool {
column.eq_ignore_ascii_case("rowid")
|| column.eq_ignore_ascii_case("rank")
|| column.eq_ignore_ascii_case(fts_table)
}
fn reject_reserved_sqlite_search_columns(
table: &str,
fields: &[(String, char)],
) -> Result<(), GenerateError> {
let fts_table = format!("{table}__fts");
for (field, _) in fields {
if is_fts5_reserved_search_column(field, &fts_table) {
return Err(GenerateError::Config(format!(
"the #[searchable] field '{field}' on table '{table}' uses an FTS5-reserved \
column name: SQLite would reject the generated `CREATE VIRTUAL TABLE \
\"{fts_table}\" USING fts5(...)` search index because FTS5 reserves the column \
names `rowid` and `rank` and forbids a column named the same as the FTS table \
(`{fts_table}`) — matched case-insensitively, and quoting the identifier does \
not help. Rename the field, or drop #[searchable] from it, to generate SQLite \
FTS5 search. (Postgres full-text search is unaffected.)"
)));
}
}
Ok(())
}
#[must_use]
pub fn add_search_down_sql_for(backend: DatabaseBackend, table: &str) -> String {
match backend {
DatabaseBackend::Sqlite => sqlite_add_search_down_sql(table),
DatabaseBackend::Postgres => {
let mut out = String::new();
let _ = writeln!(out, "DROP INDEX IF EXISTS idx_{table}_search_vector;");
let _ = writeln!(
out,
"ALTER TABLE {table} DROP COLUMN IF EXISTS search_vector;"
);
out
}
}
}
fn sqlite_add_search_up_sql(table: &str, fields: &[(String, char)]) -> String {
let fts = format!("{table}__fts");
let cols: Vec<String> = fields.iter().map(|(f, _)| format!("\"{f}\"")).collect();
let cols_csv = cols.join(", ");
let new_vals: Vec<String> = fields.iter().map(|(f, _)| format!("new.\"{f}\"")).collect();
let new_vals_csv = new_vals.join(", ");
let old_vals: Vec<String> = fields.iter().map(|(f, _)| format!("old.\"{f}\"")).collect();
let old_vals_csv = old_vals.join(", ");
let mut out = String::new();
let _ = writeln!(
out,
"-- autumn-safety: potentially-blocking \n\
-- rebuilding the FTS5 index backfills every existing row"
);
let _ = writeln!(
out,
"CREATE VIRTUAL TABLE \"{fts}\" USING fts5({cols_csv}, content='{table}', content_rowid='id', tokenize='unicode61');"
);
let _ = writeln!(
out,
"CREATE TRIGGER \"{fts}_ai\" AFTER INSERT ON \"{table}\" BEGIN\n \
INSERT INTO \"{fts}\"(rowid, {cols_csv}) VALUES (new.id, {new_vals_csv});\n\
END;"
);
let _ = writeln!(
out,
"CREATE TRIGGER \"{fts}_ad\" AFTER DELETE ON \"{table}\" BEGIN\n \
INSERT INTO \"{fts}\"(\"{fts}\", rowid, {cols_csv}) VALUES('delete', old.id, {old_vals_csv});\n\
END;"
);
let _ = writeln!(
out,
"CREATE TRIGGER \"{fts}_au\" AFTER UPDATE ON \"{table}\" BEGIN\n \
INSERT INTO \"{fts}\"(\"{fts}\", rowid, {cols_csv}) VALUES('delete', old.id, {old_vals_csv});\n \
INSERT INTO \"{fts}\"(rowid, {cols_csv}) VALUES (new.id, {new_vals_csv});\n\
END;"
);
let _ = writeln!(out, "INSERT INTO \"{fts}\"(\"{fts}\") VALUES('rebuild');");
out
}
fn sqlite_add_search_down_sql(table: &str) -> String {
let fts = format!("{table}__fts");
let mut out = String::new();
let _ = writeln!(out, "DROP TRIGGER IF EXISTS \"{fts}_au\";");
let _ = writeln!(out, "DROP TRIGGER IF EXISTS \"{fts}_ad\";");
let _ = writeln!(out, "DROP TRIGGER IF EXISTS \"{fts}_ai\";");
let _ = writeln!(out, "DROP TABLE IF EXISTS \"{fts}\";");
out
}
#[allow(clippy::option_if_let_else, clippy::too_many_lines)]
pub fn singularize(s: &str) -> String {
if s == "series" {
return "series".to_string();
}
if let Some(stripped) = s.strip_suffix("people") {
return format!("{stripped}person");
}
if let Some(stripped) = s.strip_suffix("children") {
return format!("{stripped}child");
}
let is_false_men = s == "specimen"
|| s == "regimen"
|| s == "abdomen"
|| s == "lumen"
|| s == "omen"
|| s == "semen"
|| s == "hymen"
|| s == "acumen"
|| s == "bitumen"
|| s == "stamen"
|| s.ends_with("specimen")
|| s.ends_with("regimen")
|| s.ends_with("abdomen")
|| s.ends_with("lumen")
|| s.ends_with("omen")
|| s.ends_with("semen")
|| s.ends_with("hymen")
|| s.ends_with("acumen")
|| s.ends_with("bitumen")
|| s.ends_with("stamen");
if s == "men" {
return "man".to_string();
}
if s == "women" {
return "woman".to_string();
}
if s.ends_with("men") && !is_false_men {
let stripped = s.strip_suffix("men").unwrap();
return format!("{stripped}man");
}
if s.ends_with("women") {
let stripped = s.strip_suffix("women").unwrap();
return format!("{stripped}woman");
}
if s.ends_with("ves") {
if s.ends_with("lives") {
return format!("{}life", s.strip_suffix("lives").unwrap());
}
if s.ends_with("knives") {
return format!("{}knife", s.strip_suffix("knives").unwrap());
}
if s.ends_with("wives") {
return format!("{}wife", s.strip_suffix("wives").unwrap());
}
if s.ends_with("ives") {
return s.strip_suffix('s').unwrap().to_string();
}
let stripped = s.strip_suffix("ves").unwrap();
return format!("{stripped}f");
}
if let Some(stripped) = s.strip_suffix("ies") {
if s.ends_with("movies") || s.ends_with("cookies") || s.ends_with("zombies") {
format!("{stripped}ie")
} else {
format!("{stripped}y")
}
} else if let Some(stripped) = s.strip_suffix("es") {
if s.ends_with("ches")
|| s.ends_with("shes")
|| s.ends_with("xes")
|| s.ends_with("ses")
|| s.ends_with("zes")
{
if s.ends_with("statuses")
|| s.ends_with("aliases")
|| s.ends_with("buses")
|| s.ends_with("sses")
|| s.ends_with("lenses")
{
stripped.to_owned()
} else if s.ends_with("yses") {
format!("{stripped}is")
} else if s == "crises" {
"crisis".to_string()
} else if s == "diagnoses" {
"diagnosis".to_string()
} else if s == "neuroses" {
"neurosis".to_string()
} else if s == "bases" {
"basis".to_string()
} else if s == "oases" {
"oasis".to_string()
} else if s.ends_with("ases")
|| s.ends_with("ises")
|| s.ends_with("oses")
|| s.ends_with("uses")
|| s.ends_with("ses")
{
format!("{stripped}e")
} else {
stripped.to_owned()
}
} else {
format!("{stripped}e")
}
} else if let Some(stripped) = s.strip_suffix('s') {
if s.ends_with("ss")
|| s == "news"
|| s == "status"
|| s == "alias"
|| s == "bus"
|| s == "lens"
|| s == "virus"
|| s == "canvas"
|| s == "analysis"
|| s == "basis"
|| s == "crisis"
{
s.to_owned()
} else {
stripped.to_owned()
}
} else {
s.to_owned()
}
}
fn strip_comments(src: &str) -> String {
let chars: Vec<char> = src.chars().collect();
let mut result = String::with_capacity(src.len());
let mut i = 0;
while i < chars.len() {
if i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '/' {
i += 2;
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
if i < chars.len() {
result.push('\n');
i += 1;
} else {
result.push(' ');
}
continue;
}
if i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '*' {
i += 2;
let mut depth = 1;
while i + 1 < chars.len() && depth > 0 {
if chars[i] == '/' && chars[i + 1] == '*' {
depth += 1;
i += 2;
} else if chars[i] == '*' && chars[i + 1] == '/' {
depth -= 1;
i += 2;
} else {
i += 1;
}
}
if depth > 0 {
i = chars.len();
}
result.push(' ');
continue;
}
if chars[i] == 'r' && i + 1 < chars.len() {
let mut hash_count = 0;
let mut j = i + 1;
while j < chars.len() && chars[j] == '#' {
hash_count += 1;
j += 1;
}
if j < chars.len() && chars[j] == '"' {
result.extend(&chars[i..=j]);
i = j + 1;
let mut closed = false;
while i < chars.len() {
if chars[i] == '"' {
let mut match_hashes = true;
for h in 0..hash_count {
if i + 1 + h >= chars.len() || chars[i + 1 + h] != '#' {
match_hashes = false;
break;
}
}
if match_hashes {
result.push('"');
for _ in 0..hash_count {
result.push('#');
}
i += 1 + hash_count;
closed = true;
break;
}
}
result.push(chars[i]);
i += 1;
}
if !closed {
i = chars.len();
}
continue;
}
}
if chars[i] == '"' {
result.push('"');
i += 1;
while i < chars.len() {
let ch = chars[i];
result.push(ch);
if ch == '\\' && i + 1 < chars.len() {
result.push(chars[i + 1]);
i += 2;
continue;
}
if ch == '"' {
i += 1;
break;
}
i += 1;
}
continue;
}
result.push(chars[i]);
i += 1;
}
result
}
#[must_use]
#[allow(dead_code)]
pub fn parse_model_search_config(content: &str) -> Option<(String, Vec<(String, char)>)> {
parse_model_search_config_for_table(content, "")
}
fn is_matching_table_attr(attr_content: &str, table: &str) -> bool {
let mut rest = attr_content;
while let Some(pos) = rest.find("table") {
let prev_char = if pos > 0 {
rest.as_bytes().get(pos - 1)
} else {
None
};
let next_char = rest.as_bytes().get(pos + "table".len());
let is_prev_boundary = prev_char.is_none_or(|&c| !c.is_ascii_alphanumeric() && c != b'_');
let is_next_boundary = next_char.is_none_or(|&c| !c.is_ascii_alphanumeric() && c != b'_');
if is_prev_boundary && is_next_boundary {
let after_table = &rest[pos + "table".len()..];
let trimmed = after_table.trim_start();
if let Some(stripped_eq) = trimmed.strip_prefix('=') {
let after_eq = stripped_eq.trim_start();
let expected_value = format!("\"{table}\"");
if after_eq.starts_with(&expected_value) {
return true;
}
if let Some(after_r) = after_eq.strip_prefix('r') {
let mut hash_count = 0;
let bytes = after_r.as_bytes();
while hash_count < bytes.len() && bytes[hash_count] == b'#' {
hash_count += 1;
}
let after_hashes = &after_r[hash_count..];
let expected_raw = format!("\"{table}\"");
if after_hashes.starts_with(&expected_raw) {
let after_quote = &after_hashes[expected_raw.len()..];
let mut match_close = true;
for h in 0..hash_count {
if after_quote.as_bytes().get(h) != Some(&b'#') {
match_close = false;
break;
}
}
if match_close {
return true;
}
}
}
}
}
rest = &rest[pos + "table".len()..];
}
false
}
#[allow(clippy::collapsible_if)]
fn extract_diesel_column_name(attr: &str) -> Option<String> {
let pos = attr.find("column_name")?;
let after_col = &attr[pos + "column_name".len()..];
let trimmed = after_col.trim_start();
let stripped_eq = trimmed.strip_prefix('=')?;
let after_eq = stripped_eq.trim_start();
if let Some(stripped_quote) = after_eq.strip_prefix('"') {
let quote_end = stripped_quote.find('"')?;
return Some(stripped_quote[..quote_end].to_string());
}
if let Some(after_r) = after_eq.strip_prefix('r') {
let mut hash_count = 0;
let bytes = after_r.as_bytes();
while hash_count < bytes.len() && bytes[hash_count] == b'#' {
hash_count += 1;
}
let after_hashes = &after_r[hash_count..];
if let Some(stripped_quote) = after_hashes.strip_prefix('"') {
if let Some(quote_end) = stripped_quote.find('"') {
return Some(stripped_quote[..quote_end].to_string());
}
}
}
let after_ident = after_eq.strip_prefix("r#").unwrap_or(after_eq);
let mut id_chars = String::new();
for c in after_ident.chars() {
if c.is_alphanumeric() || c == '_' {
id_chars.push(c);
} else {
break;
}
}
if !id_chars.is_empty() {
return Some(id_chars);
}
None
}
fn has_attribute_boundary(rest: &str, pos: usize, keyword: &str) -> bool {
let after = &rest[pos + keyword.len()..];
after
.chars()
.next()
.is_none_or(|c| c == '(' || c == ']' || c.is_whitespace())
}
fn find_real_struct_keyword(src: &str, start_byte_offset: usize) -> Option<usize> {
let mut chars = src.char_indices().peekable();
while let Some(&(idx, _)) = chars.peek() {
if idx < start_byte_offset {
chars.next();
} else {
break;
}
}
while let Some((idx, c)) = chars.next() {
if c == 'r' {
let mut temp = chars.clone();
let mut hash_count = 0;
while let Some((_, '#')) = temp.peek() {
hash_count += 1;
temp.next();
}
if let Some((_, '"')) = temp.peek() {
for _ in 0..hash_count {
chars.next();
}
chars.next(); while let Some((_, rc)) = chars.next() {
if rc == '"' {
let mut match_hashes = true;
let mut check_chars = chars.clone();
for _ in 0..hash_count {
if check_chars.peek().is_some_and(|&(_, ch)| ch == '#') {
check_chars.next();
} else {
match_hashes = false;
break;
}
}
if match_hashes {
for _ in 0..hash_count {
chars.next();
}
break;
}
}
}
continue;
}
}
if c == '"' {
while let Some((_, sc)) = chars.next() {
if sc == '\\' {
chars.next(); } else if sc == '"' {
break;
}
}
continue;
}
if c == 's' {
let rest = &src[idx..];
if rest.starts_with("struct") {
let next_char = src[idx + "struct".len()..].chars().next();
let is_followed_by_boundary =
next_char.is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_');
let is_preceded_by_boundary = if idx == 0 {
true
} else {
let prev_char = src[..idx].chars().next_back();
prev_char.is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_')
};
if is_followed_by_boundary && is_preceded_by_boundary {
return Some(idx);
}
}
}
}
None
}
#[must_use]
#[allow(clippy::too_many_lines, clippy::collapsible_if)]
pub fn parse_model_search_config_for_table(
content: &str,
table: &str,
) -> Option<(String, Vec<(String, char)>)> {
let clean_content = strip_comments(content);
let mut language = "simple".to_string();
let mut fields = Vec::new();
let mut model_pos = None;
let mut struct_pos = None;
if !table.is_empty() {
let mut rest = clean_content.as_str();
while let Some(pos) = rest.find("#[model") {
let offset = clean_content.len() - rest.len() + pos;
if has_attribute_boundary(rest, pos, "#[model") {
if let Some(close_bracket) = rest[pos..].find(']') {
let attr_content = &rest[pos..pos + close_bracket];
if is_matching_table_attr(attr_content, table) {
model_pos = Some(offset);
break;
}
}
}
rest = &rest[pos + "#[model".len()..];
}
if model_pos.is_none() {
let mut rest = clean_content.as_str();
while let Some(pos) = rest.find("#[autumn_web::model") {
let offset = clean_content.len() - rest.len() + pos;
if has_attribute_boundary(rest, pos, "#[autumn_web::model") {
if let Some(close_bracket) = rest[pos..].find(']') {
let attr_content = &rest[pos..pos + close_bracket];
if is_matching_table_attr(attr_content, table) {
model_pos = Some(offset);
break;
}
}
}
rest = &rest[pos + "#[autumn_web::model".len()..];
}
}
if model_pos.is_none() {
let singular = singularize(table);
let struct_name = super::naming::snake_to_pascal(&singular);
let mut current_offset = 0;
let mut found_struct_pos = None;
while let Some(pos) = find_real_struct_keyword(&clean_content, current_offset) {
let after_struct = &clean_content[pos + "struct".len()..];
if let Some(first_word) = after_struct.split_whitespace().next() {
let clean_name =
first_word.trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_');
if clean_name == struct_name {
found_struct_pos = Some(pos);
break;
}
}
current_offset = pos + "struct".len();
}
if let Some(s_pos) = found_struct_pos {
let before_struct = &clean_content[..s_pos];
let mut m_pos_opt = None;
let mut rest_before = before_struct;
while let Some(p) = rest_before.rfind("#[model") {
if has_attribute_boundary(rest_before, p, "#[model") {
m_pos_opt = Some(p);
break;
}
rest_before = &rest_before[..p];
}
let mut aw_pos_opt = None;
let mut rest_before_aw = before_struct;
while let Some(p) = rest_before_aw.rfind("#[autumn_web::model") {
if has_attribute_boundary(rest_before_aw, p, "#[autumn_web::model") {
aw_pos_opt = Some(p);
break;
}
rest_before_aw = &rest_before_aw[..p];
}
let best_pos = match (m_pos_opt, aw_pos_opt) {
(Some(p1), Some(p2)) => Some(std::cmp::max(p1, p2)),
(Some(p), None) | (None, Some(p)) => Some(p),
(None, None) => None,
};
if let Some(pos) = best_pos {
let in_between = &before_struct[pos..];
let has_other_struct = find_real_struct_keyword(in_between, 0).is_some();
if !has_other_struct {
model_pos = Some(pos);
struct_pos = Some(s_pos);
}
}
}
}
}
if model_pos.is_none() {
if table.is_empty() {
let mut rest = clean_content.as_str();
while let Some(pos) = rest.find("#[model") {
if has_attribute_boundary(rest, pos, "#[model") {
model_pos = Some(clean_content.len() - rest.len() + pos);
break;
}
rest = &rest[pos + "#[model".len()..];
}
if model_pos.is_none() {
let mut rest = clean_content.as_str();
while let Some(pos) = rest.find("#[autumn_web::model") {
if has_attribute_boundary(rest, pos, "#[autumn_web::model") {
model_pos = Some(clean_content.len() - rest.len() + pos);
break;
}
rest = &rest[pos + "#[autumn_web::model".len()..];
}
}
} else {
return None;
}
}
let struct_pos = if let Some(s_pos) = struct_pos {
s_pos
} else if let Some(m_pos) = model_pos {
find_real_struct_keyword(&clean_content, m_pos)?
} else {
find_real_struct_keyword(&clean_content, 0)?
};
let before_struct = &clean_content[..struct_pos];
let mut rest_before = before_struct;
while let Some(pos) = rest_before.rfind("#[searchable") {
let next_char = rest_before.as_bytes().get(pos + "#[searchable".len());
let is_boundary =
next_char.is_none_or(|&c| c == b']' || c == b'(' || c.is_ascii_whitespace());
if !is_boundary {
rest_before = &rest_before[..pos];
continue;
}
let attr_chunk = &rest_before[pos..];
if let Some(close_bracket) = attr_chunk.find(']') {
let end_of_attr = pos + close_bracket + 1;
let in_between = &before_struct[end_of_attr..];
if in_between.contains('}')
|| in_between.contains(';')
|| find_real_struct_keyword(in_between, 0).is_some()
{
break;
}
let attr_content = &attr_chunk[..close_bracket];
if let Some(lang_pos) = attr_content.find("language") {
let after_lang = &attr_content[lang_pos + "language".len()..];
if let Some(eq_pos) = after_lang.find('=') {
let after_eq = &after_lang[eq_pos + 1..];
if let Some(quote_start) = after_eq.find('"') {
let after_quote = &after_eq[quote_start + 1..];
if let Some(quote_end) = after_quote.find('"') {
language = after_quote[..quote_end].to_string();
}
}
}
}
}
break;
}
let mut struct_body = "";
if let Some(open_brace_offset) = clean_content[struct_pos..].find('{') {
let open_brace_pos = struct_pos + open_brace_offset;
let chars: Vec<char> = clean_content[open_brace_pos + 1..].chars().collect();
let mut brace_count = 1;
let mut close_brace_offset = None;
let mut i = 0;
while i < chars.len() {
if chars[i] == 'r' && i + 1 < chars.len() {
let mut hash_count = 0;
let mut j = i + 1;
while j < chars.len() && chars[j] == '#' {
hash_count += 1;
j += 1;
}
if j < chars.len() && chars[j] == '"' {
i = j + 1;
while i < chars.len() {
if chars[i] == '"' {
let mut match_hashes = true;
for h in 0..hash_count {
if i + 1 + h >= chars.len() || chars[i + 1 + h] != '#' {
match_hashes = false;
break;
}
}
if match_hashes {
i += 1 + hash_count;
break;
}
}
i += 1;
}
continue;
}
}
if chars[i] == '"' {
i += 1;
while i < chars.len() {
let ch = chars[i];
if ch == '\\' && i + 1 < chars.len() {
i += 2;
continue;
}
if ch == '"' {
i += 1;
break;
}
i += 1;
}
continue;
}
if chars[i] == '{' {
brace_count += 1;
} else if chars[i] == '}' {
brace_count -= 1;
if brace_count == 0 {
close_brace_offset = Some(i);
break;
}
}
i += 1;
}
if let Some(offset) = close_brace_offset {
let consumed: String = chars[..offset].iter().collect();
let consumed_len = consumed.len();
struct_body = &clean_content[open_brace_pos + 1..open_brace_pos + 1 + consumed_len];
}
}
let mut rest = struct_body;
while let Some(pos) = rest.find("#[searchable") {
let next_char = rest.as_bytes().get(pos + "#[searchable".len());
let is_boundary =
next_char.is_none_or(|&c| c == b']' || c == b'(' || c.is_ascii_whitespace());
if !is_boundary {
rest = &rest[pos + "#[searchable".len()..];
continue;
}
let attr_chunk = &rest[pos..];
let mut weight = 'D';
if let Some(close_bracket) = attr_chunk.find(']') {
let attr_content = &attr_chunk[..close_bracket];
if let Some(w_pos) = attr_content.find("weight") {
let after_weight = &attr_content[w_pos + "weight".len()..];
if let Some(eq_pos) = after_weight.find('=') {
let after_eq = &after_weight[eq_pos + 1..];
if let Some(quote_start) = after_eq.find('"') {
let after_quote = &after_eq[quote_start + 1..];
if let Some(quote_end) = after_quote.find('"') {
let w_str = &after_quote[..quote_end];
if let Some(ch) = w_str.chars().next() {
let upper = ch.to_ascii_uppercase();
if upper == 'A' || upper == 'B' || upper == 'C' || upper == 'D' {
weight = upper;
} else {
weight = 'D';
}
}
}
}
}
}
let after_attr = &attr_chunk[close_bracket + 1..];
let mut line_to_parse = "";
let mut field_attributes = Vec::new();
let mut chars_iter = after_attr.char_indices().peekable();
while let Some(&(idx, c)) = chars_iter.peek() {
if c.is_whitespace() {
chars_iter.next();
continue;
}
if c == '/' {
let mut temp = chars_iter.clone();
temp.next(); if let Some((_, '/')) = temp.peek() {
for (_, next_c) in chars_iter.by_ref() {
if next_c == '\n' {
break;
}
}
continue;
}
}
if c == '#' {
let mut temp = chars_iter.clone();
temp.next(); if let Some((_, '[')) = temp.peek() {
chars_iter.next(); chars_iter.next();
let start_attr_idx = idx;
let mut bracket_depth = 1;
let mut end_attr_idx = None;
for (c_idx, next_c) in chars_iter.by_ref() {
if next_c == '[' {
bracket_depth += 1;
} else if next_c == ']' {
bracket_depth -= 1;
if bracket_depth == 0 {
end_attr_idx = Some(c_idx + next_c.len_utf8());
break;
}
}
}
if let Some(end_idx) = end_attr_idx {
let attr_str = &after_attr[start_attr_idx..end_idx];
field_attributes.push(attr_str);
continue;
}
}
}
let rest_str = &after_attr[idx..];
if let Some(nl_idx) = rest_str.find('\n') {
line_to_parse = rest_str[..nl_idx].trim();
} else {
line_to_parse = rest_str.trim();
}
break;
}
if !line_to_parse.is_empty() {
let mut parts = line_to_parse;
if let Some(stripped_pub) = parts.strip_prefix("pub") {
parts = stripped_pub.trim();
if let Some(stripped_paren) = parts.strip_prefix('(')
&& let Some(close_paren) = stripped_paren.find(')')
{
parts = stripped_paren[close_paren + 1..].trim();
}
}
if let Some(colon) = parts.find(':') {
let field_name = parts[..colon].trim().to_string();
let mut clean_field = field_name.as_str();
if let Some(stripped) = clean_field.strip_prefix("r#") {
clean_field = stripped;
}
if !clean_field.is_empty()
&& clean_field.chars().all(|c| c.is_alphanumeric() || c == '_')
{
let mut final_col_name = clean_field.to_string();
let before_searchable_attr = &rest[..pos];
let mut prev_term_pos = 0;
let mut paren_depth = 0;
let mut bracket_depth = 0;
let chars_vec: Vec<char> = before_searchable_attr.chars().collect();
let mut char_idx = chars_vec.len();
while char_idx > 0 {
char_idx -= 1;
let c = chars_vec[char_idx];
if c == ')' {
paren_depth += 1;
} else if c == '(' {
if paren_depth > 0 {
paren_depth -= 1;
}
} else if c == ']' {
bracket_depth += 1;
} else if c == '[' {
if bracket_depth > 0 {
bracket_depth -= 1;
}
} else if paren_depth == 0 && bracket_depth == 0 {
if c == ';' || c == ',' || c == '{' {
let mut byte_pos = 0;
for &ch in &chars_vec[..=char_idx] {
byte_pos += ch.len_utf8();
}
prev_term_pos = byte_pos;
break;
}
}
}
let field_prefix = &before_searchable_attr[prev_term_pos..];
let mut scan_rest = field_prefix;
while let Some(d_pos) = scan_rest.find("#[diesel") {
let sub_chunk = &scan_rest[d_pos..];
let mut inner_bracket_depth = 0;
let mut closed_pos = None;
for (idx, c) in sub_chunk.char_indices() {
if c == '[' {
inner_bracket_depth += 1;
} else if c == ']' {
if inner_bracket_depth > 0 {
inner_bracket_depth -= 1;
if inner_bracket_depth == 0 {
closed_pos = Some(idx);
break;
}
}
}
}
if let Some(cb) = closed_pos {
let attr_content = &sub_chunk[..cb];
if let Some(custom_col) = extract_diesel_column_name(attr_content) {
final_col_name = custom_col;
}
scan_rest = &sub_chunk[cb + 1..];
} else {
break;
}
}
for attr in &field_attributes {
if let Some(custom_col) = extract_diesel_column_name(attr) {
final_col_name = custom_col;
}
}
fields.push((final_col_name, weight));
}
}
}
}
rest = &rest[pos + "#[searchable".len()..];
}
if fields.is_empty() {
None
} else {
Some((language, fields))
}
}
#[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_with_id("posts", &fields(&["title:String"]), IdType::BigSerial);
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_with_id(
"posts",
&fields(&["body:Option<String>"]),
IdType::BigSerial,
);
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_and_id(
"posts",
&fields(&["title:String"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
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_and_id(
"widgets",
&[],
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
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_and_id(
"posts",
&fields(&["body:Option<Text>"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(sql.contains("body TEXT NULL"));
}
#[test]
fn drop_table_sql_simple() {
assert_eq!(drop_table_sql("posts"), "DROP TABLE posts;\n");
}
#[test]
fn create_table_sql_emits_fk_column_with_constraint() {
let sql = create_table_sql_with_metadata_and_id(
"comments",
&fields(&["body:Text", "post:references"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(
sql.contains("post_id BIGINT NOT NULL REFERENCES posts(id)"),
"expected FK column with constraint; got:\n{sql}"
);
}
#[test]
fn create_table_sql_emits_fk_index_automatically() {
let sql = create_table_sql_with_metadata_and_id(
"comments",
&fields(&["post:references"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(
sql.contains("CREATE INDEX idx_comments_post_id ON comments (post_id);"),
"expected an automatic FK index; got:\n{sql}"
);
}
#[test]
fn create_table_sql_nullable_reference_has_no_not_null_but_keeps_constraint_and_index() {
let sql = create_table_sql_with_metadata_and_id(
"comments",
&fields(&["post:references?"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(
sql.contains("post_id BIGINT NULL REFERENCES posts(id)"),
"nullable FK column must omit NOT NULL but keep the constraint; got:\n{sql}"
);
assert!(sql.contains("CREATE INDEX idx_comments_post_id ON comments (post_id);"));
}
#[test]
fn create_table_sql_fk_index_not_duplicated_when_also_passed_via_index_flag() {
let mut explicit_indexes = BTreeSet::new();
explicit_indexes.insert("post_id".to_owned());
let sql = create_table_sql_with_metadata_and_id(
"comments",
&fields(&["post:references"]),
&explicit_indexes,
&BTreeMap::new(),
IdType::BigSerial,
);
assert_eq!(
sql.matches("CREATE INDEX idx_comments_post_id").count(),
1,
"the FK index and an explicit --index on the same field must not \
produce two CREATE INDEX statements:\n{sql}"
);
}
#[test]
fn create_table_sql_emits_unique_index_for_dsl_marker() {
let sql = create_table_sql_with_metadata_and_id(
"users",
&fields(&["email:String:unique"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(
sql.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"expected a unique index for the `:unique`-marked field; got:\n{sql}"
);
}
#[test]
fn create_table_sql_unique_index_is_distinct_from_plain_index() {
let sql = create_table_sql_with_metadata_and_id(
"users",
&fields(&["email:String:unique"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(
!sql.contains("CREATE INDEX idx_users_email ON users (email);"),
"a unique field must not also emit a plain, non-unique index; got:\n{sql}"
);
}
#[test]
fn create_table_sql_unique_field_not_duplicated_when_also_passed_via_index_flag() {
let mut explicit_indexes = BTreeSet::new();
explicit_indexes.insert("email".to_owned());
let sql = create_table_sql_with_metadata_and_id(
"users",
&fields(&["email:String:unique"]),
&explicit_indexes,
&BTreeMap::new(),
IdType::BigSerial,
);
assert_eq!(
sql.matches("CREATE UNIQUE INDEX idx_users_email_unique")
.count(),
1,
"got:\n{sql}"
);
assert!(
!sql.contains("CREATE INDEX idx_users_email ON"),
"the unique index already covers lookups; a redundant plain index \
must not also be emitted:\n{sql}"
);
}
#[test]
fn create_table_sql_unique_nullable_field_keeps_null_and_unique_index() {
let sql = create_table_sql_with_metadata_and_id(
"users",
&fields(&["nickname:Option<String>:unique"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(sql.contains("nickname TEXT NULL"), "got:\n{sql}");
assert!(
sql.contains("CREATE UNIQUE INDEX idx_users_nickname_unique ON users (nickname);"),
"got:\n{sql}"
);
}
#[test]
fn add_columns_up_sql_emits_unique_index() {
let sql = add_columns_up_sql("users", &fields(&["email:String:unique"]), "");
assert!(
sql.contains("ALTER TABLE users ADD COLUMN email TEXT NOT NULL;"),
"got:\n{sql}"
);
assert!(
sql.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"got:\n{sql}"
);
}
#[test]
fn remove_columns_down_sql_restores_unique_index_for_unique_field() {
let sql = remove_columns_down_sql("users", &fields(&["email:String:unique"]), "");
assert!(
sql.contains("ALTER TABLE users ADD COLUMN email TEXT NOT NULL"),
"got:\n{sql}"
);
assert!(
sql.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"rollback of RemoveXFromY must restore the UNIQUE index, not just the \
bare column; got:\n{sql}"
);
}
#[test]
fn add_columns_up_sql_unique_reference_skips_redundant_plain_index() {
let sql = add_columns_up_sql("posts", &fields(&["author:references:unique"]), "");
assert!(
sql.contains("CREATE UNIQUE INDEX idx_posts_author_id_unique ON posts (author_id);"),
"got:\n{sql}"
);
assert!(
!sql.contains("CREATE INDEX idx_posts_author_id ON posts (author_id);"),
"a references field that is also unique must not get a redundant \
plain index alongside its unique index; got:\n{sql}"
);
}
#[test]
fn remove_columns_down_sql_unique_reference_skips_redundant_plain_index() {
let sql = remove_columns_down_sql("posts", &fields(&["author:references:unique"]), "");
assert!(
sql.contains("CREATE UNIQUE INDEX idx_posts_author_id_unique ON posts (author_id);"),
"got:\n{sql}"
);
assert!(
!sql.contains("CREATE INDEX idx_posts_author_id ON posts (author_id);"),
"got:\n{sql}"
);
}
#[test]
fn unique_index_sql_names_index_with_unique_suffix() {
assert_eq!(
unique_index_sql("users", "email", &[]),
"CREATE UNIQUE INDEX idx_users_email_unique ON users (email);\n"
);
}
#[test]
fn unique_index_name_short_names_pass_through_unchanged() {
assert_eq!(
unique_index_name("users", "email", &[]),
"idx_users_email_unique"
);
}
#[test]
fn unique_index_name_truncates_long_names_to_fit_postgres_limit() {
let table = "a_very_long_table_name_that_pushes_the_identifier_over_the_limit";
let field = "an_equally_long_field_name_for_good_measure";
let name = unique_index_name(table, field, &[]);
assert!(
name.len() <= 63,
"index name must fit Postgres's identifier limit, got {} bytes: {name}",
name.len()
);
assert!(
name.starts_with("idx_a_very_long_table_name"),
"got: {name}"
);
}
#[test]
fn unique_index_name_disambiguates_distinct_long_names_that_share_a_prefix() {
let table = "a_very_long_table_name_that_pushes_the_identifier_over_the_limit";
let name_a = unique_index_name(table, "an_equally_long_field_name_alpha_variant", &[]);
let name_b = unique_index_name(table, "an_equally_long_field_name_bravo_variant", &[]);
assert_ne!(name_a, name_b);
}
#[test]
fn unique_index_name_is_deterministic() {
let table = "a_very_long_table_name_that_pushes_the_identifier_over_the_limit";
let field = "an_equally_long_field_name_for_good_measure";
assert_eq!(
unique_index_name(table, field, &[]),
unique_index_name(table, field, &[])
);
}
#[test]
fn unique_index_sql_uses_truncated_name_for_long_identifiers() {
let table = "a_very_long_table_name_that_pushes_the_identifier_over_the_limit";
let field = "an_equally_long_field_name_for_good_measure";
let sql = unique_index_sql(table, field, &[]);
let expected_name = unique_index_name(table, field, &[]);
assert!(
sql.contains(&format!(
"CREATE UNIQUE INDEX {expected_name} ON {table} ({field});"
)),
"got:\n{sql}"
);
}
#[test]
fn unique_index_name_disambiguates_coincidental_collision_with_plain_index() {
let colliding_field = fields(&["email_unique:String"]);
let name = unique_index_name("users", "email", &colliding_field);
assert_ne!(
name, "idx_users_email_unique",
"must disambiguate away from the name a same-named plain index \
would already claim"
);
assert!(name.len() <= 63, "got {} bytes: {name}", name.len());
}
#[test]
fn unique_index_name_no_collision_stays_the_plain_name() {
let unrelated_fields = fields(&["age:i32"]);
assert_eq!(
unique_index_name("users", "email", &unrelated_fields),
"idx_users_email_unique"
);
}
#[test]
fn create_table_sql_unique_field_avoids_name_collision_with_plain_index() {
let fields = fields(&["email:String:unique", "email_unique:String"]);
let indexes: BTreeSet<String> = std::iter::once("email_unique".to_owned()).collect();
let sql = create_table_sql_with_metadata_and_id(
"users",
&fields,
&indexes,
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(
sql.contains("CREATE INDEX idx_users_email_unique ON users (email_unique);"),
"got:\n{sql}"
);
assert!(
!sql.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"the unique index must not collide with the plain index's exact \
name; got:\n{sql}"
);
assert!(
sql.contains("CREATE UNIQUE INDEX idx_users_email_unique_")
&& sql.contains(" ON users (email);"),
"the unique index must still exist, under a disambiguated name; \
got:\n{sql}"
);
}
#[test]
fn existing_schema_columns_parses_declared_column_names() {
let schema = append_schema_table("", "users", &fields(&["email_unique:String"]));
let columns = existing_schema_columns(&schema, "users");
assert!(
columns.contains(&"email_unique".to_owned()),
"got: {columns:?}"
);
assert!(columns.contains(&"id".to_owned()), "got: {columns:?}");
assert!(
columns.contains(&"created_at".to_owned()),
"got: {columns:?}"
);
}
#[test]
fn existing_schema_columns_empty_for_unknown_table() {
let schema = append_schema_table("", "users", &fields(&["email:String"]));
assert!(existing_schema_columns(&schema, "posts").is_empty());
}
#[test]
fn add_columns_up_sql_avoids_name_collision_with_earlier_migrations_columns() {
let existing_schema = append_schema_table("", "users", &fields(&["email_unique:String"]));
let sql = add_columns_up_sql("users", &fields(&["email:String:unique"]), &existing_schema);
assert!(
!sql.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"must not collide with the pre-existing email_unique column's \
plain index name; got:\n{sql}"
);
assert!(
sql.contains("CREATE UNIQUE INDEX idx_users_email_unique_"),
"the unique index must still exist, under a disambiguated name; \
got:\n{sql}"
);
}
#[test]
fn remove_columns_down_sql_avoids_name_collision_with_earlier_migrations_columns() {
let existing_schema = append_schema_table("", "users", &fields(&["email_unique:String"]));
let sql =
remove_columns_down_sql("users", &fields(&["email:String:unique"]), &existing_schema);
assert!(
!sql.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"got:\n{sql}"
);
assert!(
sql.contains("CREATE UNIQUE INDEX idx_users_email_unique_"),
"got:\n{sql}"
);
}
#[test]
fn add_columns_up_sql_emits_fk_constraint_and_index() {
let sql = add_columns_up_sql("comments", &fields(&["post:references"]), "");
assert!(
sql.contains(
"ALTER TABLE comments ADD COLUMN post_id BIGINT NOT NULL REFERENCES posts(id);"
),
"got:\n{sql}"
);
assert!(
sql.contains("CREATE INDEX idx_comments_post_id ON comments (post_id);"),
"got:\n{sql}"
);
}
#[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_encrypt_migration() {
match detect_migration_shape("EncryptApiTokenOnAccounts") {
MigrationShape::EncryptColumns { table, columns } => {
assert_eq!(table, "accounts");
assert_eq!(columns, vec!["api_token".to_string()]);
}
other => panic!("expected EncryptColumns, got {other:?}"),
}
}
#[test]
fn encrypt_migration_documents_backfill_and_rollback() {
let cols = vec!["api_token".to_string()];
let up = encrypt_columns_up_sql("accounts", &cols);
let down = encrypt_columns_down_sql("accounts", &cols);
assert!(up.contains("active_record_encryption"));
assert!(up.contains("encrypt_text"));
assert!(up.contains("autumn-safety: backfill"));
assert!(up.contains("api_token"));
assert!(up.contains("ALTER TABLE accounts ALTER COLUMN api_token TYPE TEXT;"));
assert!(down.contains("decrypt_text"));
assert!(down.contains("Rollback"));
assert!(down.contains("api_token"));
}
#[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_up_sql_includes_safety_comment_for_not_null() {
let f = fields(&["title:String"]);
let sql = add_columns_up_sql("posts", &f, "");
assert!(
sql.contains("autumn-safety: potentially-blocking"),
"NOT NULL column must carry a safety comment; got:\n{sql}"
);
}
#[test]
fn add_columns_up_sql_no_safety_comment_for_nullable() {
let f = fields(&["subtitle:Option<String>"]);
let sql = add_columns_up_sql("posts", &f, "");
assert!(
!sql.contains("autumn-safety"),
"nullable column must NOT carry a safety comment; got:\n{sql}"
);
}
#[test]
fn remove_columns_up_sql_includes_safety_comment() {
let f = fields(&["body:String"]);
let sql = remove_columns_up_sql("posts", &f);
assert!(
sql.contains("autumn-safety: destructive"),
"DROP COLUMN must carry a safety comment; got:\n{sql}"
);
assert!(sql.contains("ALTER TABLE posts DROP COLUMN body;"));
}
#[test]
fn remove_columns_down_sql_restores_fk_constraint_and_index_for_references_field() {
let f = fields(&["post:references"]);
let sql = remove_columns_down_sql("comments", &f, "");
assert!(
sql.contains(
"ALTER TABLE comments ADD COLUMN post_id BIGINT NOT NULL REFERENCES posts(id);"
),
"got:\n{sql}"
);
assert!(
sql.contains("CREATE INDEX idx_comments_post_id ON comments (post_id);"),
"got:\n{sql}"
);
}
#[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 sqlite_add_columns_down_drops_reference_index_before_column() {
let f = fields(&["author:references?"]);
let up = add_columns_up_sql_for(DatabaseBackend::Sqlite, "posts", &f, "").unwrap();
assert!(
up.contains("CREATE INDEX idx_posts_author_id ON posts (author_id);"),
"up:\n{up}"
);
let down = add_columns_down_sql_for(DatabaseBackend::Sqlite, "posts", &f, "");
let drop_idx = down
.find("DROP INDEX idx_posts_author_id;")
.expect("drop index");
let drop_col = down
.find("ALTER TABLE posts DROP COLUMN author_id;")
.expect("drop column");
assert!(
drop_idx < drop_col,
"DROP INDEX must precede DROP COLUMN:\n{down}"
);
}
#[test]
fn sqlite_add_columns_down_drops_unique_index_before_column() {
let f = fields(&["email:Option<String>:unique"]);
let up = add_columns_up_sql_for(DatabaseBackend::Sqlite, "users", &f, "").unwrap();
assert!(
up.contains("CREATE UNIQUE INDEX idx_users_email_unique ON users (email);"),
"up:\n{up}"
);
let down = add_columns_down_sql_for(DatabaseBackend::Sqlite, "users", &f, "");
let drop_idx = down
.find("DROP INDEX idx_users_email_unique;")
.expect("drop index");
let drop_col = down
.find("ALTER TABLE users DROP COLUMN email;")
.expect("drop column");
assert!(
drop_idx < drop_col,
"DROP INDEX must precede DROP COLUMN:\n{down}"
);
}
#[test]
fn postgres_add_columns_down_has_no_explicit_drop_index() {
let f = fields(&["author:references?"]);
let down = add_columns_down_sql_for(DatabaseBackend::Postgres, "posts", &f, "");
assert_eq!(down, "ALTER TABLE posts DROP COLUMN author_id;\n");
assert_eq!(add_columns_down_sql("posts", &f), down);
}
#[test]
fn sqlite_remove_columns_up_drops_reference_index_before_column() {
let f = fields(&["author:references?"]);
let up = remove_columns_up_sql_for(DatabaseBackend::Sqlite, "posts", &f, "");
let drop_idx = up
.find("DROP INDEX IF EXISTS idx_posts_author_id;")
.expect("drop index");
let drop_col = up
.find("ALTER TABLE posts DROP COLUMN author_id;")
.expect("drop column");
assert!(
drop_idx < drop_col,
"DROP INDEX must precede DROP COLUMN:\n{up}"
);
}
#[test]
fn sqlite_remove_columns_up_drops_unique_index_before_column() {
let f = fields(&["email:Option<String>:unique"]);
let up = remove_columns_up_sql_for(DatabaseBackend::Sqlite, "users", &f, "");
let drop_idx = up
.find("DROP INDEX IF EXISTS idx_users_email_unique;")
.expect("drop index");
let drop_col = up
.find("ALTER TABLE users DROP COLUMN email;")
.expect("drop column");
assert!(
drop_idx < drop_col,
"DROP INDEX must precede DROP COLUMN:\n{up}"
);
}
#[test]
fn sqlite_remove_columns_up_drops_plain_index_before_column() {
let f = fields(&["title:String"]);
let up = remove_columns_up_sql_for(DatabaseBackend::Sqlite, "posts", &f, "");
let drop_idx = up
.find("DROP INDEX IF EXISTS idx_posts_title;")
.expect("drop index");
let drop_col = up
.find("ALTER TABLE posts DROP COLUMN title;")
.expect("drop column");
assert!(
drop_idx < drop_col,
"DROP INDEX IF EXISTS must precede DROP COLUMN:\n{up}"
);
}
#[test]
fn postgres_remove_columns_up_has_no_explicit_drop_index() {
let f = fields(&["author:references?"]);
let up = remove_columns_up_sql_for(DatabaseBackend::Postgres, "posts", &f, "");
assert!(!up.contains("DROP INDEX"), "up:\n{up}");
assert!(up.contains("ALTER TABLE posts DROP COLUMN author_id;"));
assert_eq!(remove_columns_up_sql("posts", &f), up);
}
#[test]
fn create_table_emits_check_constraint_for_enum() {
let sql = create_table_sql_with_metadata_and_id(
"posts",
&fields(&["status:enum{draft,published,archived}"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(
sql.contains(
"status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'archived'))"
),
"got:\n{sql}"
);
}
#[test]
fn create_table_check_comes_after_default() {
let mut defaults = BTreeMap::new();
defaults.insert("status".to_owned(), "'draft'".to_owned());
let sql = create_table_sql_with_metadata_and_id(
"posts",
&fields(&["status:enum{draft,published}"]),
&BTreeSet::new(),
&defaults,
IdType::BigSerial,
);
assert!(
sql.contains(
"status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published'))"
),
"got:\n{sql}"
);
}
#[test]
fn create_table_nullable_enum_check_allows_null() {
let sql = create_table_sql_with_metadata_and_id(
"posts",
&fields(&["status:Option<enum{draft,published}>"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(
sql.contains("status TEXT NULL CHECK (status IN ('draft', 'published'))"),
"got:\n{sql}"
);
}
#[test]
fn add_columns_emits_check_constraint_for_enum() {
let f = fields(&["status:enum{draft,published,archived}"]);
let sql = add_columns_up_sql("posts", &f, "");
assert!(
sql.contains(
"ALTER TABLE posts ADD COLUMN status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'archived'));"
),
"got:\n{sql}"
);
}
#[test]
fn add_columns_down_drops_enum_column_plainly() {
let f = fields(&["status:enum{draft,published}"]);
let sql = add_columns_down_sql("posts", &f);
assert_eq!(sql, "ALTER TABLE posts DROP COLUMN status;\n");
}
#[test]
fn non_enum_column_has_no_check_constraint() {
let sql = create_table_sql_with_metadata_and_id(
"posts",
&fields(&["title:String"]),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(!sql.contains("CHECK"), "got:\n{sql}");
}
#[test]
fn remove_columns_down_sql_restores_check_constraint_for_enum_field() {
let f = fields(&["status:enum{draft,published,archived}"]);
let sql = remove_columns_down_sql("posts", &f, "");
assert!(
sql.contains(
"ALTER TABLE posts ADD COLUMN status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'archived'));"
),
"got:\n{sql}"
);
}
#[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);
}
const SEED_BIN: &str = "\
//! Database seed binary.
//!
//! autumn seed --count 200 --model Post
use autumn_web::seed::SeedContext;
#[autumn_web::main]
async fn main() {}
";
#[test]
fn link_seed_bin_injects_path_qualified_schema_and_models_mods() {
let linked = link_models_into_seed_bin(SEED_BIN);
assert!(
linked.contains("#[path = \"../schema.rs\"]\nmod schema;"),
"must inject a #[path]-qualified `mod schema;`:\n{linked}"
);
assert!(
linked.contains("#[path = \"../models/mod.rs\"]\nmod models;"),
"must inject a #[path]-qualified `mod models;`:\n{linked}"
);
}
#[test]
fn link_seed_bin_inserts_after_inner_doc_block_not_before() {
let linked = link_models_into_seed_bin(SEED_BIN);
let doc_end = linked.find("use autumn_web::seed").unwrap();
let mods_at = linked.find("mod schema;").unwrap();
assert!(
linked.find("//! Database seed binary.").unwrap() < mods_at,
"mods must come after the doc comment: {linked}"
);
assert!(
mods_at < doc_end,
"mods must be inserted before the first ordinary item (`use`): {linked}"
);
}
#[test]
fn link_seed_bin_is_idempotent() {
let once = link_models_into_seed_bin(SEED_BIN);
let twice = link_models_into_seed_bin(&once);
assert_eq!(
once, twice,
"re-linking an already-linked seed bin is a no-op"
);
}
#[test]
fn link_seed_bin_preserves_existing_declarations() {
let existing = "\
//! seed
#[path = \"../models/mod.rs\"]
mod models;
use autumn_web::seed::SeedContext;
";
let linked = link_models_into_seed_bin(existing);
assert_eq!(
linked.matches("mod models;").count(),
1,
"must not duplicate the pre-existing `mod models;`:\n{linked}"
);
assert!(
linked.contains("mod schema;"),
"must still add the missing `mod schema;`:\n{linked}"
);
}
#[test]
fn link_seed_bin_no_change_when_both_present() {
let existing = "\
//! seed
#[path = \"../schema.rs\"]
mod schema;
#[path = \"../models/mod.rs\"]
mod models;
use autumn_web::seed::SeedContext;
";
assert_eq!(link_models_into_seed_bin(existing), existing);
}
#[test]
fn unlink_seed_bin_removes_the_injected_path_qualified_mods() {
let linked = link_models_into_seed_bin(SEED_BIN);
let unlinked = unlink_models_from_seed_bin(&linked);
assert!(
!unlinked.contains("mod schema;") && !unlinked.contains("mod models;"),
"both injected `mod` declarations must be gone:\n{unlinked}"
);
assert!(
!unlinked.contains("#[path = \"../schema.rs\"]")
&& !unlinked.contains("#[path = \"../models/mod.rs\"]"),
"no dangling `#[path]` attributes may remain:\n{unlinked}"
);
assert!(
unlinked.contains("use autumn_web::seed::SeedContext;")
&& unlinked.contains("async fn main() {}")
&& unlinked.contains("//! Database seed binary."),
"the original seed-binary items must be preserved:\n{unlinked}"
);
}
#[test]
fn unlink_seed_bin_is_idempotent_and_noop_when_absent() {
assert_eq!(unlink_models_from_seed_bin(SEED_BIN), SEED_BIN);
let linked = link_models_into_seed_bin(SEED_BIN);
let once = unlink_models_from_seed_bin(&linked);
let twice = unlink_models_from_seed_bin(&once);
assert_eq!(
once, twice,
"re-unlinking an already-unlinked seed bin is a no-op"
);
}
#[test]
fn unlink_seed_bin_leaves_hand_written_plain_mod_untouched() {
let existing = "\
//! seed
mod schema;
use autumn_web::seed::SeedContext;
";
assert_eq!(unlink_models_from_seed_bin(existing), existing);
}
#[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"));
}
#[test]
fn ensure_routes_entries_skips_routes_macro_in_comments() {
let original = "\
//! Register handlers with `.routes(routes![])` in main.
// e.g. routes![index]
fn main() {
routes![]
}
";
let updated = ensure_routes_entries(original, &["foo".into()]);
assert!(
updated.contains("//! Register handlers with `.routes(routes![])` in main."),
"doc comment must be untouched: {updated}"
);
assert!(
updated.contains("// e.g. routes![index]"),
"line comment must be untouched: {updated}"
);
assert!(
updated.contains("foo,"),
"entry must land in the real macro: {updated}"
);
assert!(
updated.rfind("foo,").unwrap() > updated.find("fn main()").unwrap(),
"entry must be inside the macro in fn main, not in the comments: {updated}"
);
}
#[test]
fn ensure_routes_entries_only_comment_matches_is_noop() {
let original = "//! Add handlers via routes![].\nfn main() {}\n";
let updated = ensure_routes_entries(original, &["foo".into()]);
assert_eq!(updated, original);
}
#[test]
fn ensure_routes_entries_url_in_string_before_macro_is_code() {
let original =
"fn main() {\n let url = \"https://example.com\"; app.routes(routes![index]);\n}\n";
let updated = ensure_routes_entries(original, &["foo".into()]);
assert!(
updated.contains("foo"),
"routes![ after a URL string must be edited as code: {updated}"
);
assert!(
updated.contains("\"https://example.com\""),
"the string literal must be untouched: {updated}"
);
}
#[test]
fn ensure_routes_entries_genuinely_commented_macro_still_skipped() {
let original = "\
// see \"docs\": routes![index]
fn main() {
routes![]
}
";
let updated = ensure_routes_entries(original, &["foo".into()]);
assert!(
updated.contains("// see \"docs\": routes![index]"),
"comment must be untouched: {updated}"
);
assert!(
updated.rfind("foo").unwrap() > updated.find("fn main()").unwrap(),
"entry must land in the real macro, not the comment: {updated}"
);
}
#[test]
fn ensure_routes_entries_slashes_in_string_with_macro_on_line() {
let original = "fn main() {\n let s = \"say \\\"// not a comment\\\"\"; app.routes(routes![index]);\n}\n";
let updated = ensure_routes_entries(original, &["foo".into()]);
assert!(
updated.contains("foo"),
"escaped quotes in a string must not hide the real macro: {updated}"
);
}
#[test]
fn remove_routes_entries_with_prefix_skips_commented_macro() {
let original = "\
// routes![channels::chat::chat_page]
fn main() {
routes![
channels::chat::chat_page,
index,
]
}
";
let updated = remove_routes_entries_with_prefix(original, "channels::chat::");
assert!(
updated.contains("// routes![channels::chat::chat_page]"),
"comment must be untouched: {updated}"
);
assert!(updated.contains("index,"));
assert!(
!updated.contains("routes![\n channels::chat::chat_page"),
"real entry must be removed: {updated}"
);
}
#[test]
fn remove_routes_entries_with_prefix_drops_matching_entries() {
let original = "fn main() {\n routes![\n index,\n channels::chat::chat_page,\n channels::chat::chat_events,\n channels::chat::chat_publish,\n ]\n}\n";
let updated = remove_routes_entries_with_prefix(original, "channels::chat::");
assert!(updated.contains("index,"));
assert!(!updated.contains("channels::chat::chat_page"));
assert!(!updated.contains("channels::chat::chat_events"));
assert!(!updated.contains("channels::chat::chat_publish"));
}
#[test]
fn remove_routes_entries_with_prefix_leaves_other_prefixes_untouched() {
let original = "fn main() {\n routes![\n channels::chat::chat_page,\n channels::notifications::notifications_page,\n ]\n}\n";
let updated = remove_routes_entries_with_prefix(original, "channels::chat::");
assert!(!updated.contains("channels::chat::chat_page"));
assert!(updated.contains("channels::notifications::notifications_page"));
}
#[test]
fn remove_routes_entries_with_prefix_is_noop_when_nothing_matches() {
let original = "fn main() {\n routes![index, hello]\n}\n";
let updated = remove_routes_entries_with_prefix(original, "channels::chat::");
assert_eq!(updated, original);
}
#[test]
fn remove_routes_entries_with_prefix_no_routes_macro_leaves_file_alone() {
let original = "fn main() {}\n";
let updated = remove_routes_entries_with_prefix(original, "channels::chat::");
assert_eq!(updated, original);
}
#[test]
fn remove_then_ensure_routes_entries_composes_for_transport_switch() {
let original = "fn main() {\n routes![\n channels::chat::chat_page,\n channels::chat::chat_events,\n channels::chat::chat_publish,\n ]\n}\n";
let stripped = remove_routes_entries_with_prefix(original, "channels::chat::");
let updated = ensure_routes_entries(
&stripped,
&[
"channels::chat::chat_ws".to_owned(),
"channels::chat::chat_publish".to_owned(),
],
);
assert!(!updated.contains("chat_page"));
assert!(!updated.contains("chat_events"));
assert!(updated.contains("channels::chat::chat_ws"));
assert!(updated.contains("channels::chat::chat_publish"));
}
fn app_main() -> &'static str {
"use autumn_web::prelude::*;\n\
\n\
#[autumn_web::main]\n\
async fn main() {\n\
autumn_web::app()\n\
.routes(routes![index])\n\
.run()\n\
.await;\n\
}\n"
}
#[test]
fn add_mail_preview_inserts_before_run() {
let updated = add_mail_preview_to_app(app_main(), "mailers::welcome::WelcomeMailer");
assert!(
updated.contains("mail_previews![mailers::welcome::WelcomeMailer]"),
"must insert mail_previews call: {updated}"
);
let preview_pos = updated.find("mail_previews").unwrap();
let run_pos = updated.find(".run()").unwrap();
assert!(
preview_pos < run_pos,
"mail_previews must appear before .run(): {updated}"
);
}
#[test]
fn add_mail_preview_idempotent() {
let first = add_mail_preview_to_app(app_main(), "mailers::welcome::WelcomeMailer");
let second = add_mail_preview_to_app(&first, "mailers::welcome::WelcomeMailer");
assert_eq!(first, second, "second call must be a no-op");
}
#[test]
fn add_mail_preview_augments_existing_call() {
let after_first = add_mail_preview_to_app(app_main(), "mailers::welcome::WelcomeMailer");
let after_second = add_mail_preview_to_app(&after_first, "mailers::notify::NotifyMailer");
assert!(after_second.contains("mailers::welcome::WelcomeMailer"));
assert!(after_second.contains("mailers::notify::NotifyMailer"));
assert_eq!(
after_second.matches("mail_previews![").count(),
1,
"must not duplicate the mail_previews![] call: {after_second}"
);
}
#[test]
fn add_mail_preview_preserves_run_await() {
let updated = add_mail_preview_to_app(app_main(), "mailers::welcome::WelcomeMailer");
assert!(updated.contains(".run()"), ".run() must still be present");
assert!(updated.contains(".await;"), ".await must still be present");
}
#[test]
fn add_jobs_registration_inserts_before_run() {
let updated = add_jobs_registration_to_app(app_main());
assert!(
updated.contains(".jobs(jobs::registered_jobs())"),
"must insert .jobs call: {updated}"
);
let jobs_pos = updated.find(".jobs(").unwrap();
let run_pos = updated.find(".run()").unwrap();
assert!(
jobs_pos < run_pos,
".jobs() must appear before .run(): {updated}"
);
}
#[test]
fn add_jobs_registration_idempotent() {
let first = add_jobs_registration_to_app(app_main());
let second = add_jobs_registration_to_app(&first);
assert_eq!(first, second, "second call must be a no-op");
}
#[test]
fn add_jobs_registration_preserves_run_await() {
let updated = add_jobs_registration_to_app(app_main());
assert!(updated.contains(".run()"), ".run() must still be present");
assert!(updated.contains(".await;"), ".await must still be present");
}
#[test]
fn add_jobs_registration_single_call_even_with_two_jobs() {
let after_first = add_jobs_registration_to_app(app_main());
let after_second = add_jobs_registration_to_app(&after_first);
assert_eq!(
after_second
.matches(".jobs(jobs::registered_jobs())")
.count(),
1,
"must not duplicate the .jobs() call"
);
}
#[test]
fn augment_registered_jobs_creates_fn_when_absent() {
let mod_rs = "pub mod send_welcome_email;\n";
let updated = augment_registered_jobs(mod_rs, "send_welcome_email::send_welcome_email");
assert!(
updated.contains("pub fn registered_jobs()"),
"must create registered_jobs fn: {updated}"
);
assert!(
updated.contains("jobs![send_welcome_email::send_welcome_email]"),
"must include the new entry: {updated}"
);
}
#[test]
fn augment_registered_jobs_splices_into_existing() {
let mod_rs = "pub mod send_welcome_email;\n\n\
#[must_use]\n\
pub fn registered_jobs() -> Vec<autumn_web::job::JobInfo> {\n \
autumn_web::jobs![send_welcome_email::send_welcome_email]\n}\n";
let updated = augment_registered_jobs(mod_rs, "post_notification::post_notification");
assert!(
updated.contains("send_welcome_email::send_welcome_email"),
"must preserve existing entry"
);
assert!(
updated.contains("post_notification::post_notification"),
"must include new entry"
);
assert_eq!(
updated.matches("jobs![").count(),
1,
"must not duplicate jobs![]: {updated}"
);
}
#[test]
fn augment_registered_jobs_idempotent() {
let mod_rs = "pub mod send_welcome_email;\n\n\
#[must_use]\n\
pub fn registered_jobs() -> Vec<autumn_web::job::JobInfo> {\n \
autumn_web::jobs![send_welcome_email::send_welcome_email]\n}\n";
let second = augment_registered_jobs(mod_rs, "send_welcome_email::send_welcome_email");
assert_eq!(mod_rs, second, "duplicate entry must be a no-op");
}
#[test]
fn augment_registered_jobs_no_double_comma_with_trailing_comma() {
let mod_rs = "pub fn registered_jobs() -> Vec<autumn_web::job::JobInfo> {\n \
autumn_web::jobs![\n send_welcome_email::send_welcome_email,\n ]\n}\n";
let updated = augment_registered_jobs(mod_rs, "post_notification::post_notification");
assert!(
!updated.contains(",,"),
"must not produce double comma: {updated}"
);
assert!(
updated.contains("post_notification::post_notification"),
"must include new entry"
);
}
#[test]
fn augment_registered_jobs_empty_mod_rs_creates_full_fn() {
let updated = augment_registered_jobs("", "foo::foo");
assert!(updated.contains("pub fn registered_jobs()"));
assert!(updated.contains("jobs![foo::foo]"));
}
#[test]
fn remove_job_entry_restores_original_when_it_was_the_only_one() {
let base = "pub mod send_welcome_email;\n";
let after_add = augment_registered_jobs(base, "send_welcome_email::send_welcome_email");
assert_ne!(after_add, base);
assert_eq!(
remove_job_entry(&after_add, "send_welcome_email::send_welcome_email"),
base
);
}
#[test]
fn remove_job_entry_keeps_other_entries() {
let base = "pub mod send_welcome_email;\n";
let with_one = augment_registered_jobs(base, "send_welcome_email::send_welcome_email");
let with_both = augment_registered_jobs(&with_one, "post_notification::post_notification");
let reverted = remove_job_entry(&with_both, "post_notification::post_notification");
assert!(reverted.contains("send_welcome_email::send_welcome_email"));
assert!(!reverted.contains("post_notification::post_notification"));
}
#[test]
fn remove_job_entry_is_idempotent_when_absent() {
let base = "pub mod send_welcome_email;\n";
assert_eq!(remove_job_entry(base, "nonexistent::nonexistent"), base);
}
#[test]
fn remove_jobs_registration_from_app_restores_original() {
let base = "fn main() {\n App::new()\n .run()\n}\n";
let after_add = add_jobs_registration_to_app(base);
assert_ne!(after_add, base);
assert_eq!(remove_jobs_registration_from_app(&after_add), base);
}
#[test]
fn remove_jobs_registration_from_app_is_idempotent_when_absent() {
let base = "fn main() {\n App::new()\n .run()\n}\n";
assert_eq!(remove_jobs_registration_from_app(base), base);
}
#[test]
fn remove_mail_preview_from_app_restores_original_when_only_entry() {
let base = "fn main() {\n App::new()\n .run()\n}\n";
let after_add = add_mail_preview_to_app(base, "WelcomeMailer");
assert_ne!(after_add, base);
assert_eq!(
remove_mail_preview_from_app(&after_add, "WelcomeMailer"),
base
);
}
#[test]
fn remove_mail_preview_from_app_keeps_other_entries() {
let base = "fn main() {\n App::new()\n .run()\n}\n";
let with_one = add_mail_preview_to_app(base, "WelcomeMailer");
let with_both = add_mail_preview_to_app(&with_one, "ReceiptMailer");
let reverted = remove_mail_preview_from_app(&with_both, "ReceiptMailer");
assert!(reverted.contains("WelcomeMailer"));
assert!(!reverted.contains("ReceiptMailer"));
}
#[test]
fn remove_mail_preview_from_app_removes_a_rustfmt_wrapped_call() {
let src = "fn main() {\n App::new()\n .mail_previews(mail_previews![\n WelcomeMailer,\n ])\n .run()\n}\n";
let updated = remove_mail_preview_from_app(src, "WelcomeMailer");
assert!(
!updated.contains("mail_previews"),
"the whole call must be removed once its only entry is gone, got:\n{updated}"
);
assert_eq!(updated, "fn main() {\n App::new()\n .run()\n}\n");
}
#[test]
fn remove_mail_preview_from_app_keeps_other_entry_in_a_rustfmt_wrapped_call() {
let src = "fn main() {\n App::new()\n .mail_previews(mail_previews![\n WelcomeMailer,\n ReceiptMailer,\n ])\n .run()\n}\n";
let updated = remove_mail_preview_from_app(src, "WelcomeMailer");
assert!(
updated.contains("mail_previews"),
"the call must survive while another entry remains, got:\n{updated}"
);
assert!(updated.contains("ReceiptMailer"));
assert!(!updated.contains("WelcomeMailer"));
}
#[test]
fn remove_mail_preview_from_app_is_idempotent_when_absent() {
let base = "fn main() {\n App::new()\n .run()\n}\n";
assert_eq!(remove_mail_preview_from_app(base, "WelcomeMailer"), base);
}
#[test]
fn ensure_feature_status_reports_not_found_when_dep_absent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nserde = \"1\"\n";
let (out, applied) = ensure_autumn_web_feature_status(cargo, "db");
assert!(!applied, "absent dependency must report not-applied");
assert_eq!(
out, cargo,
"an unlocatable dependency leaves the toml untouched"
);
}
#[test]
fn ensure_feature_status_reports_applied_when_present() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n";
let (_, added) = ensure_autumn_web_feature_status(cargo, "db");
assert!(added, "adding the feature to a present dep reports applied");
let once = ensure_autumn_web_feature(cargo, "db");
let (_, again) = ensure_autumn_web_feature_status(&once, "db");
assert!(again, "an already-present feature still reports applied");
}
#[test]
fn ensure_feature_transforms_bare_string_dep() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(
updated.contains("\"mail\""),
"must add mail feature: {updated}"
);
assert!(
updated.contains("version"),
"must preserve version: {updated}"
);
}
#[test]
fn ensure_feature_idempotent_bare_string() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n";
let once = ensure_autumn_web_feature(cargo, "mail");
let twice = ensure_autumn_web_feature(&once, "mail");
assert_eq!(once, twice, "second call must be a no-op");
}
#[test]
fn ensure_feature_adds_to_existing_features_list() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\n\
autumn-web = { version = \"0.6\", features = [\"db\"] }\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(updated.contains("\"mail\""));
assert!(updated.contains("\"db\""), "must preserve existing feature");
}
#[test]
fn ensure_feature_adds_features_key_when_absent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\n\
autumn-web = { version = \"0.6\" }\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(
updated.contains("\"mail\""),
"must add features key: {updated}"
);
}
#[test]
fn ensure_feature_idempotent_inline_table() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\n\
autumn-web = { version = \"0.6\", features = [\"mail\"] }\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert_eq!(cargo, updated, "already-present feature must be a no-op");
}
#[test]
fn ensure_feature_ignores_unrelated_deps() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\n\
serde = \"1\"\nautumn-web = \"0.6\"\ntracing = \"0.1\"\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(
updated.contains("serde = \"1\""),
"unrelated dep must be preserved"
);
assert!(updated.contains("\"mail\""));
}
#[test]
fn ensure_feature_returns_unchanged_when_autumn_web_absent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nserde = \"1\"\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert_eq!(cargo, updated, "no autumn-web dep → must be a no-op");
}
#[test]
fn ensure_feature_dep_without_closing_brace_uses_fallback() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = malformed\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(updated.contains("autumn-web = malformed"));
}
#[test]
fn ensure_feature_multiline_section_adds_to_existing_features() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies.autumn-web]\nversion = \"0.6\"\nfeatures = [\"db\"]\n";
let updated = ensure_autumn_web_feature(cargo, "inbound-mailgun");
assert!(
updated.contains("\"inbound-mailgun\""),
"must add feature to section: {updated}"
);
assert!(updated.contains("\"db\""), "must preserve existing feature");
}
#[test]
fn ensure_feature_multiline_section_inserts_features_when_absent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies.autumn-web]\nversion = \"0.6\"\n";
let updated = ensure_autumn_web_feature(cargo, "inbound-mailgun");
assert!(
updated.contains("\"inbound-mailgun\""),
"must insert features line: {updated}"
);
}
#[test]
fn ensure_feature_multiline_section_idempotent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies.autumn-web]\nversion = \"0.6\"\nfeatures = [\"inbound-mailgun\"]\n";
let updated = ensure_autumn_web_feature(cargo, "inbound-mailgun");
assert_eq!(
cargo, updated,
"already-present feature in section must be a no-op"
);
}
#[test]
fn ensure_feature_trailing_comment_on_string_dep() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\" # framework\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(
updated.contains("\"mail\""),
"must add feature despite trailing comment: {updated}"
);
assert!(
updated.contains("version"),
"must preserve version: {updated}"
);
}
#[test]
fn ensure_feature_trailing_comma_in_features_list() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = { version = \"0.6\", features = [\"db\",] }\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(
updated.contains("\"mail\""),
"must add feature after trailing comma: {updated}"
);
assert!(
!updated.contains(",,"),
"must not produce double comma: {updated}"
);
}
#[test]
fn ensure_feature_dotted_workspace_inserts_features_line() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web.workspace = true\n";
let updated = ensure_autumn_web_feature(cargo, "inbound-mailgun");
assert!(
updated.contains("\"inbound-mailgun\""),
"must insert features line: {updated}"
);
assert!(
updated.contains("autumn-web.features"),
"must use dotted key form: {updated}"
);
}
#[test]
fn ensure_feature_dotted_workspace_existing_features_line_spliced() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web.workspace = true\nautumn-web.features = [\"db\"]\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(
updated.contains("\"mail\""),
"must splice into existing features line: {updated}"
);
assert!(
updated.contains("\"db\""),
"must preserve existing feature: {updated}"
);
}
#[test]
fn ensure_feature_dotted_workspace_idempotent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web.workspace = true\nautumn-web.features = [\"inbound-mailgun\"]\n";
let updated = ensure_autumn_web_feature(cargo, "inbound-mailgun");
assert_eq!(cargo, updated, "already-present feature must be a no-op");
}
#[test]
fn ensure_feature_ignores_commented_features() {
let cargo_multiline = "[package]\nname=\"x\"\n\n[dependencies.autumn-web]\nversion = \"0.6\"\n# features = [\"inbound-mailgun\"]\n";
let updated_multiline = ensure_autumn_web_feature(cargo_multiline, "inbound-mailgun");
assert!(updated_multiline.contains("features = [\"inbound-mailgun\"]"));
let cargo_dotted = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web.workspace = true\n# autumn-web.features = [\"inbound-mailgun\"]\n";
let updated_dotted = ensure_autumn_web_feature(cargo_dotted, "inbound-mailgun");
assert!(updated_dotted.contains("autumn-web.features = [\"inbound-mailgun\"]"));
let cargo_inline = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = {\n version = \"0.6\",\n # features = [\"inbound-mailgun\"]\n}\n";
let updated_inline = ensure_autumn_web_feature(cargo_inline, "inbound-mailgun");
assert!(updated_inline.contains("features = [\"inbound-mailgun\"]"));
}
#[test]
fn ensure_feature_multiline_inline_table_inserts_features() {
let cargo =
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = {\n version = \"0.6\"\n}\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(
updated.contains("\"mail\""),
"must add feature to multiline inline table: {updated}"
);
assert!(
updated.contains("version = \"0.6\","),
"must add trailing comma to preceding entry: {updated}"
);
}
#[test]
fn ensure_feature_package_alias_dep_skipped_for_non_autumn_web_alias() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\naw = { package = \"autumn-web\", version = \"0.6\" }\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert_eq!(
cargo, updated,
"non-autumn_web alias must be left unchanged"
);
}
#[test]
fn ensure_feature_package_alias_dep_autumn_web_alias() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn_web = { package = \"autumn-web\", version = \"0.6\" }\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(
updated.contains("\"mail\""),
"autumn_web alias must have feature added: {updated}"
);
}
#[test]
fn ensure_feature_package_alias_dep_idempotent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn_web = { package = \"autumn-web\", version = \"0.6\", features = [\"mail\"] }\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert_eq!(cargo, updated, "already-present feature must be a no-op");
}
#[test]
fn ensure_feature_commented_dep_line_is_skipped() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\n# aw = { package = \"autumn-web\", version = \"0.6\" }\nautumn-web = \"0.6\"\n";
let updated = ensure_autumn_web_feature(cargo, "inbound-mailgun");
assert!(
updated.contains("# aw = { package"),
"comment line must be preserved as-is: {updated}"
);
let real_dep_line = updated
.lines()
.find(|l| l.trim_start().starts_with("autumn-web") && !l.trim_start().starts_with('#'))
.unwrap_or("");
assert!(
real_dep_line.contains("\"inbound-mailgun\""),
"feature must be added to the real dep line: {real_dep_line}"
);
}
#[test]
fn ensure_feature_multiline_inline_table_idempotent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = {\n version = \"0.6\",\n features = [\"mail\"]\n}\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert_eq!(cargo, updated, "already-present feature must be a no-op");
}
#[test]
fn ensure_feature_multiline_section_trailing_comment_on_header() {
let cargo =
"[package]\nname=\"x\"\n\n[dependencies.autumn-web] # pinned\nversion = \"0.6\"\n";
let updated = ensure_autumn_web_feature(cargo, "mail");
assert!(
updated.contains("\"mail\""),
"must handle trailing comment on section header: {updated}"
);
}
#[test]
fn add_mail_preview_unclosed_bracket_returns_unchanged() {
let src = "app()\n .mail_previews(mail_previews![Foo)\n .run()\n .await;\n";
let updated = add_mail_preview_to_app(src, "Bar");
assert_eq!(src, updated);
}
#[test]
fn add_mail_preview_no_run_returns_string_with_preview_appended() {
let src = "app()\n .routes(routes![index])\n";
let updated = add_mail_preview_to_app(src, "mailers::welcome::WelcomeMailer");
assert!(
!updated.contains("mail_previews"),
"no insertion point → no insertion"
);
}
#[test]
fn test_singularize_simple() {
assert_eq!(singularize("posts"), "post");
assert_eq!(singularize("categories"), "category");
assert_eq!(singularize("wishes"), "wish");
assert_eq!(singularize("test_search_records"), "test_search_record");
}
#[test]
fn test_parse_model_search_config_simple() {
let content = r#"
#[autumn_web::model(table = "test_search_records")]
#[searchable(language = "english")]
#[derive(PartialEq, Eq)]
pub struct SearchRecord {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
#[searchable(weight = "B")]
pub body: String,
}
"#;
let (lang, fields) = parse_model_search_config(content).unwrap();
assert_eq!(lang, "english");
assert_eq!(
fields,
vec![("title".to_string(), 'A'), ("body".to_string(), 'B'),]
);
}
#[test]
fn test_parse_model_search_config_robustness() {
let content_spaceless = r#"
#[autumn_web::model(table = "test_search_records")]
#[searchable(language="english")]
pub struct SearchRecord {
#[id]
pub id: i64,
#[searchable]
pub title: String,
}
"#;
let (lang, fields) = parse_model_search_config(content_spaceless).unwrap();
assert_eq!(lang, "english");
assert_eq!(fields, vec![("title".to_string(), 'D')]);
let content_leakage = r#"
#[autumn_web::model(table = "test_search_records")]
#[searchable(language = "simple")]
pub struct SearchRecord {
#[id]
pub id: i64,
#[searchable]
pub title: String,
#[searchable(weight = "B")]
pub body: String,
}
"#;
let (_, fields) = parse_model_search_config(content_leakage).unwrap();
assert_eq!(
fields,
vec![
("title".to_string(), 'D'), ("body".to_string(), 'B'),
]
);
let content_comments = r#"
#[autumn_web::model(table = "test_search_records")]
#[searchable(language = "english")]
pub struct SearchRecord {
#[id]
pub id: i64,
// #[searchable(weight = "A")]
// pub old_title: String,
/*
#[searchable(weight = "C")]
pub commented_out: String,
*/
#[searchable(weight = "B")]
pub body: String,
}
"#;
let (_, fields) = parse_model_search_config(content_comments).unwrap();
assert_eq!(fields, vec![("body".to_string(), 'B')]);
let content_collision = r#"
#[autumn_web::model(table = "test_search_records")]
#[searchable_fields]
pub struct SearchRecord {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
}
"#;
let (_, fields) = parse_model_search_config(content_collision).unwrap();
assert_eq!(fields, vec![("title".to_string(), 'A')]);
}
#[test]
fn test_singularize_ses_words() {
assert_eq!(singularize("cases"), "case");
assert_eq!(singularize("databases"), "database");
assert_eq!(singularize("phases"), "phase");
assert_eq!(singularize("uses"), "use");
assert_eq!(singularize("statuses"), "status");
assert_eq!(singularize("aliases"), "alias");
assert_eq!(singularize("buses"), "bus");
}
#[test]
fn test_singularize_irregular_plurals() {
assert_eq!(singularize("people"), "person");
assert_eq!(singularize("salespeople"), "salesperson");
assert_eq!(singularize("children"), "child");
assert_eq!(singularize("supermen"), "superman");
assert_eq!(singularize("women"), "woman");
}
#[test]
fn test_parse_model_search_config_helper_structs() {
let content = r#"
pub struct HelperOne {
pub a: i32,
}
#[autumn_web::model(table = "pages")]
#[searchable(language = "english")]
pub struct Page {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
}
pub struct HelperTwo {
#[searchable(weight = "B")]
pub b: String,
}
"#;
let (lang, fields) = parse_model_search_config(content).unwrap();
assert_eq!(lang, "english");
assert_eq!(fields, vec![("title".to_string(), 'A')]);
}
#[test]
fn test_detect_migration_shape_to_tables() {
match detect_migration_shape("AddSearchToTodos") {
MigrationShape::AddSearch { table } => assert_eq!(table, "todos"),
other => panic!("expected AddSearch, got {other:?}"),
}
match detect_migration_shape("AddSearchToTopics") {
MigrationShape::AddSearch { table } => assert_eq!(table, "topics"),
other => panic!("expected AddSearch, got {other:?}"),
}
match detect_migration_shape("AddSearchTokenToPosts") {
MigrationShape::AddColumns { table } => assert_eq!(table, "posts"),
other => panic!("expected AddColumns, got {other:?}"),
}
}
#[test]
fn test_singularize_movies_and_series() {
assert_eq!(singularize("movies"), "movie");
assert_eq!(singularize("series"), "series");
assert_eq!(singularize("cookies"), "cookie");
assert_eq!(singularize("zombies"), "zombie");
}
#[test]
fn test_detect_migration_shape_internal_to() {
match detect_migration_shape("AddSearchToTopToBottoms") {
MigrationShape::AddSearch { table } => assert_eq!(table, "top_to_bottoms"),
other => panic!("expected AddSearch, got {other:?}"),
}
match detect_migration_shape("AddSearchToToDoItems") {
MigrationShape::AddSearch { table } => assert_eq!(table, "to_do_items"),
other => panic!("expected AddSearch, got {other:?}"),
}
}
#[test]
fn test_parse_model_search_config_raw_identifiers() {
let content = r#"
#[autumn_web::model(table = "items")]
pub struct Item {
#[id]
pub id: i64,
#[searchable]
pub r#type: String,
#[searchable(weight = "B")]
pub r#match: String,
}
"#;
let (lang, fields) = parse_model_search_config(content).unwrap();
assert_eq!(lang, "simple");
assert_eq!(
fields,
vec![("type".to_string(), 'D'), ("match".to_string(), 'B')]
);
}
#[test]
fn test_parse_model_search_config_reverse_lookup() {
let content = r#"
#[searchable(language = "french")]
pub struct HelperOne {
pub a: i32,
}
#[autumn_web::model(table = "pages")]
#[searchable(language = "english")]
pub struct Page {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
}
"#;
let (lang, fields) = parse_model_search_config(content).unwrap();
assert_eq!(lang, "english");
assert_eq!(fields, vec![("title".to_string(), 'A')]);
}
#[test]
fn test_strip_comments_in_string_literals() {
let content = r#"
let url = "https://example.com/api"; // this is a comment
/* block comment */
let regex = r"//[a-z]+";
"#;
let stripped = strip_comments(content);
assert!(stripped.contains("https://example.com/api"));
assert!(!stripped.contains("this is a comment"));
assert!(!stripped.contains("block comment"));
}
#[test]
fn test_parse_model_search_config_for_table_multi() {
let content = r#"
#[autumn_web::model(table = "posts")]
#[searchable(language = "english")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
}
#[autumn_web::model(table = "comments")]
#[searchable(language = "spanish")]
pub struct Comment {
#[id]
pub id: i64,
#[searchable(weight = "B")]
pub body: String,
}
"#;
let (post_lang, post_fields) =
parse_model_search_config_for_table(content, "posts").unwrap();
assert_eq!(post_lang, "english");
assert_eq!(post_fields, vec![("title".to_string(), 'A')]);
let (comment_lang, comment_fields) =
parse_model_search_config_for_table(content, "comments").unwrap();
assert_eq!(comment_lang, "spanish");
assert_eq!(comment_fields, vec![("body".to_string(), 'B')]);
}
#[test]
fn test_strip_comments_raw_strings() {
let content = r###"
let r1 = r#"quote " inside raw"#;
let r2 = r##"// not comment inside raw block"##;
let r3 = r#"/* not block comment */"#;
"###;
let stripped = strip_comments(content);
assert!(stripped.contains("r#\"quote \" inside raw\"#"));
assert!(stripped.contains("r##\"// not comment inside raw block\"##"));
assert!(stripped.contains("r#\"/* not block comment */\"#"));
}
#[test]
fn test_parse_model_search_config_spacing_insensitivity() {
let content_spacing = r#"
#[autumn_web::model( table = "posts" )]
#[searchable(language = "english")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
}
"#;
let (lang, fields) = parse_model_search_config_for_table(content_spacing, "posts").unwrap();
assert_eq!(lang, "english");
assert_eq!(fields, vec![("title".to_string(), 'A')]);
}
#[test]
fn test_parse_model_search_config_pascal_fallback() {
let content = r#"
#[autumn_web::model]
#[searchable(language = "english")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
}
#[autumn_web::model]
#[searchable(language = "spanish")]
pub struct Comment {
#[id]
pub id: i64,
#[searchable(weight = "B")]
pub body: String,
}
"#;
let (lang, fields) = parse_model_search_config_for_table(content, "comments").unwrap();
assert_eq!(lang, "spanish");
assert_eq!(fields, vec![("body".to_string(), 'B')]);
}
#[test]
fn test_singularize_specimens_and_gentlemen() {
assert_eq!(singularize("specimens"), "specimen");
assert_eq!(singularize("regimens"), "regimen");
assert_eq!(singularize("gentlemen"), "gentleman");
assert_eq!(singularize("firemen"), "fireman");
}
#[test]
fn test_singularize_ses_trailing_e() {
assert_eq!(singularize("houses"), "house");
assert_eq!(singularize("phrases"), "phrase");
assert_eq!(singularize("guesses"), "guess");
assert_eq!(singularize("lenses"), "lens");
assert_eq!(singularize("databases"), "database");
assert_eq!(singularize("cases"), "case");
}
#[test]
fn test_parse_model_search_config_raw_string_table_attr() {
let content_raw_table = r##"
#[autumn_web::model(table = r#"raw_posts"#)]
#[searchable(language = "english")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
}
"##;
let (lang, fields) =
parse_model_search_config_for_table(content_raw_table, "raw_posts").unwrap();
assert_eq!(lang, "english");
assert_eq!(fields, vec![("title".to_string(), 'A')]);
}
#[test]
fn test_parse_model_search_config_braces_in_attributes() {
let content_braces = r#"
#[autumn_web::model(table = "posts")]
#[searchable(language = "english")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
#[validate(custom = "foo } bar")]
pub title: String,
#[searchable(weight = "B")]
pub body: String,
}
"#;
let (_, fields) = parse_model_search_config_for_table(content_braces, "posts").unwrap();
assert_eq!(
fields,
vec![("title".to_string(), 'A'), ("body".to_string(), 'B')]
);
}
#[test]
fn test_parse_model_search_config_invalid_weight_fallback() {
let content_invalid_weight = r#"
#[autumn_web::model(table = "posts")]
#[searchable(language = "english")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "Z")]
pub title: String,
}
"#;
let (_, fields) =
parse_model_search_config_for_table(content_invalid_weight, "posts").unwrap();
assert_eq!(fields, vec![("title".to_string(), 'D')]);
}
#[test]
fn test_singularize_singular_table_names_ending_in_s() {
assert_eq!(singularize("news"), "news");
assert_eq!(singularize("status"), "status");
assert_eq!(singularize("alias"), "alias");
assert_eq!(singularize("bus"), "bus");
assert_eq!(singularize("lens"), "lens");
assert_eq!(singularize("virus"), "virus");
assert_eq!(singularize("canvas"), "canvas");
assert_eq!(singularize("addresses"), "address");
assert_eq!(singularize("address"), "address");
}
#[test]
fn test_parse_model_search_config_diesel_column_name() {
let content_diesel = r##"
#[autumn_web::model(table = "posts")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
#[diesel(column_name = "headline")]
pub title: String,
#[diesel(column_name = r#"content_body"#)]
#[searchable(weight = "B")]
pub body: String,
}
"##;
let (_, fields) = parse_model_search_config_for_table(content_diesel, "posts").unwrap();
assert_eq!(
fields,
vec![
("headline".to_string(), 'A'),
("content_body".to_string(), 'B')
]
);
}
#[test]
fn test_strip_comments_nested_block() {
let content = "pub /* outer /* inner */ still outer */ struct Post {}";
let stripped = strip_comments(content);
assert_eq!(stripped.trim(), "pub struct Post {}");
}
#[test]
fn test_strip_comments_token_boundary() {
let content = "pub/*comment*/struct Post";
let stripped = strip_comments(content);
assert_eq!(stripped.trim(), "pub struct Post");
}
#[test]
fn test_parse_model_search_config_strict_fallback() {
let content_multi = r#"
#[autumn_web::model(table = "posts")]
pub struct Post {
#[id]
pub id: i64,
#[searchable]
pub title: String,
}
#[autumn_web::model(table = "comments")]
pub struct Comment {
#[id]
pub id: i64,
#[searchable]
pub body: String,
}
"#;
assert!(parse_model_search_config_for_table(content_multi, "users").is_none());
let (_, posts_fields) =
parse_model_search_config_for_table(content_multi, "posts").unwrap();
assert_eq!(posts_fields[0].0, "title");
let (_, comments_fields) =
parse_model_search_config_for_table(content_multi, "comments").unwrap();
assert_eq!(comments_fields[0].0, "body");
}
#[test]
fn test_extract_diesel_column_name_identifier() {
let content_diesel = r#"
#[autumn_web::model(table = "posts")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
#[diesel(column_name = headline)]
pub title: String,
#[diesel(column_name = content_body)]
#[searchable(weight = "B")]
pub body: String,
}
"#;
let (_, fields) = parse_model_search_config_for_table(content_diesel, "posts").unwrap();
assert_eq!(
fields,
vec![
("headline".to_string(), 'A'),
("content_body".to_string(), 'B')
]
);
}
#[test]
fn test_singularize_greek_origin_nouns() {
assert_eq!(singularize("analyses"), "analysis");
assert_eq!(singularize("crises"), "crisis");
assert_eq!(singularize("diagnoses"), "diagnosis");
assert_eq!(singularize("neuroses"), "neurosis");
assert_eq!(singularize("bases"), "basis");
assert_eq!(singularize("oases"), "oasis");
assert_eq!(singularize("databases"), "database");
assert_eq!(singularize("phases"), "phase");
assert_eq!(singularize("premises"), "premise");
}
#[test]
fn test_extract_diesel_column_name_raw_identifier() {
let content_diesel = r#"
#[autumn_web::model(table = "posts")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
#[diesel(column_name = r#headline)]
pub title: String,
#[diesel(column_name = r#content_body)]
#[searchable(weight = "B")]
pub body: String,
}
"#;
let (_, fields) = parse_model_search_config_for_table(content_diesel, "posts").unwrap();
assert_eq!(
fields,
vec![
("headline".to_string(), 'A'),
("content_body".to_string(), 'B')
]
);
}
#[test]
fn test_parse_model_search_config_model_helper_collision() {
let content_collision = r#"
#[model_helper(table = "posts")]
pub struct Helper {}
#[autumn_web::model(table = "posts")]
pub struct Post {
#[id]
pub id: i64,
#[searchable]
pub title: String,
}
"#;
let (_, fields) = parse_model_search_config_for_table(content_collision, "posts").unwrap();
assert_eq!(fields[0].0, "title");
}
#[test]
fn test_parse_model_search_config_multiline_attributes() {
let content_diesel = r#"
#[autumn_web::model(table = "posts")]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
#[diesel(
sql_type = Text,
column_name = "headline"
)]
pub title: String,
}
"#;
let (_, fields) = parse_model_search_config_for_table(content_diesel, "posts").unwrap();
assert_eq!(fields, vec![("headline".to_string(), 'A')]);
}
#[test]
fn test_parse_model_search_config_preceding_comma_in_attribute() {
let content_diesel = r#"
#[autumn_web::model(table = "posts")]
pub struct Post {
#[id]
pub id: i64,
#[diesel(sql_type = Text, column_name = headline)]
#[searchable(weight = "A")]
pub title: String,
}
"#;
let (_, fields) = parse_model_search_config_for_table(content_diesel, "posts").unwrap();
assert_eq!(fields, vec![("headline".to_string(), 'A')]);
}
#[test]
fn test_parse_model_search_config_multiple_preceding_attributes() {
let content_diesel = r#"
#[autumn_web::model(table = "posts")]
pub struct Post {
#[id]
pub id: i64,
#[diesel(sql_type = Text)]
#[diesel(column_name = headline)]
#[searchable(weight = "A")]
pub title: String,
}
"#;
let (_, fields) = parse_model_search_config_for_table(content_diesel, "posts").unwrap();
assert_eq!(fields, vec![("headline".to_string(), 'A')]);
}
#[test]
fn test_add_search_up_sql_quotes_columns() {
let sql = add_search_up_sql_for(
DatabaseBackend::Postgres,
"posts",
"english",
&[("title".to_string(), 'A'), ("body".to_string(), 'B')],
)
.expect("postgres search DDL never errors");
assert!(sql.contains("coalesce(\"title\"::text, '')"));
assert!(sql.contains("coalesce(\"body\"::text, '')"));
}
#[test]
fn test_add_search_up_sql_sqlite_emits_fts5_external_content_and_triggers() {
let up = add_search_up_sql_for(
DatabaseBackend::Sqlite,
"posts",
"english",
&[("title".to_string(), 'A'), ("body".to_string(), 'B')],
)
.expect("valid searchable columns generate FTS5 DDL");
assert!(
up.contains(
"CREATE VIRTUAL TABLE \"posts__fts\" USING fts5(\"title\", \"body\", \
content='posts', content_rowid='id', tokenize='unicode61');"
),
"up: {up}"
);
assert!(
up.contains(
"INSERT INTO \"posts__fts\"(rowid, \"title\", \"body\") \
VALUES (new.id, new.\"title\", new.\"body\");"
),
"up: {up}"
);
assert!(
up.contains(
"INSERT INTO \"posts__fts\"(\"posts__fts\", rowid, \"title\", \"body\") \
VALUES('delete', old.id, old.\"title\", old.\"body\");"
),
"up: {up}"
);
assert!(up.contains("CREATE TRIGGER \"posts__fts_au\""), "up: {up}");
assert!(
up.contains("INSERT INTO \"posts__fts\"(\"posts__fts\") VALUES('rebuild');"),
"up: {up}"
);
for leak in ["tsvector", "to_tsvector", "USING gin", "search_vector"] {
assert!(!up.contains(leak), "SQLite up leaked `{leak}`: {up}");
}
let down = add_search_down_sql_for(DatabaseBackend::Sqlite, "posts");
for trig in ["posts__fts_au", "posts__fts_ad", "posts__fts_ai"] {
assert!(
down.contains(&format!("DROP TRIGGER IF EXISTS \"{trig}\";")),
"down: {down}"
);
}
assert!(
down.contains("DROP TABLE IF EXISTS \"posts__fts\";"),
"down: {down}"
);
}
#[test]
fn test_add_search_up_sql_sqlite_rejects_fts5_reserved_column_names() {
for reserved in ["rowid", "rank", "RANK", "RowId", "posts__fts"] {
let err = add_search_up_sql_for(
DatabaseBackend::Sqlite,
"posts",
"english",
&[("title".to_string(), 'A'), (reserved.to_string(), 'B')],
)
.expect_err(&format!("reserved column `{reserved}` must be rejected"));
let msg = err.to_string();
assert!(
msg.contains(reserved) && msg.contains("FTS5-reserved"),
"message must name the offending field `{reserved}` and the reason: {msg}"
);
assert!(
msg.contains("Rename the field") && msg.contains("#[searchable]"),
"message must be actionable (rename / drop #[searchable]): {msg}"
);
}
let up = add_search_up_sql_for(
DatabaseBackend::Sqlite,
"posts",
"english",
&[("rank_note".to_string(), 'A'), ("body".to_string(), 'B')],
)
.expect("non-reserved columns (even `rank_note`) still generate FTS5 DDL");
assert!(
up.contains("CREATE VIRTUAL TABLE \"posts__fts\" USING fts5(\"rank_note\", \"body\", "),
"up: {up}"
);
let pg = add_search_up_sql_for(
DatabaseBackend::Postgres,
"posts",
"english",
&[("rank".to_string(), 'A')],
)
.expect("postgres search DDL never errors on a `rank` column");
assert!(pg.contains("coalesce(\"rank\"::text, '')"), "pg: {pg}");
}
#[test]
fn test_singularize_ves_plurals() {
assert_eq!(singularize("wolves"), "wolf");
assert_eq!(singularize("leaves"), "leaf");
assert_eq!(singularize("shelves"), "shelf");
assert_eq!(singularize("lives"), "life");
assert_eq!(singularize("hives"), "hive");
assert_eq!(singularize("knives"), "knife");
assert_eq!(singularize("wives"), "wife");
}
#[test]
fn test_add_search_up_sql_sanitizes_language() {
let sql = add_search_up_sql_for(
DatabaseBackend::Postgres,
"posts",
"english'; DROP TABLE posts;--",
&[("title".to_string(), 'A')],
)
.expect("postgres search DDL never errors");
assert!(sql.contains("to_tsvector('englishDROPTABLEposts'::regconfig"));
let sql_qualified = add_search_up_sql_for(
DatabaseBackend::Postgres,
"posts",
"pg_catalog.english",
&[("title".to_string(), 'A')],
)
.expect("postgres search DDL never errors");
assert!(sql_qualified.contains("to_tsvector('pg_catalog.english'::regconfig"));
}
#[test]
fn test_parse_model_search_config_for_table_doc_comment_struct() {
let content = r#"
#[autumn_web::model(table = "posts")]
#[doc = "This is a struct definition inside doc comment"]
pub struct Post {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
}
"#;
let (lang, fields) = parse_model_search_config_for_table(content, "posts").unwrap();
assert_eq!(lang, "simple");
assert_eq!(fields, vec![("title".to_string(), 'A')]);
}
#[test]
fn test_parse_model_search_config_for_table_prior_model_language_scoping() {
let content = r#"
#[autumn_web::model(table = "posts")]
#[searchable(language = "english")]
pub struct Post {
#[id]
pub id: i64,
#[searchable]
pub title: String,
}
#[autumn_web::model(table = "comments")]
pub struct Comment {
#[id]
pub id: i64,
#[searchable]
pub body: String,
}
"#;
let (lang, fields) = parse_model_search_config_for_table(content, "comments").unwrap();
assert_eq!(lang, "simple");
assert_eq!(fields, vec![("body".to_string(), 'D')]);
}
#[test]
fn ensure_feature_comment_mentions_feature_still_adds_it() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = { version = \"0.6\" } # add \"inbound-mailgun\" later\n";
let updated = ensure_autumn_web_feature(cargo, "inbound-mailgun");
assert!(
updated.contains("features"),
"feature must actually be added: {updated}"
);
let dep_line = updated
.lines()
.find(|l| l.contains("autumn-web"))
.unwrap_or("");
let code_part = dep_line.split_once('#').map_or(dep_line, |(b, _)| b);
assert!(
code_part.contains("\"inbound-mailgun\""),
"feature must be present in the code portion of the dep line: {dep_line}"
);
}
#[test]
fn dev_dependency_test_support_inserts_into_existing_section() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n\n[dev-dependencies]\ntokio = { version = \"1\", features = [\"rt\", \"macros\"] }\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("autumn-web = { version = \"0.6\", features = [\"test-support\"] }"),
"must add a dev-dependency autumn-web entry with test-support: {updated}"
);
assert!(updated.contains("tokio = { version = \"1\""));
let deps_section = updated.split("[dev-dependencies]").next().unwrap();
assert!(!deps_section.contains("test-support"));
}
#[test]
fn dev_dependency_test_support_is_idempotent() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\ntokio = \"1\"\n";
let once = ensure_dev_dependency_test_support(cargo, "0.6");
let twice = ensure_dev_dependency_test_support(&once, "0.6");
assert_eq!(once, twice, "a second call must be a no-op");
}
#[test]
fn dev_dependency_test_support_adds_feature_to_existing_dev_entry() {
let cargo =
"[package]\nname=\"x\"\n\n[dev-dependencies]\nautumn-web = { version = \"0.6\" }\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("\"test-support\""),
"must add test-support to the existing dev-dependency entry: {updated}"
);
assert_eq!(
updated.matches("autumn-web").count(),
1,
"must not duplicate the autumn-web dev-dependency: {updated}"
);
}
#[test]
fn dev_dependency_test_support_rewrites_single_quoted_plain_version() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\nautumn-web = '0.6'\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert_eq!(
updated.matches("autumn-web").count(),
1,
"must rewrite the existing single-quoted autumn-web dep in place, not duplicate it: {updated}"
);
assert!(
updated.contains("\"test-support\""),
"must add test-support to the single-quoted dev entry: {updated}"
);
}
#[test]
fn dev_dependency_test_support_creates_section_when_absent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(updated.contains("[dev-dependencies]"));
assert!(updated.contains("\"test-support\""));
}
#[test]
fn dev_dependency_test_support_handles_dotted_key_form() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\nautumn-web.workspace = true\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("\"test-support\""),
"must add test-support to a dotted-key autumn-web entry: {updated}"
);
assert!(
updated.contains("autumn-web.features"),
"must use the dotted key form: {updated}"
);
assert!(updated.contains("autumn-web.workspace = true"));
}
#[test]
fn dev_dependency_test_support_handles_subtable_form() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies.autumn-web]\nversion = \"0.6\"\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("test-support"),
"must add test-support to a subtable autumn-web entry: {updated}"
);
assert_eq!(
updated.matches("autumn-web").count(),
1,
"must not insert a duplicate autumn-web entry: {updated}"
);
}
#[test]
fn dev_dependency_test_support_reuses_shared_feature_logic() {
let (via_shared, found) = ensure_autumn_web_feature_status_in_section(
"[package]\nname=\"x\"\n\n[dev-dependencies]\nautumn-web = \"0.6\"\n",
"test-support",
"dev-dependencies",
);
assert!(found);
let via_public = ensure_dev_dependency_test_support(
"[package]\nname=\"x\"\n\n[dev-dependencies]\nautumn-web = \"0.6\"\n",
"0.6",
);
assert_eq!(via_shared, via_public);
}
#[test]
fn dev_dependency_test_support_mirrors_workspace_source() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web.workspace = true\n\n[dev-dependencies]\ntokio = { version = \"1\" }\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("autumn-web = { workspace = true, features = [\"test-support\"] }"),
"must mirror the workspace source instead of defaulting to a crates.io version: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_mirrors_path_source() {
let cargo =
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = { path = \"../autumn\" }\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated
.contains("autumn-web = { path = \"../autumn\", features = [\"test-support\"] }"),
"must mirror the path source instead of defaulting to a crates.io version: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_mirrors_subtable_path_source() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies.autumn-web]\npath = \"../autumn\"\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated
.contains("autumn-web = { path = \"../autumn\", features = [\"test-support\"] }"),
"must mirror a subtable-declared path source: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_falls_back_to_version_for_plain_crates_io_dep() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("autumn-web = { version = \"0.6\", features = [\"test-support\"] }"),
"must fall back to an explicit version when [dependencies] has no source keys: {updated}"
);
}
#[test]
fn dev_dependency_test_support_mirrors_registry_with_version() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = { version = \"0.6\", registry = \"private\" }\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains(
"autumn-web = { version = \"0.6\", registry = \"private\", features = [\"test-support\"] }"
),
"must mirror both version and registry together: {updated}"
);
}
#[test]
fn dev_dependency_test_support_mirrors_dotted_registry_with_version() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web.version = \"0.6\"\nautumn-web.registry = \"private\"\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
let dep_line = updated
.lines()
.find(|l| l.starts_with("autumn-web =") && l.contains("test-support"))
.unwrap_or_else(|| panic!("no dev-dependency autumn-web line in: {updated}"));
assert!(
dep_line.contains("version = \"0.6\"") && dep_line.contains("registry = \"private\""),
"must mirror both dotted version and registry together: {dep_line}"
);
}
#[test]
fn dev_dependency_test_support_mirrors_aliased_path_source() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn_web = { package = \"autumn-web\", path = \"../autumn\" }\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated
.contains("autumn-web = { path = \"../autumn\", features = [\"test-support\"] }"),
"must mirror the aliased dep's path source: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_mirrors_aliased_path_source_odd_spacing() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn_web = { package= \"autumn-web\", path = \"../autumn\" }\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated
.contains("autumn-web = { path = \"../autumn\", features = [\"test-support\"] }"),
"must mirror the aliased dep's path source despite odd spacing around package=: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_mirrors_aliased_path_source_single_quoted() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn_web = { package = 'autumn-web', path = '../autumn' }\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("autumn-web = { path = '../autumn', features = [\"test-support\"] }"),
"must mirror the aliased dep's single-quoted path source: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_mirrors_single_quoted_version() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = '0.5'\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("autumn-web = { version = '0.5', features = [\"test-support\"] }"),
"must mirror the existing single-quoted pinned version, not the CLI's: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_mirrors_existing_pinned_version_not_cli_version() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.5\"\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("autumn-web = { version = \"0.5\", features = [\"test-support\"] }"),
"must mirror the existing pinned version, not the CLI's: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_mirrors_version_from_inline_table() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = { version = \"0.5\" }\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("autumn-web = { version = \"0.5\", features = [\"test-support\"] }"),
"must mirror the existing pinned version from an inline table: {updated}"
);
}
#[test]
fn dev_dependency_test_support_mirrors_aliased_subtable_path_source() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies.autumn_web]\npackage = \"autumn-web\"\npath = \"../autumn\"\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated
.contains("autumn-web = { path = \"../autumn\", features = [\"test-support\"] }"),
"must mirror the aliased subtable's path source: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_mirrors_dotted_aliased_path_source() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn_web.package = \"autumn-web\"\nautumn_web.path = \"../autumn\"\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated
.contains("autumn-web = { path = \"../autumn\", features = [\"test-support\"] }"),
"must mirror the dotted-aliased dep's path source: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn dev_dependency_test_support_mirrors_dotted_aliased_path_source_single_quoted() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn_web.package = 'autumn-web'\nautumn_web.path = '../autumn'\n";
let updated = ensure_dev_dependency_test_support(cargo, "0.6");
assert!(
updated.contains("autumn-web = { path = '../autumn', features = [\"test-support\"] }"),
"must mirror the single-quoted dotted-aliased dep's path source: {updated}"
);
assert!(!updated.contains("version = \"0.6\""));
}
#[test]
fn tokio_test_features_inserts_when_dev_dependencies_absent() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
assert!(
updated.contains("tokio = { version = \"1\", features = [\"rt\", \"macros\"] }"),
"must insert a tokio dev-dependency with rt and macros: {updated}"
);
}
#[test]
fn tokio_test_features_inserts_when_dev_dependencies_section_exists_without_tokio() {
let cargo =
"[package]\nname=\"x\"\n\n[dev-dependencies]\nautumn-web = { version = \"0.6\" }\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
assert!(
updated.contains("tokio = { version = \"1\", features = [\"rt\", \"macros\"] }"),
"must insert tokio into the existing dev-dependencies section: {updated}"
);
assert!(updated.contains("autumn-web"));
}
#[test]
fn tokio_test_features_adds_missing_features_to_existing_tokio() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\ntokio = { version = \"1\" }\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
let dep_line = updated
.lines()
.find(|l| l.trim_start().starts_with("tokio"))
.unwrap_or_else(|| panic!("no tokio line in: {updated}"));
assert!(
dep_line.contains("\"rt\"") && dep_line.contains("\"macros\""),
"must add both missing features to the existing tokio entry: {dep_line}"
);
assert_eq!(
updated.matches("tokio").count(),
1,
"must not duplicate the tokio dev-dependency: {updated}"
);
}
#[test]
fn tokio_test_features_adds_only_the_missing_feature() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\ntokio = { version = \"1\", features = [\"macros\"] }\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
let dep_line = updated
.lines()
.find(|l| l.trim_start().starts_with("tokio"))
.unwrap_or_else(|| panic!("no tokio line in: {updated}"));
assert!(dep_line.contains("\"rt\"") && dep_line.contains("\"macros\""));
assert_eq!(
dep_line.matches("\"macros\"").count(),
1,
"must not duplicate an already-present feature: {dep_line}"
);
}
#[test]
fn tokio_test_features_is_idempotent() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\ntokio = { version = \"1\", features = [\"rt\", \"macros\"] }\n";
let once = ensure_dev_dependency_tokio_test_features(cargo);
let twice = ensure_dev_dependency_tokio_test_features(&once);
assert_eq!(once, twice, "a second call must be a no-op");
}
#[test]
fn tokio_test_features_handles_dotted_key_form() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\ntokio.version = \"1\"\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
let features_line = updated
.lines()
.find(|l| l.trim_start().starts_with("tokio.features"))
.unwrap_or_else(|| panic!("no tokio.features line in: {updated}"));
assert!(features_line.contains("\"rt\"") && features_line.contains("\"macros\""));
}
#[test]
fn tokio_test_features_dotted_form_not_shadowed_by_prefixed_dep() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\ntokio.version = \"1\"\ntokio-util = { features = [\"rt\"] }\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
let features_line = updated
.lines()
.find(|l| l.trim_start().starts_with("tokio.features"))
.unwrap_or_else(|| panic!("no tokio.features line in: {updated}"));
assert!(
features_line.contains("\"rt\"") && features_line.contains("\"macros\""),
"the real tokio dep must get both features despite the tokio-util decoy: {features_line}"
);
}
#[test]
fn tokio_test_features_not_spliced_into_trailing_comment() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\ntokio = { version = \"1\" } # features = []\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
let tokio_line = updated
.lines()
.find(|l| l.trim_start().starts_with("tokio"))
.unwrap_or_else(|| panic!("no tokio line in: {updated}"));
let code = tokio_line.split_once('#').map_or(tokio_line, |(c, _)| c);
assert!(
code.contains("\"rt\"") && code.contains("\"macros\""),
"features must be added to the real dependency value, not the trailing comment: {tokio_line}"
);
}
#[test]
fn tokio_test_features_rewrites_single_quoted_plain_version() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies]\ntokio = '1'\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
assert_eq!(
updated.matches("tokio").count(),
1,
"must rewrite the existing single-quoted tokio dep in place, not duplicate it: {updated}"
);
let tokio_line = updated
.lines()
.find(|l| l.trim_start().starts_with("tokio"))
.unwrap_or_else(|| panic!("no tokio line in: {updated}"));
assert!(
tokio_line.contains("\"rt\"") && tokio_line.contains("\"macros\""),
"must add both features to the single-quoted tokio dep: {tokio_line}"
);
}
#[test]
fn tokio_test_features_handles_subtable_form() {
let cargo = "[package]\nname=\"x\"\n\n[dev-dependencies.tokio]\nversion = \"1\"\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
assert!(
updated.contains("\"rt\"") && updated.contains("\"macros\""),
"must add both features to a subtable tokio entry: {updated}"
);
assert_eq!(
updated.matches("tokio").count(),
1,
"must not insert a duplicate tokio entry: {updated}"
);
}
#[test]
fn tokio_test_features_mirrors_path_source() {
let cargo =
"[package]\nname=\"x\"\n\n[dependencies]\ntokio = { path = \"../fake-tokio\" }\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
let tokio_dev_line = updated
.lines()
.find(|l| l.trim_start().starts_with("tokio") && l.contains("rt"))
.unwrap_or_else(|| panic!("no tokio dev-dependency line in: {updated}"));
assert!(
tokio_dev_line.contains("path = \"../fake-tokio\""),
"must mirror the path source instead of defaulting to a crates.io version: {tokio_dev_line}"
);
assert!(!tokio_dev_line.contains("version"));
}
#[test]
fn tokio_test_features_falls_back_to_version_for_plain_crates_io_dep() {
let cargo = "[package]\nname=\"x\"\n\n[dependencies]\ntokio = \"1\"\n";
let updated = ensure_dev_dependency_tokio_test_features(cargo);
let tokio_dev_line = updated
.lines()
.find(|l| l.trim_start().starts_with("tokio") && l.contains("rt"))
.unwrap_or_else(|| panic!("no tokio dev-dependency line in: {updated}"));
assert!(
tokio_dev_line.contains("version = \"1\""),
"must fall back to an explicit version when [dependencies] has no source keys: {tokio_dev_line}"
);
}
#[test]
fn schema_table_block_bigserial_is_byte_equal_to_old_default() {
let fs = fields(&["title:String"]);
let block = schema_table_block_with_id("posts", &fs, IdType::BigSerial);
let expected = "diesel::table! {\n\
\x20 posts (id) {\n\
\x20 id -> Int8,\n\
\x20 title -> Text,\n\
\x20 created_at -> Timestamp,\n\
\x20 }\n\
}\n";
assert_eq!(
block, expected,
"BigSerial schema block must match the old default byte-for-byte"
);
}
#[test]
fn schema_table_block_uuid_emits_uuid_type() {
let fs = fields(&["title:String"]);
let block = schema_table_block_with_id("posts", &fs, IdType::Uuid);
assert!(
block.contains("id -> Uuid,"),
"Uuid id_type must emit 'id -> Uuid,'"
);
assert!(!block.contains("Int8"), "Uuid block must not contain Int8");
}
#[test]
fn create_table_sql_bigserial_is_byte_equal_to_old_default() {
let fs = fields(&["title:String"]);
let sql = create_table_sql_with_metadata_and_id(
"posts",
&fs,
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
assert!(
sql.starts_with("CREATE TABLE posts ("),
"BigSerial migration must not prepend any comment: {sql}"
);
assert!(
sql.contains("id BIGSERIAL PRIMARY KEY"),
"BigSerial must emit BIGSERIAL"
);
assert!(
!sql.contains("UUID"),
"BigSerial migration must not mention UUID"
);
}
#[test]
fn create_table_sql_uuid_emits_uuid_pk() {
let fs = fields(&["title:String"]);
let sql = create_table_sql_with_metadata_and_id(
"posts",
&fs,
&BTreeSet::new(),
&BTreeMap::new(),
IdType::Uuid,
);
assert!(
sql.contains("id UUID PRIMARY KEY DEFAULT gen_random_uuid()"),
"Uuid must emit UUID PK with default"
);
assert!(
!sql.contains("BIGSERIAL"),
"Uuid migration must not contain BIGSERIAL"
);
}
#[test]
fn create_table_sql_uuid_prepends_comment_with_uuidv7_path() {
let sql = create_table_sql_with_metadata_and_id(
"posts",
&[],
&BTreeSet::new(),
&BTreeMap::new(),
IdType::Uuid,
);
assert!(
sql.contains("UUIDv7"),
"Uuid migration must document the UUIDv7 upgrade path"
);
}
#[test]
fn append_schema_table_with_id_bigserial_byte_equal_wrapper() {
let fs = fields(&["title:String"]);
let via_wrapper = append_schema_table("", "posts", &fs);
let via_explicit = append_schema_table_with_id("", "posts", &fs, IdType::BigSerial);
assert_eq!(via_wrapper, via_explicit);
}
#[test]
fn append_schema_table_with_id_uuid_contains_uuid_type() {
let fs = fields(&["title:String"]);
let schema = append_schema_table_with_id("", "posts", &fs, IdType::Uuid);
assert!(schema.contains("id -> Uuid,"));
}
#[test]
fn remove_mod_declaration_restores_empty_file() {
let after_add = add_mod_declaration("", "post");
assert_eq!(remove_mod_declaration(&after_add, "post"), "");
}
#[test]
fn remove_mod_declaration_restores_original_with_other_mods() {
let base = "pub mod user;\n";
let after_add = add_mod_declaration(base, "post");
assert_eq!(remove_mod_declaration(&after_add, "post"), base);
}
#[test]
fn remove_mod_declaration_is_idempotent_when_absent() {
let base = "pub mod user;\n";
assert_eq!(remove_mod_declaration(base, "post"), base);
}
#[test]
fn remove_mod_declaration_leaves_private_mod_untouched() {
let base = "mod post;\n";
assert_eq!(remove_mod_declaration(base, "post"), base);
}
#[test]
fn remove_schema_table_restores_empty_file() {
let f = fields(&["title:String"]);
let block = append_schema_table("", "posts", &f);
let after_add = block.clone();
assert_eq!(remove_schema_table(&after_add, "posts", &block), "");
}
#[test]
fn remove_schema_table_restores_original_with_other_tables() {
let f1 = fields(&["title:String"]);
let f2 = fields(&["name:String"]);
let base = append_schema_table("", "users", &f2);
let block = append_schema_table("", "posts", &f1);
let after_add = append_schema_table(&base, "posts", &f1);
assert_eq!(remove_schema_table(&after_add, "posts", &block), base);
}
#[test]
fn remove_schema_table_is_idempotent_when_absent() {
let f = fields(&["name:String"]);
let base = append_schema_table("", "users", &f);
let block = append_schema_table("", "posts", &f);
assert_eq!(remove_schema_table(&base, "posts", &block), base);
}
#[test]
fn remove_schema_table_never_removes_a_pre_existing_table_with_different_columns() {
let hand_written = "diesel::table! {\n posts (id) {\n id -> BigInt,\n \
body -> Text,\n }\n}\n";
let generated_block = append_schema_table("", "posts", &fields(&["title:String"]));
assert_eq!(
remove_schema_table(hand_written, "posts", &generated_block),
hand_written,
"pre-existing table with different columns must not be removed"
);
}
#[test]
fn remove_routes_entries_restores_single_line_template_body() {
let base = "fn main() {\n App::new()\n .routes(routes![index, hello, hello_name])\n .run()\n}\n";
let appended = vec![
"routes::posts::index".to_owned(),
"routes::posts::show".to_owned(),
];
let after_add = ensure_routes_entries(base, &appended);
assert_ne!(
after_add, base,
"test setup: append must actually change the body"
);
let reverted = remove_routes_entries(&after_add, &appended);
assert_eq!(reverted, base);
}
#[test]
fn remove_routes_entries_is_idempotent_when_absent() {
let base =
"fn main() {\n App::new()\n .routes(routes![index])\n .run()\n}\n";
let appended = vec!["routes::posts::index".to_owned()];
assert_eq!(remove_routes_entries(base, &appended), base);
}
#[test]
fn remove_routes_entries_removes_present_entries_even_when_one_is_already_gone() {
let appended = vec![
"routes::posts::index".to_owned(),
"routes::posts::show".to_owned(),
];
let hand_edited = "fn main() {\n App::new()\n .routes(routes![index, routes::posts::index])\n .run()\n}\n";
let reverted = remove_routes_entries(hand_edited, &appended);
assert_eq!(
reverted,
"fn main() {\n App::new()\n .routes(routes![index])\n .run()\n}\n",
"the still-present `index` entry must be removed even though `show` was \
already gone: {reverted}"
);
}
#[test]
fn remove_routes_entries_preserves_other_resources_multiline() {
let base =
"fn main() {\n App::new()\n .routes(routes![index])\n .run()\n}\n";
let comments_entries = vec![
"routes::comments::index".to_owned(),
"routes::comments::show".to_owned(),
];
let after_comments = ensure_routes_entries(base, &comments_entries);
let posts_entries = vec![
"routes::posts::index".to_owned(),
"routes::posts::show".to_owned(),
];
let after_posts = ensure_routes_entries(&after_comments, &posts_entries);
let reverted = remove_routes_entries(&after_posts, &posts_entries);
assert!(reverted.contains("routes::comments::index"));
assert!(reverted.contains("routes::comments::show"));
assert!(!reverted.contains("routes::posts::index"));
assert!(!reverted.contains("routes::posts::show"));
}
#[test]
fn remove_routes_entries_not_confused_by_kept_entry_sharing_a_prefix() {
let base = "fn main() {\n App::new()\n .routes(routes![index, routes::posts::index_all, routes::posts::index])\n .run()\n}\n";
let removed = vec!["routes::posts::index".to_owned()];
let reverted = remove_routes_entries(base, &removed);
assert_eq!(
reverted,
"fn main() {\n App::new()\n .routes(routes![index, routes::posts::index_all])\n .run()\n}\n",
"must restore the original single-line layout byte-identically, not collapse/reformat it"
);
}
#[test]
fn remove_autumn_web_feature_collapses_to_bare_string() {
let base = "[dependencies]\nautumn-web = \"0.6.0\"\n";
let after_add = ensure_autumn_web_feature(base, "maud");
assert_ne!(after_add, base);
assert_eq!(remove_autumn_web_feature(&after_add, "maud"), base);
}
#[test]
fn remove_autumn_web_feature_keeps_other_features() {
let base = "[dependencies]\nautumn-web = \"0.6.0\"\n";
let with_maud = ensure_autumn_web_feature(base, "maud");
let with_both = ensure_autumn_web_feature(&with_maud, "htmx");
let reverted = remove_autumn_web_feature(&with_both, "htmx");
assert_eq!(reverted, with_maud);
}
#[test]
fn remove_autumn_web_feature_preserves_keys_after_the_features_array() {
let base = "[dependencies]\nautumn-web = { version = \"0.6.0\", features = [\"maud\", \"htmx\"], default-features = false }\n";
let reverted = remove_autumn_web_feature(base, "htmx");
assert_eq!(
reverted,
"[dependencies]\nautumn-web = { version = \"0.6.0\", features = [\"maud\"], default-features = false }\n",
"trailing keys after the features array must survive: {reverted}"
);
}
#[test]
fn remove_autumn_web_feature_handles_default_features_key_before_features() {
let base = "[dependencies]\nautumn-web = { version = \"0.6.0\", default-features = false, features = [\"maud\", \"htmx\"] }\n";
let reverted = remove_autumn_web_feature(base, "htmx");
assert_eq!(
reverted,
"[dependencies]\nautumn-web = { version = \"0.6.0\", default-features = false, features = [\"maud\"] }\n",
"must remove only the target feature, keeping default-features intact: {reverted}"
);
}
#[test]
fn remove_autumn_web_feature_is_idempotent_when_absent() {
let base = "[dependencies]\nautumn-web = \"0.6.0\"\n";
assert_eq!(remove_autumn_web_feature(base, "maud"), base);
}
#[test]
fn remove_autumn_web_feature_reverts_dotted_key_form() {
let base =
"[dependencies]\nautumn-web.version = \"0.6.0\"\nautumn-web.features = [\"db\"]\n";
let with_mail = ensure_autumn_web_feature(base, "mail");
assert_ne!(with_mail, base);
assert_eq!(remove_autumn_web_feature(&with_mail, "mail"), base);
}
#[test]
fn remove_autumn_web_feature_deletes_dotted_features_key_when_emptied() {
let base =
"[dependencies]\nautumn-web.version = \"0.6.0\"\nautumn-web.features = [\"mail\"]\n";
let reverted = remove_autumn_web_feature(base, "mail");
assert_eq!(
reverted, "[dependencies]\nautumn-web.version = \"0.6.0\"\n",
"an emptied dotted features key must be removed outright: {reverted}"
);
}
#[test]
fn remove_autumn_web_feature_reverts_multiline_inline_table_form() {
let base = "[dependencies]\nautumn-web = {\n version = \"0.6.0\",\n features = [\"db\"],\n}\n";
let with_mail = ensure_autumn_web_feature(base, "mail");
assert_ne!(with_mail, base);
assert!(with_mail.contains("\"mail\""));
let reverted = remove_autumn_web_feature(&with_mail, "mail");
assert_eq!(
reverted, base,
"must remove only the added feature from the features line, restoring the \
original multiline table: {reverted}"
);
}
#[test]
fn remove_autumn_web_feature_reverts_subtable_form() {
let base = "[dependencies.autumn-web]\nversion = \"0.6.0\"\nfeatures = [\"db\"]\n\n[dev-dependencies]\n";
let with_mail = ensure_autumn_web_feature(base, "mail");
assert_ne!(with_mail, base);
assert_eq!(remove_autumn_web_feature(&with_mail, "mail"), base);
}
#[test]
fn remove_autumn_web_feature_deletes_subtable_features_key_when_emptied() {
let base = "[dependencies.autumn-web]\nversion = \"0.6.0\"\nfeatures = [\"mail\"]\n";
let reverted = remove_autumn_web_feature(base, "mail");
assert_eq!(
reverted, "[dependencies.autumn-web]\nversion = \"0.6.0\"\n",
"an emptied subtable features key must be removed outright, leaving \
`version` and the header untouched: {reverted}"
);
}
#[test]
fn remove_autumn_web_feature_reverts_renamed_inline_alias() {
let base =
"[dependencies]\nautumn_web = { package = \"autumn-web\", version = \"0.6.0\" }\n";
let with_mail = ensure_autumn_web_feature(base, "mail");
assert_ne!(with_mail, base);
assert!(with_mail.contains("autumn_web"));
assert_eq!(remove_autumn_web_feature(&with_mail, "mail"), base);
}
#[test]
fn remove_autumn_web_feature_reverts_renamed_subtable_alias() {
let base = "[dependencies.autumn_web]\npackage = \"autumn-web\"\nversion = \"0.6.0\"\nfeatures = [\"db\"]\n\n[dev-dependencies]\n";
let with_mail = ensure_autumn_web_feature(base, "mail");
assert_ne!(with_mail, base);
assert_eq!(remove_autumn_web_feature(&with_mail, "mail"), base);
}
#[test]
fn remove_autumn_web_dev_dependency_feature_deletes_freshly_inserted_line() {
let base =
"[dev-dependencies]\ntokio = { version = \"1\", features = [\"rt\", \"macros\"] }\n";
let after_add = ensure_dev_dependency_test_support(base, "0.6.0");
assert_ne!(after_add, base);
assert_eq!(
remove_autumn_web_dev_dependency_feature(&after_add, "test-support"),
base
);
}
#[test]
fn remove_autumn_web_dev_dependency_feature_is_idempotent_when_absent() {
let base = "[dev-dependencies]\ntokio = { version = \"1\" }\n";
assert_eq!(
remove_autumn_web_dev_dependency_feature(base, "test-support"),
base
);
}
#[test]
fn remove_main_mod_declarations_restores_original_with_no_leading_attributes() {
let base = "use autumn_web::prelude::*;\n\nfn main() {}\n";
let after_add = ensure_mods(base, &["models", "repositories", "routes", "schema"]);
assert_ne!(after_add, base);
let reverted = remove_main_mod_declarations(
&after_add,
&["models", "repositories", "routes", "schema"],
);
assert_eq!(reverted, base);
}
#[test]
fn remove_main_mod_declarations_leaves_other_shared_mods_when_only_some_missing() {
let base = "fn main() {}\n";
let after_add = ensure_mods(base, &["models", "jobs"]);
let reverted = remove_main_mod_declarations(&after_add, &["jobs"]);
assert!(reverted.contains("mod models;"));
assert!(!reverted.contains("mod jobs;"));
}
#[test]
fn remove_main_mod_declarations_is_idempotent_when_absent() {
let base = "fn main() {}\n";
assert_eq!(remove_main_mod_declarations(base, &["models"]), base);
}
}