use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use super::dsl::Field;
#[must_use]
pub fn add_mod_declaration(existing: &str, name: &str) -> String {
let line = format!("pub mod {name};");
if existing
.lines()
.any(|l| l.trim() == line || l.trim() == format!("mod {name};"))
{
return existing.to_owned();
}
if existing.is_empty() {
return format!("{line}\n");
}
let trimmed = existing.trim_end();
format!("{trimmed}\n{line}\n")
}
#[must_use]
pub fn schema_table_block(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
out.push_str("diesel::table! {\n");
let _ = writeln!(out, " {table} (id) {{");
out.push_str(" id -> Int8,\n");
for f in fields {
let _ = writeln!(out, " {} -> {},", f.name, f.schema_type());
}
out.push_str(" created_at -> Timestamp,\n");
out.push_str(" }\n");
out.push_str("}\n");
out
}
#[must_use]
pub fn append_schema_table(existing: &str, table: &str, fields: &[Field]) -> String {
if has_table(existing, table) {
return existing.to_owned();
}
let block = schema_table_block(table, fields);
if existing.is_empty() {
return block;
}
let trimmed = existing.trim_end();
format!("{trimmed}\n\n{block}")
}
fn has_table(existing: &str, table: &str) -> bool {
let needle = format!("{table} (");
existing.lines().any(|l| l.trim().starts_with(&needle))
}
#[must_use]
pub fn schema_has_table(schema: &str, table: &str) -> bool {
has_table(schema, table)
}
#[must_use]
pub fn create_table_sql_with_metadata(
table: &str,
fields: &[Field],
indexes: &BTreeSet<String>,
defaults: &BTreeMap<String, String>,
) -> String {
let mut sql = String::with_capacity(fields.len() * 64 + indexes.len() * 96 + 128);
let _ = writeln!(sql, "CREATE TABLE {table} (");
sql.push_str(" id BIGSERIAL PRIMARY KEY");
for f in fields {
sql.push_str(",\n");
let _ = write!(
sql,
" {} {} {}",
f.name,
f.sql_type(),
f.sql_nullability()
);
if let Some(default) = defaults.get(&f.name) {
let _ = write!(sql, " DEFAULT {default}");
}
}
sql.push_str(",\n created_at TIMESTAMP NOT NULL DEFAULT NOW()\n);\n");
for field_name in indexes {
let _ = writeln!(
sql,
"CREATE INDEX idx_{table}_{field_name} ON {table} ({field_name});"
);
}
sql
}
#[must_use]
pub fn drop_table_sql(table: &str) -> String {
format!("DROP TABLE {table};\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationShape {
AddColumns { table: String },
RemoveColumns { table: String },
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
}
#[must_use]
pub fn add_columns_up_sql(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
for f in fields {
if !f.nullable {
let _ = writeln!(
out,
"-- autumn-safety: potentially-blocking \
-- add a DEFAULT or backfill existing rows before enforcing NOT NULL"
);
}
let _ = writeln!(
out,
"ALTER TABLE {table} ADD COLUMN {} {} {};",
f.name,
f.sql_type(),
f.sql_nullability()
);
}
out
}
#[must_use]
pub fn add_columns_down_sql(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
for f in fields.iter().rev() {
let _ = writeln!(out, "ALTER TABLE {table} DROP COLUMN {};", f.name);
}
out
}
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
}
#[must_use]
pub fn remove_columns_up_sql(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
for f in fields {
let _ = writeln!(
out,
"-- autumn-safety: destructive \
-- old replicas that reference this column will fail until restarted; \
use expand/contract"
);
let _ = writeln!(out, "ALTER TABLE {table} DROP COLUMN {};", f.name);
}
out
}
#[must_use]
pub fn remove_columns_down_sql(table: &str, fields: &[Field]) -> String {
let mut out = String::new();
for f in fields.iter().rev() {
let _ = writeln!(
out,
"ALTER TABLE {table} ADD COLUMN {} {} {};",
f.name,
f.sql_type(),
f.sql_nullability()
);
}
out
}
#[must_use]
pub fn update_main_rs(existing: &str, mods: &[&str], route_entries: &[String]) -> String {
let with_mods = ensure_mods(existing, mods);
ensure_routes_entries(&with_mods, route_entries)
}
fn ensure_mods(existing: &str, mods: &[&str]) -> String {
use std::fmt::Write;
let mut needed: Vec<&str> = mods
.iter()
.copied()
.filter(|m| !has_mod_declaration(existing, m))
.collect();
if needed.is_empty() {
return existing.to_owned();
}
needed.sort_unstable();
let mut block = String::with_capacity(needed.len() * 15);
for (i, m) in needed.iter().enumerate() {
if i > 0 {
block.push('\n');
}
write!(block, "mod {m};").unwrap();
}
let split = existing
.lines()
.position(|l| {
let t = l.trim_start();
!t.is_empty() && !t.starts_with("//!") && !t.starts_with("#![")
})
.unwrap_or_else(|| existing.lines().count());
if split == 0 {
return format!("{block}\n\n{existing}");
}
let mut out = String::with_capacity(existing.len() + block.len() + 4);
let lines: Vec<&str> = existing.lines().collect();
for line in &lines[..split] {
out.push_str(line);
out.push('\n');
}
out.push_str(&block);
out.push('\n');
if split < lines.len() {
out.push('\n');
for line in &lines[split..] {
out.push_str(line);
out.push('\n');
}
}
if !existing.ends_with('\n') && out.ends_with('\n') {
out.pop();
}
out
}
fn has_mod_declaration(existing: &str, name: &str) -> bool {
let needles = [format!("mod {name};"), format!("pub mod {name};")];
existing
.lines()
.map(str::trim)
.any(|line| needles.iter().any(|n| line == n))
}
fn ensure_routes_entries(existing: &str, entries: &[String]) -> String {
let Some(start) = existing.find("routes![") else {
return existing.to_owned();
};
let body_start = start + "routes![".len();
let mut depth: usize = 1;
let bytes = existing.as_bytes();
let mut i = body_start;
while i < bytes.len() {
match bytes[i] {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
i += 1;
}
if depth != 0 {
return existing.to_owned();
}
let body = &existing[body_start..i];
let new_body = augment_routes_body(body, entries);
let mut out = String::with_capacity(existing.len() + new_body.len());
out.push_str(&existing[..body_start]);
out.push_str(&new_body);
out.push_str(&existing[i..]);
out
}
fn augment_routes_body(body: &str, entries: &[String]) -> String {
let existing_entries: Vec<String> = body
.split([',', '\n'])
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
let mut to_add: Vec<&String> = entries
.iter()
.filter(|e| !existing_entries.iter().any(|ex| ex == e.as_str()))
.collect();
if to_add.is_empty() {
return body.to_owned();
}
let mut seen = std::collections::HashSet::new();
to_add.retain(|s| seen.insert(s.as_str()));
let indent = leading_indent(body);
let trimmed = body.trim_end_matches([' ', '\t']);
let prefix = if trimmed.is_empty() || trimmed.ends_with(',') || trimmed.ends_with('\n') {
""
} else {
","
};
let mut out = String::with_capacity(body.len() + to_add.len() * 32);
out.push_str(trimmed);
out.push_str(prefix);
for entry in to_add {
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&indent);
out.push_str(entry);
out.push(',');
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn leading_indent(body: &str) -> String {
body.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
l.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect::<String>()
})
.max_by_key(String::len)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| " ".to_owned())
}
#[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 augment_mail_previews_list(existing: &str, body_start: usize, mailer_type: &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 == mailer_type) {
return existing.to_owned();
}
let separator = if body.trim().is_empty() { "" } else { ", " };
let new_body = format!("{}{}{}", body.trim_end(), separator, mailer_type);
[
&existing[..body_start],
&new_body,
&existing[body_start + end_offset..],
]
.concat()
}
fn insert_mail_previews_call(existing: &str, mailer_type: &str) -> String {
let mut out = String::with_capacity(existing.len() + 80);
let mut inserted = false;
for line in existing.split('\n') {
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(".mail_previews(mail_previews![");
out.push_str(mailer_type);
out.push_str("])\n");
inserted = true;
}
out.push_str(line);
out.push('\n');
}
if !existing.ends_with('\n') && out.ends_with('\n') {
out.pop();
}
if existing.ends_with('\n') && out.ends_with("\n\n") {
out.pop();
}
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,
feature: &str,
feature_quoted: &str,
) -> String {
let section_end = lines[dep_line_idx + 1..]
.iter()
.position(|l| is_toml_table_header(l.trim()))
.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().starts_with("autumn-web") && line_code.contains(feature_quoted)
}) {
return existing.to_owned();
}
for (j, &sec_line) in lines[dep_line_idx..section_end].iter().enumerate() {
if sec_line.trim_start().starts_with("autumn-web.features") {
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!("autumn-web.features = [{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 {
let feature_quoted = format!("\"{feature}\"");
let lines: Vec<&str> = existing.lines().collect();
let mut in_deps = false;
for (i, &line) in lines.iter().enumerate() {
let trimmed = line.trim();
if is_dependencies_header(trimmed) {
in_deps = true;
continue;
}
if in_deps && is_toml_table_header(trimmed) {
in_deps = false;
continue;
}
if !in_deps {
continue;
}
if trimmed.starts_with('#') {
continue;
}
let after_ws = line.trim_start();
if let Some(rest) = after_ws.strip_prefix("autumn-web") {
if rest.starts_with('.') {
return patch_dotted_dep(&lines, i, existing, feature, &feature_quoted);
}
if rest.starts_with(|c: char| c != '=' && !c.is_whitespace()) {
continue;
}
} else {
let val = after_ws.split_once('=').map_or("", |x| x.1);
if !val.contains(r#"package = "autumn-web""#)
&& !val.contains(r#"package="autumn-web""#)
{
continue;
}
let alias = after_ws.split_once('=').map_or("", |(k, _)| k.trim());
if alias.replace('-', "_") != "autumn_web" {
continue;
}
}
let line_code = line.split_once('#').map_or(line, |(before, _)| before);
if line_code.contains(&feature_quoted) {
return existing.to_owned();
}
let new_line = rewrite_dep_with_feature(line, feature);
if new_line == line {
match add_feature_to_multiline_inline_table(
&lines,
i,
existing,
feature,
&feature_quoted,
) {
None => continue,
Some(result) => return result,
}
}
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;
}
for (i, &line) in lines.iter().enumerate() {
let key_part = line.trim().split('#').next().unwrap_or("").trim();
if key_part != "[dependencies.autumn-web]" {
continue;
}
return add_feature_to_deps_section(&lines, i + 1, existing, feature, &feature_quoted);
}
if let Some(start) =
find_section_start_with_autumn_web_package(&lines, "[dependencies.autumn_web]")
{
return add_feature_to_deps_section(&lines, start, existing, feature, &feature_quoted);
}
existing.to_owned()
}
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);
code.contains(r#"package = "autumn-web""#) || code.contains(r#"package="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, feature: &str) -> String {
let feature_quoted = format!("\"{feature}\"");
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("autumn-web") {
let rest = rest.trim_start_matches([' ', '=', '\t']);
if rest.starts_with('"') {
let value_str = rest.split('#').next().unwrap_or(rest).trim_end();
if let Some(version) = value_str
.strip_prefix('"')
.and_then(|r| r.strip_suffix('"'))
{
let indent_len = line.len() - line.trim_start().len();
let indent = &line[..indent_len];
return format!(
"{indent}autumn-web = {{ version = \"{version}\", features = [{feature_quoted}] }}"
);
}
}
}
if let Some(open) = line.find("features")
&& let Some(bracket_start) = line[open..].find('[')
{
let abs_start = open + bracket_start;
if let Some(bracket_end_rel) = line[abs_start..].find(']') {
let abs_end = abs_start + bracket_end_rel;
let body = &line[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!(
"{}{}{}{}",
&line[..abs_end],
separator,
feature_quoted,
&line[abs_end..]
);
}
}
if let Some(close) = line.rfind('}') {
let before = line[..close].trim_end();
let after = &line[close..];
return format!("{before}, features = [{feature_quoted}]{after}");
}
line.to_owned()
}
fn is_dependencies_header(trimmed: &str) -> bool {
trimmed == "[dependencies]"
|| trimmed.starts_with("[dependencies]") && trimmed[13..].trim_start().starts_with('#')
}
fn is_toml_table_header(trimmed: &str) -> bool {
trimmed.starts_with('[') && !trimmed.starts_with("[dependencies.")
}
#[must_use]
pub fn add_search_up_sql(table: &str, language: &str, fields: &[(String, char)]) -> String {
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);"
);
out
}
#[must_use]
pub fn add_search_down_sql(table: &str) -> String {
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
}
#[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("posts", &fields(&["title:String"]));
assert!(block.contains("posts (id)"));
assert!(block.contains("id -> Int8,"));
assert!(block.contains("title -> Text,"));
assert!(block.contains("created_at -> Timestamp,"));
}
#[test]
fn schema_table_block_nullable() {
let block = schema_table_block("posts", &fields(&["body:Option<String>"]));
assert!(block.contains("body -> Nullable<Text>,"));
}
#[test]
fn append_schema_table_idempotent() {
let f = fields(&["title:String"]);
let first = append_schema_table("", "posts", &f);
let second = append_schema_table(&first, "posts", &f);
assert_eq!(first, second);
}
#[test]
fn append_schema_table_to_existing_keeps_old() {
let f1 = fields(&["title:String"]);
let f2 = fields(&["name:String"]);
let first = append_schema_table("", "posts", &f1);
let combined = append_schema_table(&first, "users", &f2);
assert!(combined.contains("posts (id)"));
assert!(combined.contains("users (id)"));
}
#[test]
fn create_table_sql_minimal() {
let sql = create_table_sql_with_metadata(
"posts",
&fields(&["title:String"]),
&BTreeSet::new(),
&BTreeMap::new(),
);
assert!(sql.contains("CREATE TABLE posts ("));
assert!(sql.contains("id BIGSERIAL PRIMARY KEY"));
assert!(sql.contains("title TEXT NOT NULL"));
assert!(sql.contains("created_at TIMESTAMP NOT NULL DEFAULT NOW()"));
}
#[test]
fn create_table_sql_no_extra_fields() {
let sql =
create_table_sql_with_metadata("widgets", &[], &BTreeSet::new(), &BTreeMap::new());
assert!(sql.contains("id BIGSERIAL PRIMARY KEY"));
assert!(sql.contains("created_at"));
}
#[test]
fn create_table_sql_nullable() {
let sql = create_table_sql_with_metadata(
"posts",
&fields(&["body:Option<Text>"]),
&BTreeSet::new(),
&BTreeMap::new(),
);
assert!(sql.contains("body TEXT NULL"));
}
#[test]
fn drop_table_sql_simple() {
assert_eq!(drop_table_sql("posts"), "DROP TABLE posts;\n");
}
#[test]
fn detect_add_migration() {
match detect_migration_shape("AddTitleToPosts") {
MigrationShape::AddColumns { table } => assert_eq!(table, "posts"),
other => panic!("expected AddColumns, got {other:?}"),
}
}
#[test]
fn detect_add_migration_pluralises_singular_subject() {
match detect_migration_shape("AddBodyToPost") {
MigrationShape::AddColumns { table } => assert_eq!(table, "posts"),
other => panic!("expected AddColumns, got {other:?}"),
}
}
#[test]
fn detect_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 add_columns_down_sql_drops_in_reverse() {
let f = fields(&["title:String", "count:i32"]);
let sql = add_columns_down_sql("posts", &f);
let title_pos = sql.find("DROP COLUMN title").unwrap();
let count_pos = sql.find("DROP COLUMN count").unwrap();
assert!(count_pos < title_pos);
}
#[test]
fn update_main_rs_inserts_mod_and_routes() {
let original = r#"use autumn_web::prelude::*;
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#;
let updated = update_main_rs(
original,
&["models", "routes", "schema"],
&["routes::posts::index".to_owned()],
);
assert!(updated.contains("mod models;"));
assert!(updated.contains("mod routes;"));
assert!(updated.contains("mod schema;"));
assert!(updated.contains("routes::posts::index"));
assert!(updated.contains("index,")); }
#[test]
fn update_main_rs_preserves_inner_attributes() {
let original = "#![allow(clippy::needless_pass_by_value)]\n\
#![deny(unsafe_code)]\n\
\n\
use autumn_web::prelude::*;\n\
\n\
#[autumn_web::main]\n\
async fn main() {\n\
autumn_web::app().run().await;\n\
}\n";
let updated = update_main_rs(original, &["models"], &[]);
let attr_pos = updated.find("#![allow").unwrap();
let mod_pos = updated.find("mod models;").unwrap();
assert!(
attr_pos < mod_pos,
"crate inner attributes must stay above mod items:\n{updated}"
);
assert!(updated.contains("#![deny(unsafe_code)]"));
}
#[test]
fn update_main_rs_inserts_after_doc_comment_block() {
let original = "//! Top-level docs.\n\
//! Continuation.\n\
\n\
use autumn_web::prelude::*;\n";
let updated = update_main_rs(original, &["models"], &[]);
let docs_pos = updated.find("//! Top-level docs.").unwrap();
let mod_pos = updated.find("mod models;").unwrap();
assert!(docs_pos < mod_pos);
}
#[test]
fn update_main_rs_idempotent() {
let original = "mod models;\n\
mod routes;\n\
mod schema;\n\
\n\
use autumn_web::prelude::*;\n\
\n\
#[autumn_web::main]\n\
async fn main() {\n\
autumn_web::app()\n\
.routes(routes![\n\
routes::posts::index,\n\
])\n\
.run()\n\
.await;\n\
}\n";
let once = update_main_rs(
original,
&["models", "routes", "schema"],
&["routes::posts::index".to_owned()],
);
let twice = update_main_rs(
&once,
&["models", "routes", "schema"],
&["routes::posts::index".to_owned()],
);
assert_eq!(once, twice);
}
#[test]
fn update_main_rs_no_routes_macro_leaves_file_alone() {
let original = "fn main() {}\n";
let updated = update_main_rs(original, &[], &["foo".into()]);
assert_eq!(updated, original);
}
#[test]
fn ensure_routes_entries_handles_empty_body() {
let original = "fn main() {\n routes![]\n}\n";
let updated = ensure_routes_entries(original, &["foo".into()]);
assert!(updated.contains("foo"));
}
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 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(
"posts",
"english",
&[("title".to_string(), 'A'), ("body".to_string(), 'B')],
);
assert!(sql.contains("coalesce(\"title\"::text, '')"));
assert!(sql.contains("coalesce(\"body\"::text, '')"));
}
#[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(
"posts",
"english'; DROP TABLE posts;--",
&[("title".to_string(), 'A')],
);
assert!(sql.contains("to_tsvector('englishDROPTABLEposts'::regconfig"));
let sql_qualified =
add_search_up_sql("posts", "pg_catalog.english", &[("title".to_string(), 'A')]);
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}"
);
}
}