use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use autumn_web::config::DatabaseBackend;
use super::{Flags, GenerateError, provenance};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Create { path: PathBuf, contents: String },
Modify { path: PathBuf, contents: String },
CreateBytes { path: PathBuf, bytes: Vec<u8> },
CreateIfAbsent { path: PathBuf, contents: String },
}
impl Action {
#[must_use]
pub fn path(&self) -> &Path {
match self {
Self::Create { path, .. }
| Self::Modify { path, .. }
| Self::CreateBytes { path, .. }
| Self::CreateIfAbsent { path, .. } => path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Revert {
ModDecl { path: PathBuf, name: String },
RoutesEntries { path: PathBuf, entries: Vec<String> },
SchemaTable {
path: PathBuf,
table: String,
expected_block: String,
},
CargoDeps {
path: PathBuf,
names: Vec<String>,
owner_dir: PathBuf,
},
CargoAutumnWebFeature {
path: PathBuf,
feature: String,
owner_dir: Option<PathBuf>,
},
CargoAutumnWebDevFeature {
path: PathBuf,
feature: String,
owner_dir: Option<PathBuf>,
},
JobEntry { path: PathBuf, entry: String },
PolicyRegistration {
path: PathBuf,
pascal: String,
snake: String,
},
JobsRegistration { path: PathBuf, owner_dir: PathBuf },
MailPreview { path: PathBuf, mailer_type: String },
InboundMailHandler {
path: PathBuf,
handler_module_path: String,
},
SystemTestCargoPatch { path: PathBuf, snake_name: String },
PwaMainRsInjection { path: PathBuf },
AuthOAuthProviderStubs {
path: PathBuf,
providers: Vec<String>,
},
AuthWebauthnStub { path: PathBuf },
WebhookEndpointStub {
path: PathBuf,
name: String,
route_path: String,
},
SubmitTokenValidateExempt {
path: PathBuf,
plural: String,
segment: String,
},
RememberMiddleware { path: PathBuf },
SeedBinLinks { path: PathBuf, owner_dir: PathBuf },
NestedChildSection { path: PathBuf, child_plural: String },
I18nFtlKeys {
path: PathBuf,
pascal: String,
snake: String,
},
}
impl Revert {
#[must_use]
pub fn path(&self) -> &Path {
match self {
Self::ModDecl { path, .. }
| Self::RoutesEntries { path, .. }
| Self::SchemaTable { path, .. }
| Self::CargoDeps { path, .. }
| Self::CargoAutumnWebFeature { path, .. }
| Self::CargoAutumnWebDevFeature { path, .. }
| Self::JobEntry { path, .. }
| Self::JobsRegistration { path, .. }
| Self::PolicyRegistration { path, .. }
| Self::MailPreview { path, .. }
| Self::InboundMailHandler { path, .. }
| Self::SystemTestCargoPatch { path, .. }
| Self::PwaMainRsInjection { path }
| Self::AuthOAuthProviderStubs { path, .. }
| Self::AuthWebauthnStub { path }
| Self::WebhookEndpointStub { path, .. }
| Self::SubmitTokenValidateExempt { path, .. }
| Self::RememberMiddleware { path }
| Self::SeedBinLinks { path, .. }
| Self::NestedChildSection { path, .. }
| Self::I18nFtlKeys { path, .. } => path,
}
}
fn owner_dir(&self) -> Option<&Path> {
match self {
Self::CargoDeps { owner_dir, .. }
| Self::JobsRegistration { owner_dir, .. }
| Self::SeedBinLinks { owner_dir, .. } => Some(owner_dir),
Self::CargoAutumnWebFeature { owner_dir, .. }
| Self::CargoAutumnWebDevFeature { owner_dir, .. } => owner_dir.as_deref(),
Self::ModDecl { .. }
| Self::RoutesEntries { .. }
| Self::SchemaTable { .. }
| Self::JobEntry { .. }
| Self::PolicyRegistration { .. }
| Self::MailPreview { .. }
| Self::InboundMailHandler { .. }
| Self::SystemTestCargoPatch { .. }
| Self::PwaMainRsInjection { .. }
| Self::AuthOAuthProviderStubs { .. }
| Self::AuthWebauthnStub { .. }
| Self::WebhookEndpointStub { .. }
| Self::SubmitTokenValidateExempt { .. }
| Self::NestedChildSection { .. }
| Self::I18nFtlKeys { .. }
| Self::RememberMiddleware { .. } => None,
}
}
fn apply(
&self,
content: &str,
project_root: &Path,
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> String {
use super::inbound_mail::remove_inbound_mail_handler;
use super::schema_edit::{
remove_autumn_web_dev_dependency_feature, remove_autumn_web_feature, remove_job_entry,
remove_jobs_registration_from_app, remove_mail_preview_from_app,
remove_mod_declaration, remove_policy_registration_from_app,
remove_remember_middleware_from_app, remove_routes_entries, remove_schema_table,
};
match self {
Self::ModDecl { name, .. } => remove_mod_declaration(content, name),
Self::RoutesEntries { entries, .. } => remove_routes_entries(content, entries),
Self::SchemaTable {
table,
expected_block,
..
} => remove_schema_table(content, table, expected_block),
Self::CargoDeps { names, .. } => {
let survives: Vec<&str> = names
.iter()
.map(String::as_str)
.filter(|name| {
!crate_referenced_elsewhere_in_project(
project_root,
name,
excluding,
overrides,
)
})
.collect();
if survives.is_empty() {
content.to_owned()
} else {
super::model::remove_cargo_dependencies(content, &survives)
}
}
Self::CargoAutumnWebFeature { feature, .. } => {
if autumn_web_feature_pinned_by_backend(feature, project_root)
|| autumn_web_feature_still_needed_elsewhere(
feature,
project_root,
excluding,
overrides,
)
{
content.to_owned()
} else {
remove_autumn_web_feature(content, feature)
}
}
Self::CargoAutumnWebDevFeature { feature, .. } => {
if autumn_web_feature_still_needed_elsewhere(
feature,
project_root,
excluding,
overrides,
) {
content.to_owned()
} else {
remove_autumn_web_dev_dependency_feature(content, feature)
}
}
Self::JobEntry { entry, .. } => remove_job_entry(content, entry),
Self::JobsRegistration { .. } => remove_jobs_registration_from_app(content),
Self::PolicyRegistration { pascal, snake, .. } => {
remove_policy_registration_from_app(content, pascal, snake)
}
Self::MailPreview { mailer_type, .. } => {
remove_mail_preview_from_app(content, mailer_type)
}
Self::InboundMailHandler {
handler_module_path,
..
} => remove_inbound_mail_handler(content, handler_module_path),
Self::SystemTestCargoPatch { snake_name, .. } => {
super::system_test::remove_cargo_toml_patch(
content,
snake_name,
project_root,
excluding,
)
}
Self::PwaMainRsInjection { .. } => super::pwa::remove_pwa_injection(content),
Self::RememberMiddleware { .. } => remove_remember_middleware_from_app(content),
Self::AuthOAuthProviderStubs { providers, .. } => {
super::auth::remove_oauth_provider_stubs(content, providers)
}
Self::AuthWebauthnStub { .. } => super::auth::remove_webauthn_stub(content),
Self::WebhookEndpointStub {
name, route_path, ..
} => super::webhook::remove_webhook_endpoint(content, name, route_path),
Self::SubmitTokenValidateExempt {
plural, segment, ..
} => super::scaffold::remove_submit_token_exempt_from_toml(content, plural, segment),
Self::SeedBinLinks { .. } => super::schema_edit::unlink_models_from_seed_bin(content),
Self::NestedChildSection { child_plural, .. } => {
super::nested::remove_nested_child_section(content, child_plural)
}
Self::I18nFtlKeys { pascal, snake, .. } => {
remove_i18n_keys(content, pascal, snake, project_root, excluding, overrides)
}
}
}
}
fn remove_i18n_keys(
content: &str,
pascal: &str,
snake: &str,
project_root: &Path,
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> String {
let surviving = keys_still_referenced(content, project_root, excluding, overrides);
super::scaffold_i18n::remove_en_ftl_keys(content, pascal, snake, &surviving)
}
pub(super) fn keys_still_referenced(
bundle: &str,
project_root: &Path,
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> HashSet<String> {
let scan = scan_surviving_sources(project_root, excluding, overrides);
keys_still_referenced_in(&scan, bundle)
}
pub(super) fn keys_still_referenced_in(
scan: &crate::i18n::ScanResult,
bundle: &str,
) -> HashSet<String> {
let mut surviving: HashSet<String> = scan.referenced.iter().cloned().collect();
if !scan.dynamic.is_empty() {
surviving.extend(
super::scaffold_i18n::defined_keys(bundle)
.into_keys()
.filter(|key| {
scan.dynamic
.iter()
.any(|site| key.starts_with(&site.key_prefix))
}),
);
}
surviving
}
pub(super) fn pending_contents(plan: &Plan) -> HashMap<PathBuf, String> {
plan.actions
.iter()
.filter_map(|action| match action {
Action::Create { path, contents }
| Action::Modify { path, contents }
| Action::CreateIfAbsent { path, contents } => Some((path.clone(), contents.clone())),
Action::CreateBytes { .. } => None,
})
.collect()
}
pub(super) fn scan_surviving_sources(
project_root: &Path,
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> crate::i18n::ScanResult {
let mut files = Vec::new();
crate::i18n::collect_rs_files(project_root, &mut files);
files.extend(overrides.keys().cloned());
files.sort();
files.dedup();
let mut scan = crate::i18n::ScanResult::default();
for path in files {
let content = overrides.get(&path).cloned().or_else(|| {
if excluding.contains(&path) {
None
} else {
fs::read_to_string(&path).ok()
}
});
let Some(content) = content else { continue };
crate::i18n::scan_source(&content, &path.to_string_lossy(), &mut scan);
}
scan
}
#[derive(Debug)]
pub struct Plan {
pub project_root: PathBuf,
pub invocation: String,
pub actions: Vec<Action>,
pub warnings: Vec<String>,
pub reverts: Vec<Revert>,
}
impl Plan {
#[must_use]
pub fn new(project_root: impl Into<PathBuf>) -> Self {
let project_root = project_root.into();
Self {
invocation: provenance::current_invocation(&project_root),
project_root,
actions: Vec::new(),
warnings: Vec::new(),
reverts: Vec::new(),
}
}
pub fn warn(&mut self, message: impl Into<String>) {
self.warnings.push(message.into());
}
pub fn push_revert(&mut self, revert: Revert) {
self.reverts.push(revert);
}
pub fn create(&mut self, path: impl Into<PathBuf>, contents: impl Into<String>) {
self.actions.push(Action::Create {
path: path.into(),
contents: contents.into(),
});
}
pub fn modify(&mut self, path: impl Into<PathBuf>, contents: impl Into<String>) {
self.actions.push(Action::Modify {
path: path.into(),
contents: contents.into(),
});
}
pub fn create_bytes(&mut self, path: impl Into<PathBuf>, bytes: impl Into<Vec<u8>>) {
self.actions.push(Action::CreateBytes {
path: path.into(),
bytes: bytes.into(),
});
}
pub fn create_if_absent(&mut self, path: impl Into<PathBuf>, contents: impl Into<String>) {
self.actions.push(Action::CreateIfAbsent {
path: path.into(),
contents: contents.into(),
});
}
fn collisions(&self) -> Vec<PathBuf> {
self.actions
.iter()
.filter_map(|a| match a {
Action::Create { path, .. } | Action::CreateBytes { path, .. } if path.exists() => {
Some(path.clone())
}
_ => None,
})
.collect()
}
pub fn execute(&self, flags: Flags) -> Result<(), GenerateError> {
if flags.dry_run {
self.print_warnings();
self.print_dry_run();
return Ok(());
}
if !flags.force {
let collisions = self.collisions();
if !collisions.is_empty() {
return Err(GenerateError::Collisions(collisions));
}
}
self.print_warnings();
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
for action in &self.actions {
let path = action.path();
if let Some(parent) = path.parent() {
dirs.insert(parent.to_path_buf());
}
}
for dir in &dirs {
fs::create_dir_all(dir)?;
}
let mut written: Vec<(PathBuf, String)> = Vec::new();
let result = self.write_actions(&mut written);
self.record_provenance(written);
result
}
fn write_actions(&self, written: &mut Vec<(PathBuf, String)>) -> Result<(), GenerateError> {
for action in &self.actions {
let path = action.path();
match action {
Action::Create { contents, .. } | Action::Modify { contents, .. } => {
let label = if matches!(action, Action::Modify { .. }) && path.exists() {
"Modified"
} else {
"Created"
};
fs::write(path, contents)?;
if matches!(action, Action::Create { .. }) {
written.push((path.to_path_buf(), provenance::text_digest(contents)));
}
println!(" {label} {}", relative_display(path, &self.project_root));
}
Action::CreateBytes { bytes, .. } => {
fs::write(path, bytes)?;
written.push((path.to_path_buf(), provenance::bytes_digest(bytes)));
println!(" Created {}", relative_display(path, &self.project_root));
}
Action::CreateIfAbsent { contents, .. } => {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(mut f) => {
if let Err(e) = f.write_all(contents.as_bytes()) {
let _ = fs::remove_file(path);
return Err(GenerateError::Io(e));
}
written.push((path.to_path_buf(), provenance::text_digest(contents)));
println!(" Created {}", relative_display(path, &self.project_root));
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
}
Err(e) => return Err(GenerateError::Io(e)),
}
}
}
}
Ok(())
}
fn record_provenance(&self, written: Vec<(PathBuf, String)>) {
if written.is_empty() {
return;
}
let mut recorded = provenance::Provenance::load(&self.project_root);
for (path, digest) in written {
recorded.record(&self.project_root, &path, digest, &self.invocation);
}
if let Err(e) = recorded.save(&self.project_root) {
eprintln!(
"Warning: could not record generated-file provenance in {}: {e}. \
`autumn destroy` may need --force after a CLI upgrade.",
provenance::MANIFEST_PATH
);
}
}
fn print_warnings(&self) {
for warning in &self.warnings {
eprintln!("Warning: {warning}");
}
}
fn print_dry_run(&self) {
println!("Dry run — no files written.");
for action in &self.actions {
let label = match action {
Action::Create { path, .. } | Action::CreateBytes { path, .. } if path.exists() => {
"Would overwrite"
}
Action::Modify { path, .. } if path.exists() => "Would modify",
Action::CreateIfAbsent { path, .. } if path.exists() => "Would skip (exists)",
Action::Create { .. }
| Action::Modify { .. }
| Action::CreateBytes { .. }
| Action::CreateIfAbsent { .. } => "Would create",
};
println!(
" {label} {}",
relative_display(action.path(), &self.project_root)
);
}
}
pub fn revert(&self, flags: Flags) -> Result<(), GenerateError> {
let plan = self.compute_revert_plan(flags.force);
if flags.dry_run {
println!("Dry run — no files removed.");
for path in &plan.files_to_remove {
println!(
" Would remove {}",
relative_display(path, &self.project_root)
);
}
for dir in &plan.migrations_to_remove {
println!(
" Would remove {}",
relative_display(dir, &self.project_root)
);
}
for (path, new_content) in &plan.modifies {
let label = if new_content.is_none() {
"Would remove"
} else {
"Would revert"
};
println!(" {label} {}", relative_display(path, &self.project_root));
}
if !plan.diverged.is_empty() {
println!(
" A real run would refuse (without --force) — content has diverged from what generate produced:"
);
for path in &plan.diverged {
println!(
" Diverged {}",
relative_display(path, &self.project_root)
);
}
}
for warning in &plan.warnings {
eprintln!("Warning: {warning}");
}
return Ok(());
}
if !plan.diverged.is_empty() && !flags.force {
return Err(GenerateError::Diverged(plan.diverged));
}
for warning in &plan.warnings {
eprintln!("Warning: {warning}");
}
let mut touched_dirs: Vec<PathBuf> = Vec::new();
let mut removed = Removed::default();
let result = self.apply_revert(&plan, &mut touched_dirs, &mut removed);
for dir in touched_dirs {
prune_empty_ancestors(dir, &self.project_root);
}
self.forget_provenance(&removed);
result?;
sync_mod_declarations_in(
&self.project_root.join("src").join("mailers"),
&["previews"],
&self.project_root,
);
sync_main_rs_mod_declarations(&self.project_root);
Ok(())
}
fn apply_revert(
&self,
plan: &RevertPlan,
touched_dirs: &mut Vec<PathBuf>,
removed: &mut Removed,
) -> Result<(), GenerateError> {
for path in &plan.files_to_remove {
fs::remove_file(path)?;
removed.files.push(path.clone());
println!(" Removed {}", relative_display(path, &self.project_root));
if let Some(parent) = path.parent() {
touched_dirs.push(parent.to_path_buf());
}
}
for dir in &plan.migrations_to_remove {
fs::remove_dir_all(dir)?;
removed.dirs.push(dir.clone());
println!(" Removed {}", relative_display(dir, &self.project_root));
touched_dirs.push(self.project_root.join("migrations"));
}
for (path, new_content) in &plan.modifies {
if let Some(content) = new_content {
fs::write(path, content)?;
println!(" Reverted {}", relative_display(path, &self.project_root));
} else {
fs::remove_file(path)?;
println!(" Removed {}", relative_display(path, &self.project_root));
if let Some(parent) = path.parent() {
touched_dirs.push(parent.to_path_buf());
}
}
}
Ok(())
}
fn forget_provenance(&self, removed: &Removed) {
if removed.files.is_empty() && removed.dirs.is_empty() {
return;
}
let mut recorded = provenance::Provenance::load(&self.project_root);
if recorded.is_empty() {
return;
}
for path in &removed.files {
recorded.forget(&self.project_root, path);
}
for dir in &removed.dirs {
recorded.forget_dir(&self.project_root, dir);
}
if let Err(e) = recorded.save(&self.project_root) {
eprintln!(
"Warning: could not update {} after destroy: {e}",
provenance::MANIFEST_PATH
);
}
}
#[allow(
clippy::too_many_lines,
reason = "a linear sequence of independent passes (normal files, migrations, \
create-if-absent shared files, modify reverts) that share the \
files_to_remove/diverged accumulators — splitting it up would just \
move the same state-threading around, not simplify it"
)]
fn compute_revert_plan(&self, force: bool) -> RevertPlan {
let migrations_root = self.project_root.join("migrations");
let recorded = provenance::Provenance::load(&self.project_root);
let baseline = Baseline {
recorded: &recorded,
invocation: &self.invocation,
};
let mut migration_groups: Vec<(PathBuf, Vec<&Action>)> = Vec::new();
let mut normal_actions: Vec<&Action> = Vec::new();
let mut create_if_absent_actions: Vec<&Action> = Vec::new();
for action in &self.actions {
if matches!(action, Action::Modify { .. }) {
continue;
}
let path = action.path();
if let Some(dir) = path.parent()
&& dir.parent() == Some(migrations_root.as_path())
{
if let Some(entry) = migration_groups.iter_mut().find(|(d, _)| d == dir) {
entry.1.push(action);
} else {
migration_groups.push((dir.to_path_buf(), vec![action]));
}
continue;
}
if matches!(action, Action::CreateIfAbsent { .. }) {
create_if_absent_actions.push(action);
} else {
normal_actions.push(action);
}
}
let mut files_to_remove = Vec::new();
let mut diverged = Vec::new();
let mut warnings = Vec::new();
for action in normal_actions {
let path = action.path();
if !path.exists() {
continue; }
let matches = is_generator_output(action, path, &self.project_root, baseline);
if matches || force {
files_to_remove.push(path.to_path_buf());
} else {
diverged.push(path.to_path_buf());
}
}
let create_if_absent_paths: Vec<PathBuf> = create_if_absent_actions
.iter()
.map(|a| a.path().to_path_buf())
.collect();
for action in create_if_absent_actions {
let path = action.path();
if !path.exists() {
continue;
}
if let Some(dir) = path.parent() {
let mut excluding = files_to_remove.clone();
excluding.extend(create_if_absent_paths.iter().cloned());
if resource_dir_has_other_files(dir, &excluding) {
continue; }
}
let matches = is_generator_output(action, path, &self.project_root, baseline);
if matches {
files_to_remove.push(path.to_path_buf());
} else {
warnings.push(format!(
"{} doesn't match what this generator produces; leaving it in \
place since it may predate this resource — remove it by hand \
if it's actually orphaned.",
relative_display(path, &self.project_root),
));
}
}
let mut migrations_to_remove = Vec::new();
for (dir, actions) in &migration_groups {
match resolve_migration_removal(
dir,
actions,
&migrations_root,
force,
&self.project_root,
&files_to_remove,
baseline,
) {
MigrationOutcome::Remove(real_dir) => migrations_to_remove.push(real_dir),
MigrationOutcome::Diverged(path) => diverged.push(path),
MigrationOutcome::Applied(real_dir) => warnings.push(format!(
"migration {} appears to be applied to the database; skipping it \
— write a down-migration instead. destroy never touches the database.",
relative_display(&real_dir, &self.project_root),
)),
MigrationOutcome::Ambiguous(suffix) => warnings.push(format!(
"multiple migrations directories match the expected suffix '{suffix}'; \
skipping — remove the correct one manually."
)),
MigrationOutcome::StillNeededElsewhere(real_dir) => warnings.push(format!(
"migration {} is still needed by another mailer's --list-unsubscribe; \
skipping — pass --force to remove it anyway.",
relative_display(&real_dir, &self.project_root),
)),
MigrationOutcome::NotFound => {}
}
}
let grouped_reverts = group_reverts_by_path(&self.reverts);
let mut cargo_deps_excluding = files_to_remove.clone();
cargo_deps_excluding.extend(grouped_reverts.iter().map(|(path, _)| path.clone()));
let cargo_toml_path = self.project_root.join("Cargo.toml");
let mut scan_overrides: HashMap<PathBuf, String> = HashMap::new();
for (path, reverts) in &grouped_reverts {
if *path == cargo_toml_path || !path.exists() {
continue;
}
let Ok(original) = fs::read_to_string(path) else {
continue;
};
let mut content = original;
for revert in reverts {
if let Some(dir) = revert.owner_dir()
&& resource_dir_has_other_files(dir, &files_to_remove)
{
continue;
}
content = revert.apply(
&content,
&self.project_root,
&cargo_deps_excluding,
&scan_overrides,
);
}
if !content.trim().is_empty() {
scan_overrides.insert(path.clone(), content);
}
}
let mut modifies = Vec::new();
for (path, reverts) in grouped_reverts {
if !path.exists() {
continue;
}
let Ok(original) = fs::read_to_string(&path) else {
continue;
};
let mut content = original.clone();
for revert in &reverts {
if let Some(dir) = revert.owner_dir()
&& resource_dir_has_other_files(dir, &files_to_remove)
{
continue;
}
content = revert.apply(
&content,
&self.project_root,
&cargo_deps_excluding,
&scan_overrides,
);
}
if content == original {
continue;
}
if content.trim().is_empty() {
modifies.push((path, None));
} else {
modifies.push((path, Some(content)));
}
}
RevertPlan {
files_to_remove,
migrations_to_remove,
modifies,
diverged,
warnings,
}
}
}
#[derive(Default)]
struct Removed {
files: Vec<PathBuf>,
dirs: Vec<PathBuf>,
}
struct RevertPlan {
files_to_remove: Vec<PathBuf>,
migrations_to_remove: Vec<PathBuf>,
modifies: Vec<(PathBuf, Option<String>)>,
diverged: Vec<PathBuf>,
warnings: Vec<String>,
}
fn group_reverts_by_path(reverts: &[Revert]) -> Vec<(PathBuf, Vec<&Revert>)> {
let mut groups: Vec<(PathBuf, Vec<&Revert>)> = Vec::new();
for revert in reverts {
let path = revert.path().to_path_buf();
if let Some(entry) = groups.iter_mut().find(|(p, _)| p == &path) {
entry.1.push(revert);
} else {
groups.push((path, vec![revert]));
}
}
groups
}
fn prune_empty_ancestors(mut dir: PathBuf, project_root: &Path) {
while dir != *project_root && dir.starts_with(project_root) {
if fs::remove_dir(&dir).is_err() {
break;
}
match dir.parent() {
Some(parent) => dir = parent.to_path_buf(),
None => break,
}
}
}
fn resource_dir_has_other_files(dir: &Path, excluding: &[PathBuf]) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
entries.filter_map(Result::ok).any(|entry| {
let path = entry.path();
path.is_file()
&& path.file_name() != Some(std::ffi::OsStr::new("mod.rs"))
&& !excluding.contains(&path)
})
}
fn crate_referenced_elsewhere_in_project(
project_root: &Path,
crate_name: &str,
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> bool {
crate_reference_site(project_root, crate_name, excluding, overrides).is_some()
}
pub fn crate_reference_site(
project_root: &Path,
crate_name: &str,
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> Option<PathBuf> {
let markers = crate_reference_markers(crate_name);
["src", "tests", "benches", "examples"]
.iter()
.find_map(|dir| {
rs_tree_marker_site(&project_root.join(dir), &markers, excluding, overrides)
})
}
#[must_use]
pub fn crate_reference_markers(crate_name: &str) -> Vec<String> {
let ident = crate_name.replace('-', "_");
vec![
format!("{ident}::"),
format!("use {ident}"),
format!("extern crate {ident}"),
]
}
fn autumn_web_feature_markers(feature: &str) -> &'static [&'static str] {
match feature {
"mail" => &["Mail::builder("],
"oauth2" => &["OAuth2"],
"webauthn" => &["Webauthn"],
"ws" => &["#[ws]", "autumn_web::sse::stream("],
"test-support" => &["TestDb::"],
"multipart" => &["Multipart"],
"storage" => &["autumn_web::storage::"],
"markdown" => &["autumn_web::markdown::"],
"csv" => &["autumn_web::data::csv"],
_ => &[],
}
}
fn autumn_web_feature_pinned_by_backend(feature: &str, project_root: &Path) -> bool {
feature == "sqlite" && super::detect_backend(project_root) == DatabaseBackend::Sqlite
}
fn autumn_web_feature_still_needed_elsewhere(
feature: &str,
project_root: &Path,
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> bool {
let markers = autumn_web_feature_markers(feature);
if markers.is_empty() {
return false;
}
let markers: Vec<String> = markers.iter().map(|m| (*m).to_owned()).collect();
["src", "tests", "benches"]
.iter()
.any(|dir| rs_tree_contains_marker(&project_root.join(dir), &markers, excluding, overrides))
}
pub(super) fn cargo_feature_still_gated_elsewhere(
feature: &str,
project_root: &Path,
excluding: &[PathBuf],
) -> bool {
let markers = [format!("feature = \"{feature}\"")];
let no_overrides = HashMap::new();
["src", "tests", "benches"].iter().any(|dir| {
rs_tree_contains_marker(&project_root.join(dir), &markers, excluding, &no_overrides)
})
}
fn rs_tree_contains_marker(
dir: &Path,
markers: &[String],
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> bool {
rs_tree_marker_site(dir, markers, excluding, overrides).is_some()
}
fn rs_tree_marker_site(
dir: &Path,
markers: &[String],
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> Option<PathBuf> {
let entries = fs::read_dir(dir).ok()?;
let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
if let Some(found) = rs_tree_marker_site(&path, markers, excluding, overrides) {
return Some(found);
}
continue;
}
if path.extension().is_none_or(|ext| ext != "rs") {
continue;
}
let content = overrides.get(&path).cloned().or_else(|| {
if excluding.contains(&path) {
None
} else {
fs::read_to_string(&path).ok()
}
});
if content.is_some_and(|c| markers.iter().any(|m| c.contains(m.as_str()))) {
return Some(path);
}
}
None
}
enum MigrationOutcome {
Remove(PathBuf),
Diverged(PathBuf),
Applied(PathBuf),
Ambiguous(String),
StillNeededElsewhere(PathBuf),
NotFound,
}
fn migration_dir_matches_actions(dir: &Path, actions: &[&Action]) -> bool {
actions.iter().all(|action| {
let Some(file_name) = action.path().file_name() else {
return true;
};
let expected: &str = match action {
Action::Create { contents, .. } | Action::CreateIfAbsent { contents, .. } => contents,
Action::CreateBytes { .. } | Action::Modify { .. } => return true,
};
fs::read_to_string(dir.join(file_name)).is_ok_and(|actual| text_matches(&actual, expected))
})
}
fn is_generator_output(
action: &Action,
path: &Path,
project_root: &Path,
baseline: Baseline<'_>,
) -> bool {
let (on_disk_digest, planned) = match action {
Action::Create { contents, .. } | Action::CreateIfAbsent { contents, .. } => {
let Ok(on_disk) = fs::read_to_string(path) else {
return false;
};
if text_matches(&on_disk, contents) {
return true;
}
(provenance::text_digest(&on_disk), Claim::of(action))
}
Action::CreateBytes { bytes, .. } => {
let Ok(on_disk) = fs::read(path) else {
return false;
};
if &on_disk == bytes {
return true;
}
(provenance::bytes_digest(&on_disk), Claim::of(action))
}
Action::Modify { .. } => return false,
};
baseline.accepts(planned, project_root, path, &on_disk_digest)
}
#[derive(Clone, Copy)]
struct Baseline<'a> {
recorded: &'a provenance::Provenance,
invocation: &'a str,
}
impl Baseline<'_> {
fn accepts(self, claim: Claim, project_root: &Path, path: &Path, digest: &str) -> bool {
match claim {
Claim::ByThisCommand => {
self.recorded
.is_ours(project_root, path, digest, self.invocation)
}
Claim::ByAnyCommand => self.recorded.was_written(project_root, path, digest),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Claim {
ByThisCommand,
ByAnyCommand,
}
impl Claim {
const fn of(action: &Action) -> Self {
match action {
Action::CreateIfAbsent { .. } => Self::ByAnyCommand,
Action::Create { .. } | Action::CreateBytes { .. } | Action::Modify { .. } => {
Self::ByThisCommand
}
}
}
}
fn text_matches(on_disk: &str, expected: &str) -> bool {
on_disk == expected || provenance::text_digest(on_disk) == provenance::text_digest(expected)
}
fn split_migration_dir_name(name: &str) -> (&str, &str) {
let digit_len = name
.bytes()
.position(|b| !b.is_ascii_digit())
.unwrap_or(name.len());
let (version, rest) = name.split_at(digit_len);
(version, rest.strip_prefix('_').unwrap_or(rest))
}
fn resolve_migration_removal(
plan_dir: &Path,
actions: &[&Action],
migrations_root: &Path,
force: bool,
project_root: &Path,
excluding: &[PathBuf],
baseline: Baseline<'_>,
) -> MigrationOutcome {
let Some(plan_dir_name) = plan_dir.file_name().and_then(|n| n.to_str()) else {
return MigrationOutcome::NotFound;
};
let (_, suffix) = split_migration_dir_name(plan_dir_name);
let Ok(entries) = fs::read_dir(migrations_root) else {
return MigrationOutcome::NotFound;
};
let mut candidates: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.is_dir()
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| split_migration_dir_name(name).1 == suffix)
})
.collect();
candidates.sort();
let real_dir = match candidates.len() {
0 => return MigrationOutcome::NotFound,
1 => candidates.into_iter().next().expect("len checked above"),
_ => {
let mut matching: Vec<PathBuf> = candidates
.into_iter()
.filter(|dir| migration_dir_matches_actions(dir, actions))
.collect();
if matching.len() != 1 {
return MigrationOutcome::Ambiguous(suffix.to_owned());
}
matching.remove(0)
}
};
for action in actions {
let Some(file_name) = action.path().file_name() else {
continue;
};
if matches!(action, Action::CreateBytes { .. } | Action::Modify { .. }) {
continue;
}
let real_file = real_dir.join(file_name);
if !is_generator_output(action, &real_file, project_root, baseline) && !force {
return MigrationOutcome::Diverged(real_file);
}
}
if !force && let Ok(entries) = fs::read_dir(&real_dir) {
for entry in entries.filter_map(Result::ok) {
let name = entry.file_name();
let planned = actions
.iter()
.any(|action| action.path().file_name() == Some(name.as_os_str()));
if !planned {
return MigrationOutcome::Diverged(entry.path());
}
}
}
if !force
&& mail_unsubscribes_migration_still_needed_elsewhere(&real_dir, project_root, excluding)
{
return MigrationOutcome::StillNeededElsewhere(real_dir);
}
let real_dir_name = real_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
let (version, _) = split_migration_dir_name(real_dir_name);
match migration_applied_status(version, migrations_root) {
MigrationStatus::Applied | MigrationStatus::Unknown => MigrationOutcome::Applied(real_dir),
MigrationStatus::NotApplied | MigrationStatus::NotConfigured => {
MigrationOutcome::Remove(real_dir)
}
}
}
fn mail_unsubscribes_migration_still_needed_elsewhere(
real_dir: &Path,
project_root: &Path,
excluding: &[PathBuf],
) -> bool {
let is_unsubscribes_migration = real_dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| split_migration_dir_name(name).1 == "create_mail_unsubscribes");
if !is_unsubscribes_migration {
return false;
}
let Ok(entries) = fs::read_dir(project_root.join("src").join("mailers")) else {
return false;
};
entries.filter_map(Result::ok).any(|entry| {
let path = entry.path();
path.extension().is_some_and(|ext| ext == "rs")
&& !excluding.contains(&path)
&& fs::read_to_string(&path)
.is_ok_and(|content| content.contains("list_unsubscribe = "))
})
}
enum MigrationStatus {
NotConfigured,
NotApplied,
Applied,
Unknown,
}
fn migration_applied_status(version: &str, migrations_dir: &Path) -> MigrationStatus {
let profile = crate::migrate::effective_profile(None);
let table = crate::migrate::read_autumn_toml_table_with_profile(Some(&profile));
let control_url = crate::migrate::resolve_primary_database_url_from_sources(
|k| std::env::var(k),
table.as_ref(),
);
let shard_urls = crate::migrate::resolve_shard_database_urls_from_sources(
|k| std::env::var(k),
table.as_ref(),
);
let urls: Vec<String> = control_url
.into_iter()
.chain(shard_urls.into_iter().map(|(_, url)| url))
.collect();
if urls.is_empty() {
return MigrationStatus::NotConfigured;
}
let mut any_unreachable = false;
for url in urls {
match autumn_web::migrate::applied_user_migrations(&url, migrations_dir) {
Ok(applied) if applied.iter().any(|m| m.version == version) => {
return MigrationStatus::Applied;
}
Ok(_) => {}
Err(_) => any_unreachable = true,
}
}
if any_unreachable {
MigrationStatus::Unknown
} else {
MigrationStatus::NotApplied
}
}
const SHARED_MAIN_MODULE_NAMES: &[&str] = &[
"models",
"schema",
"repositories",
"routes",
"channels",
"jobs",
"mailers",
"inbound_mailers",
"webhooks",
"policies",
"notifications",
"teams",
];
fn shared_module_backing_path_exists(project_root: &Path, name: &str) -> bool {
let src = project_root.join("src");
src.join(format!("{name}.rs")).is_file() || src.join(name).join("mod.rs").is_file()
}
fn sync_main_rs_mod_declarations(project_root: &Path) {
let main_path = project_root.join("src").join("main.rs");
let Ok(content) = fs::read_to_string(&main_path) else {
return;
};
let orphaned: Vec<&str> = SHARED_MAIN_MODULE_NAMES
.iter()
.copied()
.filter(|name| {
content.lines().any(|l| l.trim() == format!("mod {name};"))
&& !shared_module_backing_path_exists(project_root, name)
})
.collect();
if orphaned.is_empty() {
return;
}
let updated = super::schema_edit::remove_main_mod_declarations(&content, &orphaned);
if updated != content && fs::write(&main_path, &updated).is_ok() {
println!(" Reverted {}", relative_display(&main_path, project_root));
}
}
fn sync_mod_declarations_in(dir: &Path, shared_names: &[&str], project_root: &Path) {
let mod_path = dir.join("mod.rs");
let Ok(content) = fs::read_to_string(&mod_path) else {
return;
};
let orphaned: Vec<&str> = shared_names
.iter()
.copied()
.filter(|name| {
content
.lines()
.any(|l| l.trim() == format!("pub mod {name};"))
&& !dir.join(format!("{name}.rs")).is_file()
&& !dir.join(name).join("mod.rs").is_file()
})
.collect();
if orphaned.is_empty() {
return;
}
let mut updated = content.clone();
for name in orphaned {
updated = super::schema_edit::remove_mod_declaration(&updated, name);
}
if updated == content {
return;
}
if updated.trim().is_empty() {
if fs::remove_file(&mod_path).is_ok() {
println!(" Removed {}", relative_display(&mod_path, project_root));
prune_empty_ancestors(dir.to_path_buf(), project_root);
}
} else if fs::write(&mod_path, &updated).is_ok() {
println!(" Reverted {}", relative_display(&mod_path, project_root));
}
}
pub fn relative_display(path: &Path, root: &Path) -> String {
let display = path
.strip_prefix(root)
.map_or_else(|_| path.display().to_string(), |p| p.display().to_string());
display.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
fn surviving_common_keys(root: &Path) -> HashSet<String> {
scan_surviving_sources(root, &[], &HashMap::new())
.referenced
.into_iter()
.filter(|key| key.starts_with("common."))
.collect()
}
fn common_keys_in(src: &str) -> Vec<String> {
let mut scan = crate::i18n::ScanResult::default();
crate::i18n::scan_source(src, "test.rs", &mut scan);
scan.referenced
.into_iter()
.filter(|k| k.starts_with("common."))
.collect()
}
fn fixture() -> (tempfile::TempDir, Plan) {
let tmp = tempfile::TempDir::new().unwrap();
let plan = Plan::new(tmp.path());
(tmp, plan)
}
#[test]
fn new_plan_has_no_warnings() {
let (_tmp, plan) = fixture();
assert!(plan.warnings.is_empty());
}
#[test]
fn chrome_reachable_only_through_a_dynamic_key_survives() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(
root.join("src").join("nav.rs"),
"fn nav(locale: &Locale, action: &str) -> String {\n locale.t(&format!(\"common.{action}\"))\n}\n",
)
.unwrap();
let ftl = concat!(
"# Shared chrome — reused by every scaffolded resource.\n",
"common.save = Save\n",
"common.back = Back\n",
"# — end shared chrome —\n",
"\n",
"# Post — generated by `autumn generate scaffold --i18n`.\n",
"post.new = New Post\n",
"# — end Post —\n",
);
let out = remove_i18n_keys(ftl, "Post", "post", root, &[], &HashMap::new());
assert!(
out.contains("common.save") && out.contains("common.back"),
"a `common.{{action}}` lookup can reach both:\n{out}"
);
assert!(!out.contains("post.new"), "{out}");
}
#[test]
fn chrome_survives_when_only_a_non_src_target_uses_it() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("src/routes")).unwrap();
std::fs::create_dir_all(root.join("tests")).unwrap();
std::fs::create_dir_all(root.join("examples")).unwrap();
std::fs::write(
root.join("tests").join("views.rs"),
"fn t() { let _ = t!(locale, \"common.save\"); }\n",
)
.unwrap();
std::fs::write(
root.join("examples").join("demo.rs"),
"fn main() { let _ = locale.t(\"common.back\"); }\n",
)
.unwrap();
std::fs::write(
root.join("src/routes").join("posts.rs"),
"fn show() { let _ = t!(locale, \"post.new\"); }\n",
)
.unwrap();
let found = surviving_common_keys(root);
assert!(found.contains("common.save"), "tests/ counts: {found:?}");
assert!(found.contains("common.back"), "examples/ counts: {found:?}");
}
#[test]
fn chrome_scan_reads_every_supported_lookup_form() {
let cases: &[(&str, &str)] = &[
("plain", r#"let a = t!(locale, "common.plain");"#),
(
"rustfmt-wrapped",
"let b = t!(\n locale,\n \"common.wrapped\",\n n = &c.to_string()\n);",
),
(
"comment before key",
r#"let c = t!(locale, /* shared */ "common.commented");"#,
),
("raw string key", r#"let d = t!(locale, r"common.raw");"#),
(
"hashed raw key",
r##"let e = t!(locale, r#"common.hashed"#);"##,
),
(
"other locale binding",
r#"let f = t!(lang, "common.lang");"#,
),
("field locale", r#"let g = t!(ctx.locale, "common.field");"#),
("spaced call", r#"let h = t! (locale, "common.spaced");"#),
("method call", r#"let i = locale.t("common.method");"#),
(
"method with args",
r#"let j = locale.t_with("common.method_args", &[]);"#,
),
(
"associated call",
r#"let k = Locale::t(&locale, "common.assoc");"#,
),
(
"inside a macro body",
r#"let l = html! { span { (t!(locale, "common.nested")) } };"#,
),
];
for (what, src) in cases {
let found = common_keys_in(src);
assert!(!found.is_empty(), "no key found for the {what} form: {src}");
}
}
#[test]
fn chrome_scan_ignores_non_lookups() {
for src in [
r#"let a = "common.just-a-string";"#,
r#"// t!(locale, "common.commented-out")"#,
r#"/* t!(locale, "common.blocked") */"#,
r#"let b = t("common.bare-fn");"#,
] {
assert!(
common_keys_in(src).is_empty(),
"unexpectedly counted a reference in: {src}"
);
}
}
#[test]
fn chrome_survives_when_only_non_route_source_still_uses_it() {
let tmp = tempfile::TempDir::new().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(src.join("routes")).unwrap();
std::fs::create_dir_all(src.join("components")).unwrap();
std::fs::write(
src.join("components").join("navigation.rs"),
"fn nav(locale: &Locale) -> Markup { html! { (t!(locale, \"common.save\")) } }\n",
)
.unwrap();
std::fs::write(
src.join("routes").join("posts.rs"),
"fn show(locale: &Locale) -> Markup { html! { (t!(locale, \"post.new\")) } }\n",
)
.unwrap();
let found = surviving_common_keys(&src);
assert!(
found.contains("common.save"),
"a non-route caller keeps the key alive: {found:?}"
);
}
#[test]
fn a_resource_key_a_surviving_source_still_uses_is_not_destroyed() {
let tmp = tempfile::TempDir::new().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(src.join("components")).unwrap();
std::fs::write(
src.join("components").join("nav.rs"),
"fn nav(locale: &Locale) -> Markup { html! { (t!(locale, \"post.index.title\")) } }\n",
)
.unwrap();
let ftl = super::super::scaffold_i18n::merge_en_ftl_keeping(
"",
"Post",
"post",
&[
("post.index.title", "Posts"),
("post.new.title", "New Post"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect(),
&HashSet::new(),
);
let out = remove_i18n_keys(&ftl, "Post", "post", &src, &[], &HashMap::new());
assert!(
out.contains("post.index.title = Posts"),
"the surviving call site keeps its key:\n{out}"
);
assert!(
!out.contains("post.new.title"),
"keys nothing references still go:\n{out}"
);
}
#[test]
fn warn_records_message_and_execute_does_not_fail() {
let (_tmp, mut plan) = fixture();
plan.warn("referenced table 'posts' is assumed to exist");
assert_eq!(plan.warnings.len(), 1);
plan.execute(Flags::default()).unwrap();
}
#[test]
fn warnings_do_not_block_a_collision_error() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
fs::write(&target, "existing").unwrap();
plan.warn("some advisory message");
plan.create(target, "new");
let err = plan.execute(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Collisions(_)));
}
#[test]
fn create_action_writes_file() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.execute(Flags::default()).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "hello");
}
#[test]
fn create_action_creates_parent_dirs() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("nested/dir/out.txt");
plan.create(target.clone(), "hi");
plan.execute(Flags::default()).unwrap();
assert!(target.exists());
}
#[test]
fn collision_without_force_errors() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
fs::write(&target, "old").unwrap();
plan.create(target.clone(), "new");
let err = plan.execute(Flags::default()).unwrap_err();
match err {
GenerateError::Collisions(paths) => {
assert_eq!(paths, vec![target.clone()]);
}
_ => panic!("expected collision error, got {err:?}"),
}
assert_eq!(fs::read_to_string(&target).unwrap(), "old");
}
#[test]
fn collision_with_force_overwrites() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
fs::write(&target, "old").unwrap();
plan.create(target.clone(), "new");
plan.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new");
}
#[test]
fn modify_action_overwrites_without_force() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
fs::write(&target, "old").unwrap();
plan.modify(target.clone(), "new");
plan.execute(Flags::default()).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new");
}
#[test]
fn dry_run_writes_nothing() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(!target.exists());
}
#[test]
fn dry_run_skips_collision_check() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
fs::write(&target, "existing").unwrap();
plan.create(target.clone(), "new");
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "existing");
}
#[test]
fn collision_lists_every_offender() {
let (tmp, mut plan) = fixture();
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
fs::write(&a, "x").unwrap();
fs::write(&b, "y").unwrap();
plan.create(a.clone(), "1");
plan.create(b.clone(), "2");
let err = plan.execute(Flags::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains(a.display().to_string().replace('\\', "/").as_str()));
assert!(msg.contains(b.display().to_string().replace('\\', "/").as_str()));
}
fn no_db_env<R>(f: impl FnOnce() -> R) -> R {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
f,
)
}
#[test]
fn revert_removes_create_action_file() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.execute(Flags::default()).unwrap();
assert!(target.exists());
plan.revert(Flags::default()).unwrap();
assert!(!target.exists());
}
#[test]
fn revert_is_idempotent_when_file_already_missing() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.revert(Flags::default()).unwrap();
assert!(!target.exists());
}
#[test]
fn revert_refuses_on_diverged_content_without_force() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.execute(Flags::default()).unwrap();
fs::write(&target, "hand-edited by user").unwrap();
let err = plan.revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(target.exists(), "diverged file must not be deleted");
}
#[test]
fn revert_with_force_deletes_diverged_file() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.execute(Flags::default()).unwrap();
fs::write(&target, "hand-edited by user").unwrap();
plan.revert(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert!(!target.exists());
}
#[test]
fn revert_dry_run_does_not_touch_disk() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.execute(Flags::default()).unwrap();
plan.revert(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(target.exists());
}
fn newer_template_plan(tmp: &tempfile::TempDir, path: &Path, contents: &str) -> Plan {
let mut plan = Plan::new(tmp.path());
plan.create(path.to_path_buf(), contents);
plan
}
#[test]
fn revert_removes_untouched_file_whose_template_changed_since_generation() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("src/routes/auth.rs");
plan.create(target.clone(), "// v1 template\n");
plan.execute(Flags::default()).unwrap();
let newer = newer_template_plan(&tmp, &target, "// v2 template\n");
newer.revert(Flags::default()).unwrap();
assert!(!target.exists(), "untouched generated file must be removed");
}
#[test]
fn revert_still_refuses_a_hand_edited_file_whose_template_changed() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("src/routes/auth.rs");
plan.create(target.clone(), "// v1 template\n");
plan.execute(Flags::default()).unwrap();
fs::write(&target, "// hand-edited by user\n").unwrap();
let newer = newer_template_plan(&tmp, &target, "// v2 template\n");
let err = newer.revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(target.exists(), "a real edit must survive");
}
#[test]
fn execute_records_a_provenance_digest_for_every_owned_file() {
let (tmp, mut plan) = fixture();
plan.create(tmp.path().join("src/models/post.rs"), "// model\n");
plan.create_bytes(tmp.path().join("static/logo.png"), vec![1, 2, 3]);
plan.create_if_absent(tmp.path().join("templates/_layout.html"), "<html>\n");
plan.modify(tmp.path().join("src/main.rs"), "fn main() {}\n");
plan.execute(Flags::default()).unwrap();
let recorded = provenance::Provenance::load(tmp.path());
assert!(recorded.contains("src/models/post.rs"));
assert!(recorded.contains("static/logo.png"));
assert!(recorded.contains("templates/_layout.html"));
assert!(
!recorded.contains("src/main.rs"),
"a Modify target is shared, never owned by one plan"
);
}
#[test]
fn dry_run_execute_records_no_provenance() {
let (tmp, mut plan) = fixture();
plan.create(tmp.path().join("out.txt"), "hello");
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(!tmp.path().join(provenance::MANIFEST_PATH).exists());
}
#[test]
fn dry_run_revert_records_no_provenance_change() {
let (tmp, mut plan) = fixture();
plan.create(tmp.path().join("out.txt"), "hello");
plan.execute(Flags::default()).unwrap();
plan.revert(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(
provenance::Provenance::load(tmp.path()).contains("out.txt"),
"a dry run must leave the manifest alone"
);
}
#[test]
fn revert_prunes_the_provenance_entries_it_removed() {
let (tmp, mut plan) = fixture();
plan.create(tmp.path().join("out.txt"), "hello");
plan.execute(Flags::default()).unwrap();
plan.revert(Flags::default()).unwrap();
assert!(!provenance::Provenance::load(tmp.path()).contains("out.txt"));
assert!(
!tmp.path().join(provenance::MANIFEST_PATH).exists(),
"an emptied manifest is removed, not left as a stub"
);
}
#[test]
fn a_skipped_create_if_absent_is_not_recorded_as_ours() {
let (tmp, mut plan) = fixture();
let shared = tmp.path().join("templates/_layout.html");
fs::create_dir_all(shared.parent().unwrap()).unwrap();
fs::write(&shared, "<html>hand-written</html>\n").unwrap();
plan.create_if_absent(shared, "<html>generated</html>\n");
plan.execute(Flags::default()).unwrap();
assert!(
!provenance::Provenance::load(tmp.path()).contains("templates/_layout.html"),
"generate skipped the write, so it owns nothing"
);
}
#[test]
fn create_if_absent_matching_provenance_is_removed_after_a_template_change() {
let (tmp, mut plan) = fixture();
let shared = tmp.path().join("templates/_layout.html");
plan.create_if_absent(shared.clone(), "<html>v1</html>\n");
plan.execute(Flags::default()).unwrap();
let mut newer = Plan::new(tmp.path());
newer.create_if_absent(shared.clone(), "<html>v2</html>\n");
newer.revert(Flags::default()).unwrap();
assert!(!shared.exists());
}
#[test]
fn create_bytes_matching_provenance_is_removed_after_a_template_change() {
let (tmp, mut plan) = fixture();
let asset = tmp.path().join("static/vendor.js");
plan.create_bytes(asset.clone(), b"v1".to_vec());
plan.execute(Flags::default()).unwrap();
let mut newer = Plan::new(tmp.path());
newer.create_bytes(asset.clone(), b"v2".to_vec());
newer.revert(Flags::default()).unwrap();
assert!(!asset.exists());
}
#[test]
fn a_crlf_checkout_of_a_generated_file_is_not_divergence() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("src/models/post.rs");
plan.create(target.clone(), "line one\nline two\n");
plan.execute(Flags::default()).unwrap();
fs::write(&target, "line one\r\nline two\r\n").unwrap();
plan.revert(Flags::default()).unwrap();
assert!(!target.exists(), "core.autocrlf is not a user edit");
}
#[test]
fn a_project_without_provenance_still_refuses_a_changed_template() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("src/routes/auth.rs");
plan.create(target.clone(), "// v1 template\n");
plan.execute(Flags::default()).unwrap();
fs::remove_file(tmp.path().join(provenance::MANIFEST_PATH)).unwrap();
let newer = newer_template_plan(&tmp, &target, "// v2 template\n");
let err = newer.revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(target.exists());
}
#[test]
fn revert_removes_a_migration_whose_template_changed_since_generation() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
let dir = tmp.path().join("migrations/20260101000000_create_posts");
plan.create(dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(dir.join("down.sql"), "DROP TABLE posts;\n");
plan.execute(Flags::default()).unwrap();
let mut newer = Plan::new(tmp.path());
let fresh = tmp.path().join("migrations/99999999999999_create_posts");
newer.create(fresh.join("up.sql"), "CREATE TABLE posts (id BIGINT);\n");
newer.create(fresh.join("down.sql"), "DROP TABLE posts;\n");
newer.revert(Flags::default()).unwrap();
assert!(!dir.exists());
});
}
#[test]
fn a_hand_edited_migration_whose_template_changed_is_still_refused() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
let dir = tmp.path().join("migrations/20260101000000_create_posts");
plan.create(dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(dir.join("down.sql"), "DROP TABLE posts;\n");
plan.execute(Flags::default()).unwrap();
fs::write(dir.join("up.sql"), "CREATE TABLE posts (mine INT);\n").unwrap();
let mut newer = Plan::new(tmp.path());
let fresh = tmp.path().join("migrations/99999999999999_create_posts");
newer.create(fresh.join("up.sql"), "CREATE TABLE posts (id BIGINT);\n");
newer.create(fresh.join("down.sql"), "DROP TABLE posts;\n");
let err = newer.revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(dir.exists());
});
}
#[test]
fn regenerating_after_a_template_change_refreshes_the_recorded_digest() {
let (tmp, mut plan) = fixture();
let target = tmp.path().join("src/routes/auth.rs");
plan.create(target.clone(), "// v1 template\n");
plan.execute(Flags::default()).unwrap();
let mut newer = Plan::new(tmp.path());
newer.create(target.clone(), "// v2 template\n");
newer
.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
newer.revert(Flags::default()).unwrap();
assert!(!target.exists());
}
#[test]
fn provenance_accumulates_across_generator_runs() {
let (tmp, mut first) = fixture();
first.invocation = "model\u{1f}Post".to_owned();
first.create(tmp.path().join("src/models/post.rs"), "// post\n");
first.execute(Flags::default()).unwrap();
let mut second = Plan::new(tmp.path());
second.invocation = "model\u{1f}Comment".to_owned();
second.create(tmp.path().join("src/models/comment.rs"), "// comment\n");
second.execute(Flags::default()).unwrap();
let recorded = provenance::Provenance::load(tmp.path());
assert!(
recorded.contains("src/models/post.rs"),
"a second generator must not drop the first's baseline"
);
assert!(recorded.contains("src/models/comment.rs"));
}
#[test]
fn destroying_one_resource_keeps_another_resources_provenance() {
let (tmp, mut first) = fixture();
let post = tmp.path().join("src/models/post.rs");
first.invocation = "model\u{1f}Post".to_owned();
first.create(post.clone(), "// post\n");
first.execute(Flags::default()).unwrap();
let mut second = Plan::new(tmp.path());
let comment = tmp.path().join("src/models/comment.rs");
second.invocation = "model\u{1f}Comment".to_owned();
second.create(comment.clone(), "// comment\n");
second.execute(Flags::default()).unwrap();
first.revert(Flags::default()).unwrap();
assert!(!post.exists());
assert!(comment.exists(), "a sibling resource survives");
let recorded = provenance::Provenance::load(tmp.path());
assert!(!recorded.contains("src/models/post.rs"));
assert!(recorded.contains("src/models/comment.rs"));
}
#[test]
fn the_same_command_owns_the_path_it_recorded() {
let (tmp, mut generated) = fixture();
let path = tmp.path().join("src/controllers/post.rs");
generated.invocation = "controller\u{1f}Post\u{1f}index".to_owned();
generated.create(path.clone(), "// v1 template\n");
generated.execute(Flags::default()).unwrap();
let mut destroy = Plan::new(tmp.path());
destroy.invocation = "controller\u{1f}Post\u{1f}index".to_owned();
destroy.create(path.clone(), "// v2 template\n");
destroy.revert(Flags::default()).unwrap();
assert!(!path.exists());
}
#[test]
fn a_different_command_never_owns_a_recorded_path() {
let (tmp, mut generated) = fixture();
let path = tmp.path().join("src/models/post.rs");
generated.invocation = "model\u{1f}Post\u{1f}title:String".to_owned();
generated.create(path.clone(), "// v1 template\n");
generated.execute(Flags::default()).unwrap();
let mut destroy = Plan::new(tmp.path());
destroy.invocation = "model\u{1f}Post".to_owned();
destroy.create(path.clone(), "// v2 template\n");
let err = destroy.revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(path.exists());
}
#[test]
fn a_starter_scaffold_never_owns_a_generators_output() {
let (tmp, mut starter) = fixture();
let routes = tmp.path().join("src/routes/auth.rs");
starter.invocation = "new\u{1f}--starter\u{1f}saas\u{1f}app".to_owned();
starter.create(routes.clone(), "// the starter's own auth routes\n");
starter.execute(Flags::default()).unwrap();
let mut destroy = Plan::new(tmp.path());
destroy.invocation = "auth\u{1f}User".to_owned();
destroy.create(routes.clone(), "// what generate auth renders\n");
let err = destroy.revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(
routes.exists(),
"a starter file is never a generator's to delete"
);
}
#[test]
fn execute_records_provenance_even_when_a_later_action_fails() {
let (tmp, mut plan) = fixture();
let good = tmp.path().join("src/models/post.rs");
let blocked = tmp.path().join("src/models/blocked.rs");
fs::create_dir_all(&blocked).unwrap();
plan.create(good.clone(), "// post\n");
plan.create(blocked, "// never lands\n");
plan.execute(Flags {
dry_run: false,
force: true,
})
.unwrap_err();
assert!(good.exists());
assert!(
provenance::Provenance::load(tmp.path()).contains("src/models/post.rs"),
"a file already written is still ours"
);
}
#[test]
fn revert_prunes_provenance_for_what_it_removed_before_failing() {
let (tmp, mut plan) = fixture();
let removable = tmp.path().join("a.rs");
let blocked = tmp.path().join("z.rs");
plan.create(removable.clone(), "// a\n");
plan.create(blocked.clone(), "// z\n");
plan.execute(Flags::default()).unwrap();
fs::remove_file(&blocked).unwrap();
fs::create_dir_all(&blocked).unwrap();
plan.revert(Flags {
dry_run: false,
force: true,
})
.unwrap_err();
assert!(!removable.exists());
assert!(
!provenance::Provenance::load(tmp.path()).contains("a.rs"),
"an entry must never outlive the file it describes"
);
}
#[test]
fn destroying_a_migration_prunes_its_provenance_entries() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
let dir = tmp.path().join("migrations/20260101000000_create_posts");
plan.create(dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(dir.join("down.sql"), "DROP TABLE posts;\n");
plan.execute(Flags::default()).unwrap();
assert!(
provenance::Provenance::load(tmp.path())
.contains("migrations/20260101000000_create_posts/up.sql")
);
let mut newer = Plan::new(tmp.path());
let fresh = tmp.path().join("migrations/99999999999999_create_posts");
newer.create(fresh.join("up.sql"), "CREATE TABLE posts ();\n");
newer.create(fresh.join("down.sql"), "DROP TABLE posts;\n");
newer.revert(Flags::default()).unwrap();
let recorded = provenance::Provenance::load(tmp.path());
assert!(!recorded.contains("migrations/20260101000000_create_posts/up.sql"));
assert!(!recorded.contains("migrations/20260101000000_create_posts/down.sql"));
});
}
#[test]
fn same_suffix_migrations_are_disambiguated_by_the_current_render_alone() {
no_db_env(|| {
let (tmp, mut older) = fixture();
let older_dir = tmp.path().join("migrations/20260101000000_create_posts");
older.create(older_dir.join("up.sql"), "CREATE TABLE posts (old INT);\n");
older.create(older_dir.join("down.sql"), "DROP TABLE posts;\n");
older.execute(Flags::default()).unwrap();
let mut current = Plan::new(tmp.path());
let current_dir = tmp.path().join("migrations/20260202000000_create_posts");
current.create(current_dir.join("up.sql"), "CREATE TABLE posts ();\n");
current.create(current_dir.join("down.sql"), "DROP TABLE posts;\n");
current.execute(Flags::default()).unwrap();
let mut destroy = Plan::new(tmp.path());
let fresh = tmp.path().join("migrations/99999999999999_create_posts");
destroy.create(fresh.join("up.sql"), "CREATE TABLE posts ();\n");
destroy.create(fresh.join("down.sql"), "DROP TABLE posts;\n");
destroy.revert(Flags::default()).unwrap();
assert!(!current_dir.exists(), "the matching directory is removed");
assert!(older_dir.exists(), "the other one is never guessed at");
});
}
#[test]
fn a_crlf_rewritten_binary_asset_is_still_divergence() {
let (tmp, mut plan) = fixture();
let asset = tmp.path().join("static/vendor.bin");
plan.create_bytes(asset.clone(), b"a\nb".to_vec());
plan.execute(Flags::default()).unwrap();
fs::write(&asset, b"a\r\nb").unwrap();
let err = plan.revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(asset.exists(), "bytes are never LF-normalised");
}
#[test]
fn a_modify_only_plan_records_nothing() {
let (tmp, mut plan) = fixture();
plan.modify(tmp.path().join("src/main.rs"), "fn main() {}\n");
plan.execute(Flags::default()).unwrap();
assert!(!tmp.path().join(provenance::MANIFEST_PATH).exists());
}
#[cfg(unix)]
#[test]
fn a_generator_run_still_writes_its_files_when_provenance_cannot_be_recorded() {
let (tmp, mut plan) = fixture();
let outside = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".autumn")).unwrap();
std::os::unix::fs::symlink(
outside.path().join("escaped.toml"),
tmp.path().join(provenance::MANIFEST_PATH),
)
.unwrap();
let target = tmp.path().join("out.txt");
plan.create(target.clone(), "hello");
plan.execute(Flags::default())
.expect("recording is best effort, never fatal to a generator run");
assert!(target.exists());
assert!(!outside.path().join("escaped.toml").exists());
}
#[test]
fn editing_the_generator_config_drops_the_baseline() {
let tmp = tempfile::TempDir::new().unwrap();
let config = tmp.path().join("autumn.generate.toml");
fs::write(&config, "[scaffold.Post]\nfields = [\"title:String\"]\n").unwrap();
let mut generated = Plan::new(tmp.path());
let target = tmp.path().join("src/models/post.rs");
generated.create(target.clone(), "// built from the first recipe\n");
generated.execute(Flags::default()).unwrap();
fs::write(&config, "[scaffold.Post]\nfields = [\"body:Text\"]\n").unwrap();
let mut destroy = Plan::new(tmp.path());
destroy.create(target.clone(), "// built from the second recipe\n");
let err = destroy.revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(target.exists());
}
#[test]
fn an_unchanged_generator_config_keeps_the_baseline() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(
tmp.path().join("autumn.generate.toml"),
"[scaffold.Post]\nfields = [\"title:String\"]\n",
)
.unwrap();
let mut generated = Plan::new(tmp.path());
let target = tmp.path().join("src/models/post.rs");
generated.create(target.clone(), "// v1 template\n");
generated.execute(Flags::default()).unwrap();
let mut destroy = Plan::new(tmp.path());
destroy.create(target.clone(), "// v2 template\n");
destroy.revert(Flags::default()).unwrap();
assert!(!target.exists());
}
#[test]
fn the_last_consumer_of_a_shared_file_can_still_remove_it() {
let (tmp, mut first) = fixture();
let shared = tmp.path().join("templates/mailers/_layout.html");
first.invocation = "mailer\u{1f}Welcome".to_owned();
first.create_if_absent(shared.clone(), "<html>v1</html>\n");
first.execute(Flags::default()).unwrap();
let mut second = Plan::new(tmp.path());
second.invocation = "mailer\u{1f}Receipt".to_owned();
second.create_if_absent(shared.clone(), "<html>v2</html>\n");
second.execute(Flags::default()).unwrap();
let mut destroy = Plan::new(tmp.path());
destroy.invocation = "mailer\u{1f}Receipt".to_owned();
destroy.create_if_absent(shared.clone(), "<html>v2</html>\n");
destroy.revert(Flags::default()).unwrap();
assert!(
!shared.exists(),
"the last consumer takes the layout with it"
);
}
#[test]
fn a_shared_file_still_survives_while_a_sibling_needs_it() {
let (tmp, mut first) = fixture();
let shared = tmp.path().join("templates/mailers/_layout.html");
let sibling = tmp.path().join("templates/mailers/receipt.html");
first.invocation = "mailer\u{1f}Welcome".to_owned();
first.create_if_absent(shared.clone(), "<html>v1</html>\n");
first.execute(Flags::default()).unwrap();
fs::write(&sibling, "<html>receipt</html>\n").unwrap();
let mut destroy = Plan::new(tmp.path());
destroy.invocation = "mailer\u{1f}Welcome".to_owned();
destroy.create_if_absent(shared.clone(), "<html>v2</html>\n");
destroy.revert(Flags::default()).unwrap();
assert!(shared.exists(), "a sibling still renders through it");
}
#[test]
fn a_hand_written_shared_file_is_still_never_removed() {
let (tmp, mut plan) = fixture();
let shared = tmp.path().join("templates/mailers/_layout.html");
fs::create_dir_all(shared.parent().unwrap()).unwrap();
fs::write(&shared, "<html>hand-written</html>\n").unwrap();
plan.create_if_absent(shared.clone(), "<html>generated</html>\n");
plan.execute(Flags::default()).unwrap();
plan.revert(Flags::default()).unwrap();
assert!(
shared.exists(),
"content Autumn never wrote is never deleted"
);
}
#[test]
fn revert_applies_mod_decl_revert_and_deletes_now_empty_file() {
let (tmp, mut plan) = fixture();
let mod_path = tmp.path().join("mod.rs");
plan.modify(
mod_path.clone(),
crate::generate::schema_edit::add_mod_declaration("", "post"),
);
plan.push_revert(Revert::ModDecl {
path: mod_path.clone(),
name: "post".to_owned(),
});
plan.execute(Flags::default()).unwrap();
assert!(mod_path.exists());
plan.revert(Flags::default()).unwrap();
assert!(!mod_path.exists());
}
#[test]
fn revert_applies_mod_decl_revert_preserving_other_mods() {
let (tmp, mut plan) = fixture();
let mod_path = tmp.path().join("mod.rs");
fs::write(&mod_path, "pub mod user;\n").unwrap();
plan.modify(
mod_path.clone(),
crate::generate::schema_edit::add_mod_declaration("pub mod user;\n", "post"),
);
plan.push_revert(Revert::ModDecl {
path: mod_path.clone(),
name: "post".to_owned(),
});
plan.execute(Flags::default()).unwrap();
plan.revert(Flags::default()).unwrap();
assert!(mod_path.exists());
assert_eq!(fs::read_to_string(&mod_path).unwrap(), "pub mod user;\n");
}
#[test]
fn revert_prunes_now_empty_generated_directory_but_not_sibling_content() {
let (tmp, mut plan) = fixture();
let posts_tmpl = tmp.path().join("templates/posts/index.html.tmpl");
plan.create(posts_tmpl.clone(), "content");
plan.execute(Flags::default()).unwrap();
fs::create_dir_all(tmp.path().join("templates/other")).unwrap();
fs::write(tmp.path().join("templates/other/keep.tmpl"), "keep").unwrap();
plan.revert(Flags::default()).unwrap();
assert!(!posts_tmpl.exists());
assert!(!tmp.path().join("templates/posts").exists());
assert!(tmp.path().join("templates").exists());
assert!(tmp.path().join("templates/other/keep.tmpl").exists());
}
#[test]
fn revert_never_deletes_a_diverged_create_if_absent_file_even_with_force() {
let (tmp, mut plan) = fixture();
let layout = tmp.path().join("templates/mailers/_layout.html");
fs::create_dir_all(layout.parent().unwrap()).unwrap();
fs::write(&layout, "hand-rolled layout, predates any mailer").unwrap();
plan.create_if_absent(layout.clone(), "generated default layout");
plan.revert(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert!(
layout.exists(),
"pre-existing content diverging from the generated default must survive \
even with --force"
);
assert_eq!(
fs::read_to_string(&layout).unwrap(),
"hand-rolled layout, predates any mailer"
);
}
#[test]
fn revert_deletes_a_create_if_absent_file_when_content_matches_the_generated_default() {
let (tmp, mut plan) = fixture();
let layout = tmp.path().join("templates/mailers/_layout.html");
fs::create_dir_all(layout.parent().unwrap()).unwrap();
plan.create_if_absent(layout.clone(), "generated default layout");
plan.execute(Flags::default()).unwrap();
plan.revert(Flags::default()).unwrap();
assert!(!layout.exists());
}
#[test]
fn revert_keeps_a_dependency_still_needed_by_another_table_in_the_same_schema_file() {
use crate::generate::dsl::parse_fields;
use crate::generate::schema_edit::append_schema_table;
let (tmp, mut plan) = fixture();
let fields = parse_fields(&["title:String".to_owned()]).unwrap();
let posts_block = append_schema_table("", "posts", &fields);
let schema_path = tmp.path().join("src/schema.rs");
fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
let schema_with_users = append_schema_table("", "users", &fields);
let schema_content = append_schema_table(&schema_with_users, "posts", &fields);
fs::write(&schema_path, &schema_content).unwrap();
let cargo_path = tmp.path().join("Cargo.toml");
fs::write(
&cargo_path,
"[package]\nname = \"x\"\n\n[dependencies]\ndiesel = \"2\"\n",
)
.unwrap();
plan.push_revert(Revert::SchemaTable {
path: schema_path.clone(),
table: "posts".to_owned(),
expected_block: posts_block,
});
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
plan.push_revert(Revert::CargoDeps {
path: cargo_path.clone(),
names: vec!["diesel".to_owned()],
owner_dir: models_dir,
});
plan.revert(Flags::default()).unwrap();
let schema_after = fs::read_to_string(&schema_path).unwrap();
assert!(
!schema_after.contains("posts ("),
"posts table must be removed"
);
assert!(
schema_after.contains("users ("),
"the other table must survive: {schema_after}"
);
let cargo_after = fs::read_to_string(&cargo_path).unwrap();
assert!(
cargo_after.contains("diesel"),
"diesel must survive — schema.rs still has a diesel::table! for `users`: \
{cargo_after}"
);
}
#[test]
fn revert_keeps_ws_feature_when_a_live_scaffold_route_still_uses_sse_stream() {
let (tmp, mut plan) = fixture();
let routes_dir = tmp.path().join("src/routes");
fs::create_dir_all(&routes_dir).unwrap();
fs::write(
routes_dir.join("posts.rs"),
"pub async fn stream_posts() {\n autumn_web::sse::stream(&state, \"posts\")\n}\n",
)
.unwrap();
let cargo_path = tmp.path().join("Cargo.toml");
fs::write(
&cargo_path,
"[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = { version = \"0.6\", features = [\"ws\"] }\n",
)
.unwrap();
let channels_dir = tmp.path().join("src/channels");
fs::create_dir_all(&channels_dir).unwrap();
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path.clone(),
feature: "ws".to_owned(),
owner_dir: Some(channels_dir),
});
plan.revert(Flags::default()).unwrap();
let cargo_after = fs::read_to_string(&cargo_path).unwrap();
assert!(
cargo_after.contains("\"ws\""),
"ws must survive — a live scaffold route still uses autumn_web::sse::stream: \
{cargo_after}"
);
}
#[test]
fn revert_removes_unapplied_migration_matched_by_suffix() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
let plan_dir = tmp.path().join("migrations/99999999999999_create_posts");
let real_dir = tmp.path().join("migrations/20260101000000_create_posts");
fs::create_dir_all(&real_dir).unwrap();
fs::write(real_dir.join("up.sql"), "CREATE TABLE posts ();\n").unwrap();
fs::write(real_dir.join("down.sql"), "DROP TABLE posts;\n").unwrap();
plan.create(plan_dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(plan_dir.join("down.sql"), "DROP TABLE posts;\n");
plan.revert(Flags::default()).unwrap();
assert!(!real_dir.exists());
});
}
#[test]
fn revert_refuses_diverged_migration_sql_without_force() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
let plan_dir = tmp.path().join("migrations/99999999999999_create_posts");
let real_dir = tmp.path().join("migrations/20260101000000_create_posts");
fs::create_dir_all(&real_dir).unwrap();
fs::write(
real_dir.join("up.sql"),
"CREATE TABLE posts (extra_col INT);\n",
)
.unwrap();
fs::write(real_dir.join("down.sql"), "DROP TABLE posts;\n").unwrap();
plan.create(plan_dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(plan_dir.join("down.sql"), "DROP TABLE posts;\n");
let err = plan.revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(real_dir.exists());
});
}
#[test]
fn revert_never_removes_a_migration_with_an_unreachable_database_even_with_force() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
(
"AUTUMN_DATABASE__URL",
Some("postgres://postgres:x@127.0.0.1:1/nope"),
),
("DATABASE_URL", None::<&str>),
],
|| {
let (tmp, mut plan) = fixture();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
let plan_dir = tmp.path().join("migrations/99999999999999_create_posts");
let real_dir = tmp.path().join("migrations/20260101000000_create_posts");
fs::create_dir_all(&real_dir).unwrap();
fs::write(real_dir.join("up.sql"), "CREATE TABLE posts ();\n").unwrap();
fs::write(real_dir.join("down.sql"), "DROP TABLE posts;\n").unwrap();
plan.create(plan_dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(plan_dir.join("down.sql"), "DROP TABLE posts;\n");
plan.revert(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert!(
real_dir.exists(),
"migration must survive even with --force when the database is unreachable"
);
},
);
}
#[test]
fn revert_never_removes_a_migration_when_only_an_unreachable_shard_is_configured() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
("AUTUMN_DATABASE__SHARDS__0__NAME", Some("shard0")),
(
"AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
Some("postgres://postgres:x@127.0.0.1:1/nope"),
),
],
|| {
let (tmp, mut plan) = fixture();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
let plan_dir = tmp.path().join("migrations/99999999999999_create_posts");
let real_dir = tmp.path().join("migrations/20260101000000_create_posts");
fs::create_dir_all(&real_dir).unwrap();
fs::write(real_dir.join("up.sql"), "CREATE TABLE posts ();\n").unwrap();
fs::write(real_dir.join("down.sql"), "DROP TABLE posts;\n").unwrap();
plan.create(plan_dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(plan_dir.join("down.sql"), "DROP TABLE posts;\n");
plan.revert(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert!(
real_dir.exists(),
"migration must survive when a shard is configured but unreachable, \
even with no control database URL and even with --force"
);
},
);
}
#[test]
fn revert_is_idempotent_when_migration_dir_missing() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
let plan_dir = tmp.path().join("migrations/99999999999999_create_posts");
plan.create(plan_dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(plan_dir.join("down.sql"), "DROP TABLE posts;\n");
plan.revert(Flags::default()).unwrap();
});
}
#[test]
fn revert_refuses_to_guess_between_two_migrations_sharing_a_suffix() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
let plan_dir = tmp.path().join("migrations/99999999999999_create_posts");
let first = tmp.path().join("migrations/20260101000000_create_posts");
let second = tmp.path().join("migrations/20260201000000_create_posts");
fs::create_dir_all(&first).unwrap();
fs::write(first.join("up.sql"), "CREATE TABLE posts ();\n").unwrap();
fs::write(first.join("down.sql"), "DROP TABLE posts;\n").unwrap();
fs::create_dir_all(&second).unwrap();
fs::write(second.join("up.sql"), "CREATE TABLE posts ();\n").unwrap();
fs::write(second.join("down.sql"), "DROP TABLE posts;\n").unwrap();
plan.create(plan_dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(plan_dir.join("down.sql"), "DROP TABLE posts;\n");
plan.revert(Flags::default()).unwrap();
assert!(
first.exists(),
"ambiguous suffix must not delete either dir"
);
assert!(
second.exists(),
"ambiguous suffix must not delete either dir"
);
});
}
#[test]
fn revert_disambiguates_shared_suffix_migrations_by_exact_content_match() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
let plan_dir = tmp.path().join("migrations/99999999999999_create_posts");
let matching = tmp.path().join("migrations/20260101000000_create_posts");
let other = tmp.path().join("migrations/20260201000000_create_posts");
fs::create_dir_all(&matching).unwrap();
fs::write(matching.join("up.sql"), "CREATE TABLE posts ();\n").unwrap();
fs::write(matching.join("down.sql"), "DROP TABLE posts;\n").unwrap();
fs::create_dir_all(&other).unwrap();
fs::write(other.join("up.sql"), "CREATE TABLE posts (extra INT);\n").unwrap();
fs::write(other.join("down.sql"), "DROP TABLE posts;\n").unwrap();
plan.create(plan_dir.join("up.sql"), "CREATE TABLE posts ();\n");
plan.create(plan_dir.join("down.sql"), "DROP TABLE posts;\n");
plan.revert(Flags::default()).unwrap();
assert!(
!matching.exists(),
"the content-matching directory must be removed"
);
assert!(
other.exists(),
"the non-matching directory must be left alone"
);
});
}
#[test]
fn revert_prunes_now_empty_directory_after_shared_submodule_deletion() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/main.rs"),
"use autumn_web::prelude::*;\n\n#[autumn_web::main]\nasync fn main() {\n \
autumn_web::app().routes(routes![]).run().await;\n}\n",
)
.unwrap();
let plan =
crate::generate::mailer::plan_mailer(tmp.path(), "Welcome", None, false).unwrap();
plan.execute(Flags::default()).unwrap();
assert!(tmp.path().join("src/mailers/mod.rs").exists());
plan.revert(Flags::default()).unwrap();
assert!(
!tmp.path().join("src/mailers").exists(),
"src/mailers/ must be fully pruned once mod.rs and every file under it are gone, \
just like every other now-empty generated directory"
);
}
#[test]
fn multipart_and_storage_have_feature_markers() {
let multipart = autumn_web_feature_markers("multipart");
assert!(
multipart.contains(&"Multipart"),
"multipart marker must catch bare `Multipart` (covers prelude-unqualified \
and `autumn_web::extract::Multipart` usage), got {multipart:?}"
);
let storage = autumn_web_feature_markers("storage");
assert!(
storage.contains(&"autumn_web::storage::"),
"storage marker must catch `autumn_web::storage::` usage, got {storage:?}"
);
}
#[test]
fn multipart_and_storage_markers_retain_features_for_handwritten_routes() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("src/routes")).unwrap();
let handwritten = tmp.path().join("src/routes/manual.rs");
fs::write(
&handwritten,
"use autumn_web::prelude::*;\n\n\
async fn upload(mut mp: Multipart, store: autumn_web::storage::BlobStoreState) {}\n",
)
.unwrap();
let excluding = vec![tmp.path().join("src/models/photo.rs")];
let overrides = HashMap::new();
assert!(
autumn_web_feature_still_needed_elsewhere(
"multipart",
tmp.path(),
&excluding,
&overrides
),
"multipart must be retained while a hand-written route uses the Multipart extractor"
);
assert!(
autumn_web_feature_still_needed_elsewhere(
"storage",
tmp.path(),
&excluding,
&overrides
),
"storage must be retained while a hand-written route references autumn_web::storage::"
);
fs::remove_file(&handwritten).unwrap();
assert!(!autumn_web_feature_still_needed_elsewhere(
"multipart",
tmp.path(),
&excluding,
&overrides
));
assert!(!autumn_web_feature_still_needed_elsewhere(
"storage",
tmp.path(),
&excluding,
&overrides
));
}
#[test]
fn crate_reference_markers_cover_every_spelling_that_names_a_crate() {
let markers = crate_reference_markers("autumn-admin-plugin");
let matches = |src: &str| markers.iter().any(|m| src.contains(m.as_str()));
assert!(matches("let p = autumn_admin_plugin::AdminPlugin::new();"));
assert!(matches("use autumn_admin_plugin::AdminPlugin;"));
assert!(matches("use autumn_admin_plugin as admin;"));
assert!(matches("extern crate autumn_admin_plugin as admin;"));
assert!(matches("extern crate autumn_admin_plugin;"));
assert!(!matches("let x = 1;"));
assert!(!matches("use some_other_crate::Thing;"));
assert!(matches("use autumn_admin_plugin_extras::Thing;"));
}
}