pub mod admin;
pub mod auth;
pub mod channel;
pub mod commentable;
pub mod config;
pub mod controller;
pub mod counter_cache;
pub mod dsl;
pub mod emit;
pub mod inbound_mail;
pub mod introspect;
pub mod job;
pub mod mailer;
pub mod migration;
pub mod model;
pub mod naming;
mod nested;
pub mod notifications;
pub mod plugin;
pub mod policy;
pub mod pwa;
pub mod scaffold;
mod scaffold_i18n;
pub mod schema_edit;
pub mod system_test;
pub mod task;
pub mod tauri;
pub mod tauri_mobile;
pub mod teams;
pub mod webhook;
pub mod wizard;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum GenerateError {
#[error("would overwrite existing file(s):\n{}", format_collisions(.0))]
Collisions(Vec<PathBuf>),
#[error("invalid resource name '{0}': {1}")]
InvalidName(String, String),
#[error("invalid field '{token}': {reason}")]
InvalidField {
token: String,
reason: String,
},
#[error("not inside an Autumn project (no Cargo.toml found in current directory)")]
NotInProject,
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Config(String),
#[error(
"refusing to destroy — file(s) diverged from generated content, pass --force to override:\n{}",
format_collisions(.0)
)]
Diverged(Vec<PathBuf>),
}
fn format_collisions(paths: &[PathBuf]) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(paths.len() * 60);
for (i, p) in paths.iter().enumerate() {
if i > 0 {
out.push('\n');
}
write!(out, " {}", p.display().to_string().replace('\\', "/")).unwrap();
}
out
}
#[must_use]
pub fn uuid_pk_commentable_unsupported_error() -> GenerateError {
GenerateError::Config(
"`comments:commentable` needs an integer primary key, so it cannot be combined with `--id uuid`. The shared `comments` table keys its rows on `commentable_id BIGINT` — one column serving every commentable model — and the generated helpers take `parent_id: i64`, so a UUID-keyed parent has no representation there. Re-run without `--id uuid` to use the default BIGINT primary key, or without `comments:commentable` and attach comments to a model that has one."
.to_owned(),
)
}
pub fn sqlite_uuid_pk_unsupported_error() -> GenerateError {
GenerateError::Config(
"UUID primary keys (--id uuid) are not yet supported on SQLite apps; Postgres uses \
`UUID PRIMARY KEY DEFAULT gen_random_uuid()`, but SQLite has no uuid type nor a \
gen_random_uuid() default, and the generated `New*` insert type omits `#[id]` \
fields — so inserted rows would get NULL/omitted ids. App-side UUID generation is \
part of the deferred SQLite runtime slice, tracked in \
https://github.com/autumn-foundation/autumn/issues/1905 — re-run without `--id uuid` to use \
the default INTEGER PRIMARY KEY AUTOINCREMENT, or target a Postgres database."
.to_owned(),
)
}
#[must_use]
pub fn sqlite_sharded_unsupported_error() -> GenerateError {
GenerateError::Config(
"sharded models (--sharded) require the Postgres backend: SQLite is single-host, \
single-writer, so horizontal sharding is Postgres-only and permanently out of scope \
for SQLite. A sharded resource needs a `[[database.shards]]` topology, which config \
validation rejects for a SQLite primary — so no SQLite app could use the generated \
resource. Drop `--sharded`, or target a Postgres database. See \
docs/guide/sqlite-in-production.md for SQLite's supported single-host deployment model."
.to_owned(),
)
}
#[must_use]
pub fn sqlite_add_not_null_without_default_error(table: &str, column: &str) -> GenerateError {
GenerateError::Config(format!(
"cannot add NOT NULL column `{column}` to table `{table}` on a SQLite app: SQLite \
rejects `ALTER TABLE {table} ADD COLUMN {column} … NOT NULL` without a DEFAULT once \
the table has rows (\"Cannot add a NOT NULL column with default value NULL\"). Make \
the field nullable (e.g. `{column}:Option<…>`) or give it a default so the column \
can be added safely. (A NOT NULL column is fine inside CREATE TABLE — this limit is \
specific to ALTER TABLE ADD COLUMN.)"
))
}
#[must_use]
pub fn sqlite_teams_unsupported_error() -> GenerateError {
GenerateError::Config(
"`autumn generate teams` requires the Postgres backend: its migration and \
`#[model]`/`#[repository]` templates are fixed Postgres DDL/Diesel code \
(BIGSERIAL, NOW(), TIMESTAMP, the `diesel` \"postgres\" feature), with no \
SQLite-aware rendering path yet. Target a Postgres database to use \
`autumn generate teams`."
.to_owned(),
)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Flags {
pub dry_run: bool,
pub force: bool,
}
pub fn ensure_project_root(dir: &Path) -> Result<(), GenerateError> {
if dir.join("Cargo.toml").is_file() {
Ok(())
} else {
Err(GenerateError::NotInProject)
}
}
pub fn timestamp_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
timestamp_from_unix(secs)
}
#[must_use]
pub fn timestamp_from_unix(unix_secs: u64) -> String {
let days = unix_secs / 86_400;
let rem = unix_secs % 86_400;
let hour = rem / 3600;
let minute = (rem % 3600) / 60;
let second = rem % 60;
let (y, m, d) = ymd_from_days(days);
format!("{y:04}{m:02}{d:02}{hour:02}{minute:02}{second:02}")
}
#[allow(
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
reason = "the input range (Unix seconds within Diesel's reasonable lifetime) is far\
below i64::MAX/2, so the i64↔u64 round-trip stays within bounds."
)]
const fn ymd_from_days(days_since_epoch: u64) -> (u64, u64, u64) {
let z = days_since_epoch as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as u64, m, d)
}
pub fn read_or_empty(path: &Path) -> String {
std::fs::read_to_string(path).unwrap_or_default()
}
#[must_use]
pub fn detect_backend(project_root: &Path) -> autumn_web::config::DatabaseBackend {
detect_backend_for_profile(project_root, None)
}
#[must_use]
pub fn detect_backend_for_profile(
project_root: &Path,
profile: Option<&str>,
) -> autumn_web::config::DatabaseBackend {
let effective = crate::migrate::effective_profile(profile);
detect_backend_with(project_root, Some(&effective), |k| std::env::var(k))
}
fn detect_backend_with<F>(
project_root: &Path,
profile: Option<&str>,
env_var: F,
) -> autumn_web::config::DatabaseBackend
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
use autumn_web::config::DatabaseBackend;
let base = FnEnv(&env_var);
let dotenv_overlay: std::collections::BTreeMap<String, String> =
autumn_web::dotenv::resolve_dotenv_vars_in(project_root, profile.unwrap_or("dev"), &base)
.unwrap_or_default()
.into_iter()
.collect();
let env_with_dotenv = |key: &str| {
env_var(key).or_else(|_| {
dotenv_overlay
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
})
};
let table = crate::migrate::read_autumn_toml_table_with_profile_in(project_root, profile);
crate::migrate::resolve_primary_database_url_from_sources(env_with_dotenv, table.as_ref())
.as_deref()
.and_then(DatabaseBackend::detect)
.unwrap_or(DatabaseBackend::Postgres)
}
struct FnEnv<F>(F);
impl<F> autumn_web::config::Env for FnEnv<F>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
(self.0)(key)
}
}
#[must_use]
pub fn sqlite_field_kind_unsupported_error(field: &str, rust_type: &str) -> GenerateError {
GenerateError::Config(format!(
"field `{field}` has no working diesel SQLite conversion for its Rust type \
`{rust_type}`: diesel implements no FromSql/ToSql for that type on its SQLite \
backend in a generated app's feature set, so a generated SQLite app using this \
field would fail to compile. Supported SQLite field kinds are: {kinds}. \
SQLite support for the remaining Uuid / Decimal / enum fields is tracked in \
https://github.com/autumn-foundation/autumn/issues/1924 — use a \
supported field kind, or target a Postgres database.",
kinds = dsl::SQLITE_SUPPORTED_KINDS,
))
}
pub fn reject_sqlite_unsupported_field_kinds(fields: &[dsl::Field]) -> Result<(), GenerateError> {
for f in fields {
if !f.kind.sqlite_has_diesel_conversion() {
return Err(sqlite_field_kind_unsupported_error(&f.name, &f.rust_type()));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamp_format_is_14_digits() {
let ts = timestamp_now();
assert_eq!(ts.len(), 14);
assert!(ts.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn timestamp_2026_04_27_known_value() {
let ts = timestamp_from_unix(1_777_248_000);
assert_eq!(ts, "20260427000000");
}
#[test]
fn timestamp_1970_epoch() {
assert_eq!(timestamp_from_unix(0), "19700101000000");
}
#[test]
fn timestamp_handles_leap_year() {
assert_eq!(timestamp_from_unix(1_709_210_096), "20240229123456");
}
#[test]
fn ensure_project_root_succeeds_with_cargo_toml() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
assert!(ensure_project_root(tmp.path()).is_ok());
}
#[test]
fn ensure_project_root_fails_without_cargo_toml() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(matches!(
ensure_project_root(tmp.path()).unwrap_err(),
GenerateError::NotInProject
));
}
fn no_env(_key: &str) -> Result<String, std::env::VarError> {
Err(std::env::VarError::NotPresent)
}
#[test]
fn detect_backend_honors_sqlite_url_in_active_profile_overlay() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://localhost/app\"\n\n\
[profile.prod.database]\nprimary_url = \"sqlite://prod.db\"\n",
)
.unwrap();
assert_eq!(
detect_backend_with(tmp.path(), Some("prod"), no_env),
DatabaseBackend::Sqlite
);
assert_eq!(
detect_backend_with(tmp.path(), Some("dev"), no_env),
DatabaseBackend::Postgres
);
}
#[test]
fn detect_backend_honors_sqlite_url_in_profile_overlay_file() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://localhost/app\"\n",
)
.unwrap();
std::fs::write(
tmp.path().join("autumn-prod.toml"),
"[database]\nprimary_url = \"sqlite://prod.db\"\n",
)
.unwrap();
assert_eq!(
detect_backend_with(tmp.path(), Some("prod"), no_env),
DatabaseBackend::Sqlite
);
}
#[test]
fn detect_backend_base_file_postgres_stays_postgres() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://localhost/app\"\n",
)
.unwrap();
assert_eq!(
detect_backend_with(tmp.path(), Some("dev"), no_env),
DatabaseBackend::Postgres
);
}
#[test]
fn detect_backend_no_config_defaults_to_postgres() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
assert_eq!(
detect_backend_with(tmp.path(), Some("dev"), no_env),
DatabaseBackend::Postgres
);
}
#[test]
fn detect_backend_env_var_overrides_config() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://localhost/app\"\n",
)
.unwrap();
let env = |key: &str| {
if key == "DATABASE_URL" {
Ok("sqlite://override.db".to_owned())
} else {
Err(std::env::VarError::NotPresent)
}
};
assert_eq!(
detect_backend_with(tmp.path(), Some("dev"), env),
DatabaseBackend::Sqlite
);
}
#[test]
fn detect_backend_honors_sqlite_url_in_dotenv() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join(".env"), "DATABASE_URL=sqlite://app.db\n").unwrap();
assert_eq!(
detect_backend_with(tmp.path(), Some("dev"), no_env),
DatabaseBackend::Sqlite
);
}
#[test]
fn detect_backend_process_env_wins_over_dotenv() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join(".env"), "DATABASE_URL=sqlite://app.db\n").unwrap();
let env = |key: &str| {
if key == "DATABASE_URL" {
Ok("postgres://localhost/app".to_owned())
} else {
Err(std::env::VarError::NotPresent)
}
};
assert_eq!(
detect_backend_with(tmp.path(), Some("dev"), env),
DatabaseBackend::Postgres
);
}
#[test]
fn detect_backend_dotenv_overlays_above_autumn_toml() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://localhost/app\"\n",
)
.unwrap();
std::fs::write(tmp.path().join(".env"), "DATABASE_URL=sqlite://app.db\n").unwrap();
assert_eq!(
detect_backend_with(tmp.path(), Some("dev"), no_env),
DatabaseBackend::Sqlite
);
}
#[test]
fn detect_backend_no_dotenv_stays_postgres() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://localhost/app\"\n",
)
.unwrap();
assert_eq!(
detect_backend_with(tmp.path(), Some("dev"), no_env),
DatabaseBackend::Postgres
);
}
#[test]
fn detect_backend_dotenv_gated_off_for_prod_profile() {
use autumn_web::config::DatabaseBackend;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join(".env"), "DATABASE_URL=sqlite://app.db\n").unwrap();
assert_eq!(
detect_backend_with(tmp.path(), Some("prod"), no_env),
DatabaseBackend::Postgres
);
}
}