use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use super::dsl::{Field, FieldKind, parse_fields};
use super::emit::Plan;
use super::naming::{pascal, pluralize, snake};
use super::schema_edit::{
add_mod_declaration, append_schema_table, create_table_sql_with_metadata, drop_table_sql,
};
use super::{Flags, GenerateError, ensure_project_root, timestamp_now};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelOptions {
pub indexes: Vec<String>,
pub validations: Vec<String>,
pub defaults: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelMetadata {
indexes: BTreeSet<String>,
validations: BTreeMap<String, Vec<String>>,
defaults: BTreeMap<String, String>,
}
impl ModelMetadata {
#[must_use]
pub fn has_validator_rules(&self) -> bool {
!self.validations.is_empty()
}
#[must_use]
pub const fn indexes(&self) -> &BTreeSet<String> {
&self.indexes
}
#[must_use]
pub const fn defaults(&self) -> &BTreeMap<String, String> {
&self.defaults
}
}
pub fn plan_model(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
) -> Result<Plan, GenerateError> {
plan_model_with_options(
project_root,
name,
field_tokens,
timestamp,
&ModelOptions::default(),
)
}
pub fn plan_model_with_options(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
options: &ModelOptions,
) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
validate_resource_name(name)?;
let fields = parse_fields(field_tokens)?;
validate_field_names(&fields)?;
let metadata = parse_model_metadata(&fields, options)?;
let pascal_name = pascal(name);
let snake_name = snake(name);
let table = pluralize(&snake_name);
let mut plan = Plan::new(project_root);
let models_dir = project_root.join("src").join("models");
let model_file = models_dir.join(format!("{snake_name}.rs"));
plan.create(
model_file,
render_model_file(&pascal_name, &table, &fields, &metadata),
);
let mod_path = models_dir.join("mod.rs");
let mod_existing = read_or_empty(&mod_path);
plan.modify(mod_path, add_mod_declaration(&mod_existing, &snake_name));
let migration_dir_name = format!("{timestamp}_create_{table}");
let migration_dir = project_root.join("migrations").join(&migration_dir_name);
let up_sql =
create_table_sql_with_metadata(&table, &fields, metadata.indexes(), metadata.defaults());
plan.create(migration_dir.join("up.sql"), up_sql);
plan.create(migration_dir.join("down.sql"), drop_table_sql(&table));
let schema_path = project_root.join("src").join("schema.rs");
let schema_existing = read_or_empty(&schema_path);
plan.modify(
schema_path,
append_schema_table(&schema_existing, &table, &fields),
);
let mut deps: Vec<(&str, &str)> = MODEL_DEPS.to_vec();
if metadata.has_validator_rules() {
deps.push((
"validator",
"{ version = \"0.20\", features = [\"derive\"] }",
));
}
plan_cargo_deps(&mut plan, project_root, &deps);
Ok(plan)
}
pub(super) const MODEL_DEPS: &[(&str, &str)] = &[
("chrono", "{ version = \"0.4\", features = [\"serde\"] }"),
(
"diesel",
"{ version = \"2\", features = [\"postgres\", \"chrono\", \"uuid\"] }",
),
(
"diesel-async",
"{ version = \"0.8\", features = [\"postgres\"] }",
),
(
"pq-sys",
"{ version = \"0.7\", features = [\"bundled_without_openssl\"] }",
),
("diesel_migrations", "\"2\""),
("serde", "{ version = \"1\", features = [\"derive\"] }"),
("serde_json", "\"1\""),
("uuid", "{ version = \"1\", features = [\"serde\"] }"),
];
pub(super) fn plan_cargo_deps(plan: &mut Plan, project_root: &Path, deps: &[(&str, &str)]) {
let cargo_toml_path = project_root.join("Cargo.toml");
let existing = read_or_empty(&cargo_toml_path);
let updated = ensure_cargo_dependencies(&existing, deps);
if updated != existing {
plan.modify(cargo_toml_path, updated);
}
}
pub(super) fn ensure_cargo_dependencies(existing: &str, deps: &[(&str, &str)]) -> String {
let lines: Vec<&str> = existing.lines().collect();
let Some(deps_idx) = lines
.iter()
.position(|l| is_table_header(l, "dependencies"))
else {
use std::fmt::Write as _;
let mut out = String::with_capacity(existing.len() + 64);
out.push_str(existing);
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("[dependencies]\n");
for (name, spec) in deps {
let _ = writeln!(out, "{name} = {spec}");
}
return out;
};
let scan_end = lines[deps_idx + 1..]
.iter()
.position(|l| is_any_table_header(l) && !is_dep_subtable_boundary_marker(l))
.map_or(lines.len(), |off| deps_idx + 1 + off);
let insert_end = lines[deps_idx + 1..]
.iter()
.position(|l| is_any_table_header(l))
.map_or(lines.len(), |off| deps_idx + 1 + off);
let dep_section = &lines[deps_idx + 1..scan_end];
let to_add: Vec<(&str, &str)> = deps
.iter()
.copied()
.filter(|(name, _)| !dep_section_has(dep_section, name))
.collect();
if to_add.is_empty() {
return existing.to_owned();
}
let mut insert_at = insert_end;
while insert_at > deps_idx + 1 && lines[insert_at - 1].trim().is_empty() {
insert_at -= 1;
}
let inserted: Vec<String> = to_add
.iter()
.map(|(name, spec)| format!("{name} = {spec}"))
.collect();
let mut out = String::with_capacity(
existing.len() + inserted.iter().map(String::len).sum::<usize>() + 16,
);
for line in &lines[..insert_at] {
out.push_str(line);
out.push('\n');
}
for entry in &inserted {
out.push_str(entry);
out.push('\n');
}
for line in &lines[insert_at..] {
out.push_str(line);
out.push('\n');
}
if !existing.ends_with('\n') {
out.pop();
}
out
}
fn is_table_header(line: &str, table: &str) -> bool {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix('[') else {
return false;
};
let Some(close_idx) = rest.find(']') else {
return false;
};
if rest[..close_idx].trim() != table {
return false;
}
let after = rest[close_idx + 1..].trim_start();
after.is_empty() || after.starts_with('#')
}
fn is_any_table_header(line: &str) -> bool {
let trimmed = line.trim_start();
let after_open = trimmed
.strip_prefix("[[")
.or_else(|| trimmed.strip_prefix('['));
let Some(rest) = after_open else {
return false;
};
let Some(close_idx) = rest.find(']') else {
return false;
};
if rest[..close_idx].trim().is_empty() {
return false;
}
let after = rest[close_idx + 1..].trim_start();
let after = after.strip_prefix(']').unwrap_or(after).trim_start();
after.is_empty() || after.starts_with('#')
}
fn dep_subtable_crate_name(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix('[')?;
let close_idx = rest.find(']')?;
let after = rest[close_idx + 1..].trim_start();
if !after.is_empty() && !after.starts_with('#') {
return None;
}
let inner = rest[..close_idx].trim();
let dep_name = inner.strip_prefix("dependencies")?.trim_start();
let dep_name = dep_name.strip_prefix('.')?.trim_start();
if dep_name.is_empty() {
return None;
}
Some(dep_name)
}
fn is_dep_subtable_boundary_marker(line: &str) -> bool {
dep_subtable_crate_name(line).is_some()
}
fn dep_section_has(dep_section: &[&str], crate_name: &str) -> bool {
dep_section.iter().any(|l| {
let t = l.trim_start();
if t.starts_with('#') {
return false;
}
if let Some(name) = dep_subtable_crate_name(l) {
return name == crate_name;
}
t.split_once('=')
.is_some_and(|(name, _)| name.trim() == crate_name)
})
}
const RESERVED_RESOURCE_NAMES: &[&str] = &["main", "lib"];
pub(super) fn validate_resource_name(name: &str) -> Result<(), GenerateError> {
if name.is_empty() {
return Err(GenerateError::InvalidName(
name.to_owned(),
"name cannot be empty".into(),
));
}
let first = name.chars().next().expect("non-empty");
if !first.is_ascii_alphabetic() {
return Err(GenerateError::InvalidName(
name.to_owned(),
"must start with a letter".into(),
));
}
if let Some(bad) = name
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '_')
{
return Err(GenerateError::InvalidName(
name.to_owned(),
format!("contains invalid character '{bad}'"),
));
}
let snake_name = super::naming::snake(name);
if super::dsl::is_rust_keyword(&snake_name) {
return Err(GenerateError::InvalidName(
name.to_owned(),
format!(
"'{name}' is a Rust keyword (its snake_case form '{snake_name}' cannot be a module name)"
),
));
}
if RESERVED_RESOURCE_NAMES.contains(&snake_name.as_str()) {
return Err(GenerateError::InvalidName(
name.to_owned(),
format!(
"'{name}' is reserved — its snake_case form ('{snake_name}') collides with a special file"
),
));
}
Ok(())
}
const RESERVED_FIELD_NAMES: &[&str] = &["id", "created_at"];
fn validate_field_names(fields: &[Field]) -> Result<(), GenerateError> {
for f in fields {
if RESERVED_FIELD_NAMES.contains(&f.name.as_str()) {
return Err(GenerateError::InvalidField {
token: format!("{}:{}", f.name, f.rust_type()),
reason: format!(
"'{}' is reserved — the generator always emits this column",
f.name
),
});
}
}
Ok(())
}
fn read_or_empty(path: &Path) -> String {
std::fs::read_to_string(path).unwrap_or_default()
}
pub fn parse_model_metadata(
fields: &[Field],
options: &ModelOptions,
) -> Result<ModelMetadata, GenerateError> {
let mut metadata = ModelMetadata::default();
for index in &options.indexes {
let field_name = index.trim();
validate_known_field(fields, field_name, index)?;
metadata.indexes.insert(field_name.to_owned());
}
for validation in &options.validations {
let (field_name, rule) = split_key_value(validation, '=')?;
let field =
field_by_name(fields, field_name).ok_or_else(|| GenerateError::InvalidField {
token: validation.clone(),
reason: format!("unknown field '{field_name}'"),
})?;
let attr =
render_validation_attr(field, rule).map_err(|reason| GenerateError::InvalidField {
token: validation.clone(),
reason,
})?;
metadata
.validations
.entry(field_name.to_owned())
.or_default()
.push(attr);
}
for default in &options.defaults {
let (field_name, value) = split_key_value(default, '=')?;
let field =
field_by_name(fields, field_name).ok_or_else(|| GenerateError::InvalidField {
token: default.clone(),
reason: format!("unknown field '{field_name}'"),
})?;
let sql =
sql_default_literal(field, value).map_err(|reason| GenerateError::InvalidField {
token: default.clone(),
reason,
})?;
metadata.defaults.insert(field_name.to_owned(), sql);
}
Ok(metadata)
}
fn split_key_value(token: &str, sep: char) -> Result<(&str, &str), GenerateError> {
let (key, value) = token
.split_once(sep)
.ok_or_else(|| GenerateError::InvalidField {
token: token.to_owned(),
reason: format!("expected `field{sep}value`"),
})?;
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: format!("expected non-empty field and value in `field{sep}value`"),
});
}
Ok((key, value))
}
pub fn field_by_name<'a>(fields: &'a [Field], name: &str) -> Option<&'a Field> {
fields.iter().find(|field| field.name == name)
}
fn validate_known_field(
fields: &[Field],
field_name: &str,
token: &str,
) -> Result<(), GenerateError> {
if field_by_name(fields, field_name).is_some() {
Ok(())
} else {
Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: format!("unknown field '{field_name}'"),
})
}
}
fn render_validation_attr(field: &Field, rule: &str) -> Result<String, String> {
if rule == "url" || rule == "email" {
if !is_string_like(field) {
return Err(format!("{rule} validation requires String or Text fields"));
}
return Ok(rule.to_owned());
}
let Some(rest) = rule.strip_prefix("length:") else {
return Err("supported validation rules: url, email, length:min=N,max=N".to_owned());
};
if !is_string_like(field) {
return Err("length validation requires String or Text fields".to_owned());
}
let mut min = None;
let mut max = None;
for part in rest.split(',') {
let (key, value) = part
.split_once('=')
.ok_or_else(|| "length validation expects min=N and/or max=N".to_owned())?;
let parsed = value
.trim()
.parse::<u64>()
.map_err(|_| "length validation bounds must be unsigned integers".to_owned())?;
match key.trim() {
"min" => min = Some(parsed),
"max" => max = Some(parsed),
other => return Err(format!("unsupported length validation option '{other}'")),
}
}
if min.is_none() && max.is_none() {
return Err("length validation needs at least min=N or max=N".to_owned());
}
let mut args = Vec::new();
if let Some(min) = min {
args.push(format!("min = {min}"));
}
if let Some(max) = max {
args.push(format!("max = {max}"));
}
Ok(format!("length({})", args.join(", ")))
}
const fn is_string_like(field: &Field) -> bool {
matches!(field.kind, FieldKind::String | FieldKind::Text)
}
fn sql_default_literal(field: &Field, value: &str) -> Result<String, String> {
match field.kind {
FieldKind::Bool => match value.to_ascii_lowercase().as_str() {
"true" => Ok("TRUE".to_owned()),
"false" => Ok("FALSE".to_owned()),
_ => Err("bool defaults must be true or false".to_owned()),
},
FieldKind::String | FieldKind::Text => {
let unquoted = value
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(value);
Ok(format!("'{}'", unquoted.replace('\'', "''")))
}
FieldKind::I32 => value
.parse::<i32>()
.map(|_| value.to_owned())
.map_err(|_| "i32 defaults must fit the SQL INTEGER range".to_owned()),
FieldKind::I64 => value
.parse::<i64>()
.map(|_| value.to_owned())
.map_err(|_| "integer defaults must be valid integers".to_owned()),
FieldKind::F32 | FieldKind::F64 => value
.parse::<f64>()
.map(|_| value.to_owned())
.map_err(|_| "float defaults must be valid numbers".to_owned()),
FieldKind::Uuid | FieldKind::NaiveDateTime | FieldKind::DateTime | FieldKind::Bytea => {
Err(format!(
"defaults for {} fields are not supported by `autumn generate` yet",
field.rust_type()
))
}
}
}
fn render_model_file(
name: &str,
table: &str,
fields: &[Field],
metadata: &ModelMetadata,
) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(fields.len() * 128 + 256);
out.push_str("//! Generated by `autumn generate`.\n");
out.push_str("//!\n");
out.push_str("//! Edit this file freely — once a generator has run, the\n");
out.push_str("//! framework treats this as ordinary user code.\n\n");
let _ = writeln!(out, "use crate::schema::{table};");
out.push('\n');
out.push_str("#[autumn_web::model]\n");
let _ = writeln!(out, "pub struct {name} {{");
out.push_str(" #[id]\n");
out.push_str(" pub id: i64,\n");
for f in fields {
if metadata.indexes.contains(&f.name) {
out.push_str(" #[indexed]\n");
}
if let Some(validations) = metadata.validations.get(&f.name) {
for validation in validations {
let _ = writeln!(out, " #[validate({validation})]");
}
}
if metadata.defaults.contains_key(&f.name) {
out.push_str(" #[default]\n");
}
let _ = writeln!(out, " pub {}: {},", f.name, f.rust_type());
}
out.push_str(" #[default]\n");
out.push_str(" pub created_at: chrono::NaiveDateTime,\n");
out.push_str("}\n");
out
}
pub fn run(name: &str, field_tokens: &[String], flags: Flags) {
let cwd = match std::env::current_dir() {
Ok(d) => d,
Err(e) => {
eprintln!("Error: cannot determine current directory: {e}");
std::process::exit(1);
}
};
let timestamp = timestamp_now();
match plan_model(&cwd, name, field_tokens, ×tamp).and_then(|p| p.execute(flags)) {
Ok(()) => {}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::emit::Action;
use std::fs;
use tempfile::TempDir;
fn project() -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
tmp
}
fn paths(plan: &Plan) -> Vec<String> {
plan.actions
.iter()
.map(|a| {
a.path()
.strip_prefix(&plan.project_root)
.unwrap()
.display()
.to_string()
.replace('\\', "/")
})
.collect()
}
#[test]
fn plan_creates_expected_file_set() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
)
.unwrap();
let p = paths(&plan);
assert!(p.contains(&"src/models/post.rs".into()));
assert!(p.contains(&"src/models/mod.rs".into()));
assert!(p.contains(&"migrations/20260427000000_create_posts/up.sql".into()));
assert!(p.contains(&"migrations/20260427000000_create_posts/down.sql".into()));
assert!(p.contains(&"src/schema.rs".into()));
}
#[test]
fn plan_rejects_lowercase_first_char() {
let tmp = project();
let err = plan_model(tmp.path(), "123Bad", &[], "20260427000000").unwrap_err();
assert!(matches!(err, GenerateError::InvalidName(_, _)));
}
#[test]
fn plan_rejects_reserved_resource_names() {
for name in ["mod", "main", "lib", "Mod"] {
let tmp = project();
let err = plan_model(tmp.path(), name, &[], "20260427000000").unwrap_err();
assert!(
matches!(err, GenerateError::InvalidName(_, _)),
"expected '{name}' to be rejected"
);
}
}
#[test]
fn plan_rejects_keyword_resource_names() {
for name in ["Type", "type", "Match", "match", "Self", "Trait"] {
let tmp = project();
let err = plan_model(tmp.path(), name, &[], "20260427000000").unwrap_err();
assert!(
matches!(err, GenerateError::InvalidName(_, _)),
"expected '{name}' to be rejected as a keyword"
);
assert!(
err.to_string().contains("keyword"),
"expected keyword error for '{name}'; got: {err}"
);
}
}
#[test]
fn plan_rejects_id_or_created_at_as_user_field() {
for token in ["id:i64", "created_at:NaiveDateTime"] {
let tmp = project();
let err =
plan_model(tmp.path(), "Post", &[token.into()], "20260427000000").unwrap_err();
assert!(
matches!(err, GenerateError::InvalidField { .. }),
"expected '{token}' to be rejected"
);
assert!(
err.to_string().contains("reserved"),
"expected reserved-field error for '{token}'; got: {err}"
);
}
}
#[test]
fn plan_rejects_unsupported_field_type() {
let tmp = project();
let err = plan_model(
tmp.path(),
"Post",
&["price:Decimal".into()],
"20260427000000",
)
.unwrap_err();
assert!(matches!(err, GenerateError::InvalidField { .. }));
}
#[test]
fn plan_outside_project_root_errors() {
let tmp = TempDir::new().unwrap();
let err = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap_err();
assert!(matches!(err, GenerateError::NotInProject));
}
#[test]
fn execute_writes_idiomatic_model() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "published:bool".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(model.contains("#[autumn_web::model]"));
assert!(model.contains("pub struct Post"));
assert!(model.contains("pub title: String,"));
assert!(model.contains("pub published: bool,"));
assert!(model.contains("#[id]"));
assert!(model.contains("pub id: i64,"));
assert!(model.contains("created_at: chrono::NaiveDateTime"));
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(up.contains("CREATE TABLE posts ("));
assert!(up.contains("title TEXT NOT NULL"));
assert!(up.contains("published BOOLEAN NOT NULL"));
assert!(up.contains("id BIGSERIAL PRIMARY KEY"));
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(schema.contains("posts (id)"));
assert!(schema.contains("title -> Text,"));
}
#[test]
fn rerunning_with_force_overwrites_model_but_appends_schema() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let plan2 = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427100000",
)
.unwrap();
plan2
.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert_eq!(schema.matches("posts (id)").count(), 1);
}
#[test]
fn dry_run_writes_nothing() {
let tmp = project();
let plan = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(!tmp.path().join("src/models/post.rs").exists());
assert!(!tmp.path().join("src/schema.rs").exists());
}
#[test]
fn collision_reports_clean_path() {
let tmp = project();
let dir = tmp.path().join("src/models");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("post.rs"), "// existing").unwrap();
let plan = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap();
let err = plan.execute(Flags::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("post.rs"));
}
#[test]
fn modify_actions_marked_correctly() {
let tmp = project();
let plan = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap();
let modify_count = plan
.actions
.iter()
.filter(|a| matches!(a, Action::Modify { .. }))
.count();
assert!(modify_count >= 3);
}
#[test]
fn ensure_cargo_dependencies_appends_missing() {
let original = "[package]\n\
name = \"x\"\n\
\n\
[dependencies]\n\
autumn-web = \"0.3\"\n";
let updated = ensure_cargo_dependencies(
original,
&[
("chrono", "\"0.4\""),
("autumn-web", "\"99\""), ],
);
assert!(updated.contains("autumn-web = \"0.3\""));
assert!(updated.contains("chrono = \"0.4\""));
assert_eq!(updated.matches("autumn-web =").count(), 1);
}
#[test]
fn ensure_cargo_dependencies_idempotent() {
let original = "[package]\nname = \"x\"\n\n[dependencies]\nchrono = \"0.4\"\n";
let once = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
let twice = ensure_cargo_dependencies(&once, &[("chrono", "\"0.4\"")]);
assert_eq!(once, twice);
assert_eq!(once, original);
}
#[test]
fn ensure_cargo_dependencies_inserts_before_next_section() {
let original = "[package]\nname = \"x\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n\n\
[dev-dependencies]\ntempfile = \"3\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
let chrono_pos = updated.find("chrono = \"0.4\"").unwrap();
let dev_deps_pos = updated.find("[dev-dependencies]").unwrap();
assert!(
chrono_pos < dev_deps_pos,
"chrono must land in [dependencies], not [dev-dependencies]"
);
}
#[test]
fn ensure_cargo_dependencies_treats_array_of_tables_as_boundary() {
let original = "[package]\nname = \"x\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n\n\
[[bin]]\nname = \"app\"\npath = \"src/main.rs\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
let chrono_pos = updated.find("chrono = \"0.4\"").unwrap();
let bin_pos = updated.find("[[bin]]").unwrap();
assert!(
chrono_pos < bin_pos,
"chrono must land in [dependencies], not inside [[bin]]:\n{updated}"
);
}
#[test]
fn ensure_cargo_dependencies_recognises_subtable_form() {
let original = "[package]\nname = \"x\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n\n\
[dependencies.chrono]\nversion = \"0.4\"\nfeatures = [\"serde\"]\n";
let updated = ensure_cargo_dependencies(
original,
&[
("chrono", "\"99\""), ("diesel", "\"2\""),
],
);
assert!(
!updated.contains("chrono = \"99\""),
"[dependencies.chrono] subtable form must count as 'chrono is declared':\n{updated}"
);
let diesel_pos = updated.find("diesel = \"2\"").unwrap();
let chrono_subtable_pos = updated.find("[dependencies.chrono]").unwrap();
assert!(
diesel_pos < chrono_subtable_pos,
"new dep must land inside [dependencies], above any [dependencies.X] subtable:\n{updated}"
);
}
#[test]
fn dep_subtable_crate_name_parses_canonical_form() {
assert_eq!(
dep_subtable_crate_name("[dependencies.chrono]"),
Some("chrono")
);
assert_eq!(
dep_subtable_crate_name(" [dependencies.chrono] # opt"),
Some("chrono")
);
assert_eq!(dep_subtable_crate_name("[dependencies]"), None);
assert_eq!(dep_subtable_crate_name("[dev-dependencies.chrono]"), None);
assert_eq!(dep_subtable_crate_name("[package]"), None);
assert_eq!(dep_subtable_crate_name("[[bin]]"), None);
}
#[test]
fn ensure_cargo_dependencies_skips_commented_out_entries() {
let original = "[dependencies]\n# autumn-web = \"0.2\"\n";
let updated = ensure_cargo_dependencies(original, &[("autumn-web", "\"0.3\"")]);
assert!(updated.contains("autumn-web = \"0.3\""));
}
#[test]
fn ensure_cargo_dependencies_handles_header_with_trailing_comment() {
let original = "[dependencies] # shared deps\nautumn-web = \"0.3\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
assert_eq!(
updated.matches("[dependencies]").count(),
1,
"duplicate [dependencies] table appended:\n{updated}"
);
assert!(updated.contains("chrono = \"0.4\""));
}
#[test]
fn ensure_cargo_dependencies_treats_indented_section_as_a_header() {
let original = "[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = \"0.3\"\n\n [dev-dependencies]\ntempfile = \"3\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
let chrono_pos = updated.find("chrono = \"0.4\"").unwrap();
let dev_deps_pos = updated.find("[dev-dependencies]").unwrap();
assert!(
chrono_pos < dev_deps_pos,
"chrono must land in [dependencies], not [dev-dependencies]"
);
}
#[test]
fn plan_includes_cargo_toml_modification() {
let tmp = project();
let plan = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap();
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("Cargo.toml")),
"plan must touch Cargo.toml so generated code compiles"
);
}
#[test]
fn execute_adds_chrono_and_diesel_to_cargo_toml() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let cargo_toml = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
for dep in [
"chrono",
"diesel",
"diesel-async",
"serde",
"serde_json",
"diesel_migrations",
] {
assert!(
cargo_toml.contains(&format!("{dep} =")),
"missing '{dep}' in Cargo.toml after `generate model`:\n{cargo_toml}"
);
}
}
#[test]
fn execute_adds_uuid_dependencies_for_uuid_fields() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"ApiToken",
&["token:Uuid".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let cargo_toml = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo_toml.contains("uuid = { version = \"1\", features = [\"serde\"] }"),
"uuid::Uuid fields need a direct uuid dependency with serde support:\n{cargo_toml}"
);
assert!(
cargo_toml.contains(
"diesel = { version = \"2\", features = [\"postgres\", \"chrono\", \"uuid\"] }"
),
"Diesel schema Uuid fields need diesel's uuid feature:\n{cargo_toml}"
);
}
}