use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use autumn_web::config::DatabaseBackend;
use super::{Flags, GenerateError, provenance, sweep};
#[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,
}
}
#[must_use]
pub(super) fn anchors(&self, root: &Path) -> Vec<provenance::RevertAnchor> {
let rel =
|p: &Path| provenance::key(root, p).unwrap_or_else(|| p.to_string_lossy().into_owned());
let owner = |dir: Option<&Path>| dir.map_or_else(String::new, rel);
let each = |kind: &str, items: &[String], tail: String| -> Vec<String> {
items
.iter()
.map(|item| format!("{kind}({item}{tail})"))
.collect()
};
let reverts: Vec<String> = match self {
Self::ModDecl { name, .. } => vec![format!("ModDecl({name})")],
Self::RoutesEntries { entries, .. } => each("RoutesEntries", entries, String::new()),
Self::SchemaTable { table, .. } => vec![format!("SchemaTable({table})")],
Self::CargoDeps {
names, owner_dir, ..
} => each("CargoDeps", names, format!("; {}", rel(owner_dir))),
Self::CargoAutumnWebFeature {
feature, owner_dir, ..
} => vec![format!(
"CargoAutumnWebFeature({feature}; {})",
owner(owner_dir.as_deref())
)],
Self::CargoAutumnWebDevFeature {
feature, owner_dir, ..
} => vec![format!(
"CargoAutumnWebDevFeature({feature}; {})",
owner(owner_dir.as_deref())
)],
Self::JobEntry { entry, .. } => vec![format!("JobEntry({entry})")],
Self::JobsRegistration { owner_dir, .. } => {
vec![format!("JobsRegistration({})", rel(owner_dir))]
}
Self::PolicyRegistration { pascal, snake, .. } => {
vec![format!("PolicyRegistration({pascal}, {snake})")]
}
Self::MailPreview { mailer_type, .. } => vec![format!("MailPreview({mailer_type})")],
Self::InboundMailHandler {
handler_module_path,
..
} => vec![format!("InboundMailHandler({handler_module_path})")],
Self::SystemTestCargoPatch { snake_name, .. } => {
vec![format!("SystemTestCargoPatch({snake_name})")]
}
Self::PwaMainRsInjection { .. } => vec!["PwaMainRsInjection".to_owned()],
Self::AuthOAuthProviderStubs { providers, .. } => {
each("AuthOAuthProviderStubs", providers, String::new())
}
Self::AuthWebauthnStub { .. } => vec!["AuthWebauthnStub".to_owned()],
Self::WebhookEndpointStub {
name, route_path, ..
} => vec![format!("WebhookEndpointStub({name}, {route_path})")],
Self::SubmitTokenValidateExempt {
plural, segment, ..
} => vec![format!("SubmitTokenValidateExempt({plural}, {segment})")],
Self::RememberMiddleware { .. } => vec!["RememberMiddleware".to_owned()],
Self::SeedBinLinks { owner_dir, .. } => {
vec![format!("SeedBinLinks({})", rel(owner_dir))]
}
Self::NestedChildSection { child_plural, .. } => {
vec![format!("NestedChildSection({child_plural})")]
}
Self::I18nFtlKeys { pascal, snake, .. } => {
vec![format!("I18nFtlKeys({pascal}, {snake})")]
}
};
let path = rel(self.path());
reverts
.into_iter()
.map(|revert| provenance::RevertAnchor {
path: path.clone(),
revert,
})
.collect()
}
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>,
released_features: Vec<ReleasedFeature>,
sole_resource: bool,
}
#[derive(Debug)]
struct ReleasedFeature {
path: PathBuf,
feature: String,
owner: Option<(PathBuf, PathBuf)>,
}
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(),
released_features: Vec::new(),
sole_resource: false,
}
}
pub fn claim_sole_resource(&mut self) -> bool {
static CLAIMED: AtomicBool = AtomicBool::new(false);
self.claim_with(&CLAIMED)
}
fn claim_with(&mut self, claimed: &AtomicBool) -> bool {
self.sole_resource = !claimed.swap(true, Ordering::SeqCst);
self.sole_resource
}
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(super) fn release_feature(
&mut self,
path: PathBuf,
feature: &str,
owner: Option<(PathBuf, PathBuf)>,
) {
self.released_features.push(ReleasedFeature {
path,
feature: feature.to_owned(),
owner,
});
}
pub(super) fn settle_released_features(&mut self) {
use super::schema_edit::remove_autumn_web_feature;
let released = std::mem::take(&mut self.released_features);
if released.is_empty() {
return;
}
let pending = pending_contents(self);
for r in released {
let base = self
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &r.path => Some(contents.clone()),
_ => None,
})
.unwrap_or_else(|| fs::read_to_string(&r.path).unwrap_or_default());
let markers = release_markers(&r.feature);
let by_marker = markers
.as_deref()
.is_some_and(|m| markers_in_project(m, &self.project_root, &[], &pending));
let by_sibling = r.owner.as_ref().is_some_and(|(dir, own)| {
resource_dir_has_other_files(dir, std::slice::from_ref(own))
});
let mut used_markers = markers.clone().unwrap_or_default();
if r.feature == "multipart" {
used_markers.extend(release_markers("storage").unwrap_or_default());
}
let was_used = Some(used_markers.as_slice())
.filter(|m| !m.is_empty())
.is_some_and(|m| {
self.actions.iter().any(|a| {
a.path() != r.path
&& fs::read_to_string(a.path()).is_ok_and(|old| {
m.iter().any(|marker| old.contains(marker.as_str()))
})
})
});
let defaults_off = r.feature == "htmx" && base_disables_defaults(&base);
let configured = r.feature == "storage" && storage_is_configured(&self.project_root);
let needed = !was_used
|| by_marker
|| by_sibling
|| defaults_off
|| configured
|| (markers.is_none() && r.owner.is_none());
if needed {
continue;
}
let updated = remove_autumn_web_feature(&base, &r.feature);
if updated != base {
self.actions.retain(|a| a.path() != r.path);
self.modify(r.path, updated);
}
}
}
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<Written> = Vec::new();
let result = self.write_actions(&mut written);
self.record_provenance(written);
result
}
fn write_actions(&self, written: &mut Vec<Written>) -> 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),
provenance::Scope::Owned,
));
}
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),
provenance::Scope::Owned,
));
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),
provenance::Scope::Shared,
));
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<Written>) {
if written.is_empty() {
return;
}
let mut recorded = provenance::Provenance::load(&self.project_root);
for (path, digest, scope) in written {
let scope = self.sole_resource.then_some(scope);
recorded.record(&self.project_root, &path, digest, &self.invocation, scope);
}
if self.sole_resource {
recorded.record_reverts(&self.invocation, self.revert_anchors());
}
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 revert_anchors(&self) -> Vec<provenance::RevertAnchor> {
let mut anchors: Vec<_> = self
.reverts
.iter()
.flat_map(|r| r.anchors(&self.project_root))
.filter(provenance::RevertAnchor::is_project_relative)
.collect();
anchors.sort();
anchors.dedup();
anchors
}
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),
plan.sweep_note(path)
);
}
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)
);
}
}
if !plan.dropped_reverts.is_empty() {
println!(
" A real run refuses without --force. It would leave these edits behind:"
);
for anchor in &plan.dropped_reverts {
println!(" {anchor}");
}
}
for warning in &plan.warnings {
eprintln!("Warning: {warning}");
}
return Ok(());
}
if !plan.diverged.is_empty() && !flags.force {
return Err(GenerateError::Diverged(plan.diverged));
}
if !plan.dropped_reverts.is_empty() {
return Err(GenerateError::RevertsDropped(plan.dropped_reverts));
}
for warning in &plan.warnings {
eprintln!("Warning: {warning}");
}
let mut touched_dirs: Vec<PathBuf> = Vec::new();
let mut removed = Removed {
stale: plan.stale.clone(),
..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.is_ok());
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> {
let changed: Vec<PathBuf> = plan
.swept
.iter()
.filter(|(path, digest)| !sweep::still_sweepable(&self.project_root, path, digest))
.map(|(path, _)| path.clone())
.collect();
if !changed.is_empty() {
return Err(GenerateError::Diverged(changed));
}
for path in &plan.files_to_remove {
let expected = plan.swept.get(path);
let swept = expected.is_some();
if expected
.is_some_and(|digest| !sweep::still_sweepable(&self.project_root, path, digest))
{
return Err(GenerateError::Diverged(vec![path.clone()]));
}
match fs::remove_file(path) {
Err(e) if swept && e.kind() == std::io::ErrorKind::NotFound => continue,
other => other?,
}
removed.files.push(path.clone());
println!(
" Removed {}{}",
relative_display(path, &self.project_root),
plan.sweep_note(path)
);
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, finished: bool) {
let forget_run = finished && self.sole_resource;
if removed.files.is_empty()
&& removed.dirs.is_empty()
&& removed.stale.is_empty()
&& !forget_run
{
return;
}
let mut recorded = provenance::Provenance::load(&self.project_root);
if recorded.is_empty() {
return;
}
if forget_run {
recorded.forget_run(&self.invocation);
}
for path in removed.files.iter().chain(&removed.stale) {
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 sweep = if self.sole_resource {
let revert_targets: Vec<&Path> = self.reverts.iter().map(Revert::path).collect();
sweep::plan_sweep(
&self.project_root,
&recorded,
&self.invocation,
&self.actions,
&revert_targets,
)
} else {
sweep::Sweep::default()
};
let dropped_reverts = if force || !self.sole_resource {
Vec::new()
} else {
recorded.dropped_reverts(&self.project_root, &self.invocation, &self.revert_anchors())
};
let mut files_to_remove = sweep.remove.clone();
let mut diverged = Vec::new();
let mut warnings = sweep.warnings;
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::StillNeededByCommentable(real_dir) => warnings.push(format!(
"migration {} creates the `comments` table that another `#[commentable]` \
model still uses; 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,
dropped_reverts,
warnings,
swept: sweep.digests,
stale: sweep.forget,
}
}
}
type Written = (PathBuf, String, provenance::Scope);
#[derive(Default)]
struct Removed {
files: Vec<PathBuf>,
dirs: Vec<PathBuf>,
stale: Vec<PathBuf>,
}
struct RevertPlan {
files_to_remove: Vec<PathBuf>,
migrations_to_remove: Vec<PathBuf>,
modifies: Vec<(PathBuf, Option<String>)>,
diverged: Vec<PathBuf>,
dropped_reverts: Vec<provenance::RevertAnchor>,
warnings: Vec<String>,
swept: HashMap<PathBuf, String>,
stale: Vec<PathBuf>,
}
impl RevertPlan {
fn sweep_note(&self, path: &Path) -> &'static str {
if self.swept.contains_key(path) {
" (no longer generated)"
} else {
""
}
}
}
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",
"autumn_web::channels",
"Channels",
"Broadcast",
"ChannelMessage",
"ChannelStats",
],
"test-support" => &["TestDb::"],
"multipart" => &["Multipart"],
"storage" => &["autumn_web::storage"],
"markdown" => &["autumn_web::markdown"],
"csv" => &[
"autumn_web::data::csv",
"extract::Csv",
"autumn_web::Csv",
"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();
markers_in_project(&markers, project_root, excluding, overrides)
}
fn storage_is_configured(root: &Path) -> bool {
let Ok(entries) = fs::read_dir(root) else {
return false;
};
entries.filter_map(Result::ok).any(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
let is_toml = entry
.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
let is_config = (name.starts_with("autumn") && is_toml) || name.starts_with(".env");
is_config
&& fs::read_to_string(entry.path())
.is_ok_and(|text| text.to_ascii_lowercase().contains("storage"))
})
}
fn base_disables_defaults(manifest: &str) -> bool {
let is_off = |line: &str| {
let squeezed = line.replace(' ', "");
squeezed.contains("default-features=false") || squeezed.contains("workspace=true")
};
let names_web = |text: &str| text.contains("autumn-web") || text.contains("autumn_web");
let depth = |text: &str| {
i32::try_from(text.matches('{').count()).unwrap_or(0)
- i32::try_from(text.matches('}').count()).unwrap_or(0)
};
let mut in_web_table = false;
let mut open_entry = 0;
for line in manifest.lines() {
let trimmed = line.trim();
if open_entry > 0 {
if is_off(trimmed) {
return true;
}
open_entry += depth(trimmed);
} else if trimmed.starts_with('[') {
in_web_table = names_web(trimmed);
} else if in_web_table || names_web(trimmed) {
if is_off(trimmed) {
return true;
}
if !in_web_table {
open_entry = depth(trimmed).max(0);
}
}
}
false
}
fn markers_in_project(
markers: &[String],
project_root: &Path,
excluding: &[PathBuf],
overrides: &HashMap<PathBuf, String>,
) -> bool {
let named = crate::plugin::install::explicit_target_sources(project_root);
let in_named = named.iter().any(|path| {
let content = overrides.get(path).cloned().or_else(|| {
(!excluding.contains(path))
.then(|| fs::read_to_string(path).ok())
.flatten()
});
content.is_some_and(|text| markers.iter().any(|m| text.contains(m.as_str())))
});
in_named
|| ["src", "tests", "benches", "examples"].iter().any(|dir| {
rs_tree_contains_marker(&project_root.join(dir), markers, excluding, overrides)
})
}
fn release_markers(feature: &str) -> Option<Vec<String>> {
let markers: &[&str] = match feature {
"htmx" => &["HTMX_JS_PATH", "HxRequest"],
other => autumn_web_feature_markers(other),
};
(!markers.is_empty()).then(|| markers.iter().map(|m| (*m).to_owned()).collect())
}
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),
StillNeededByCommentable(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)
}
pub(super) 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
if !force
&& super::commentable::comments_migration_still_needed(
project_root,
&real_dir,
excluding,
) =>
{
MigrationOutcome::StillNeededByCommentable(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());
}
fn i18n_revert(root: &Path, dir: &str) -> Revert {
Revert::I18nFtlKeys {
path: root.join(dir).join("en.ftl"),
pascal: "Post".to_owned(),
snake: "post".to_owned(),
}
}
fn generated_with_bundle(tmp: &tempfile::TempDir) -> PathBuf {
let target = tmp.path().join("src/models/post.rs");
fs::create_dir_all(tmp.path().join("i18n")).unwrap();
fs::write(
tmp.path().join("i18n/en.ftl"),
"# Post\npost.title = Post\n",
)
.unwrap();
let mut generated = Plan::new(tmp.path());
generated.create(target.clone(), "// v1 template\n");
generated.push_revert(i18n_revert(tmp.path(), "i18n"));
sole(generated).execute(Flags::default()).unwrap();
target
}
fn destroy_plan(tmp: &tempfile::TempDir, target: &Path, template: &str, dir: &str) -> Plan {
let mut destroy = Plan::new(tmp.path());
destroy.create(target.to_path_buf(), template);
destroy.push_revert(i18n_revert(tmp.path(), dir));
sole(destroy)
}
#[test]
fn a_revert_that_moved_off_a_live_target_refuses_the_destroy() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
let err = destroy_plan(&tmp, &target, "// v1 template\n", "translations")
.revert(Flags::default())
.unwrap_err();
assert!(
matches!(err, GenerateError::RevertsDropped(ref r) if r.len() == 1),
"{err}"
);
assert!(err.to_string().contains("i18n/en.ftl"), "{err}");
assert!(target.exists(), "nothing is removed");
}
#[test]
fn a_moved_revert_is_refused_after_a_template_change_too() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
let err = destroy_plan(&tmp, &target, "// v2 template\n", "translations")
.revert(Flags::default())
.unwrap_err();
assert!(matches!(err, GenerateError::RevertsDropped(_)));
assert!(target.exists());
}
#[test]
fn force_overrides_a_moved_revert() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
destroy_plan(&tmp, &target, "// v2 template\n", "translations")
.revert(Flags {
force: true,
..Flags::default()
})
.unwrap();
assert!(!target.exists());
}
#[test]
fn a_dry_run_reports_a_moved_revert_and_touches_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
destroy_plan(&tmp, &target, "// v1 template\n", "translations")
.revert(Flags {
dry_run: true,
..Flags::default()
})
.unwrap();
assert!(target.exists());
}
#[test]
fn the_same_reverts_keep_the_baseline_across_a_template_change() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
destroy_plan(&tmp, &target, "// v2 template\n", "i18n")
.revert(Flags::default())
.unwrap();
assert!(!target.exists());
}
#[test]
fn a_revert_planned_in_addition_is_not_a_drop() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
let mut destroy = destroy_plan(&tmp, &target, "// v2 template\n", "i18n");
destroy.push_revert(i18n_revert(tmp.path(), "i18n-extra"));
destroy.revert(Flags::default()).unwrap();
assert!(!target.exists());
}
#[test]
fn a_recorded_revert_whose_target_is_gone_does_not_block() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
fs::remove_file(tmp.path().join("i18n/en.ftl")).unwrap();
destroy_plan(&tmp, &target, "// v2 template\n", "translations")
.revert(Flags::default())
.unwrap();
assert!(!target.exists());
}
#[test]
fn a_manifest_without_recorded_reverts_keeps_the_old_behaviour() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
let manifest = tmp.path().join(provenance::MANIFEST_PATH);
let text = fs::read_to_string(&manifest).unwrap();
let (files, _) = text.split_once("[[runs").expect("a run was recorded");
fs::write(&manifest, files).unwrap();
destroy_plan(&tmp, &target, "// v2 template\n", "translations")
.revert(Flags::default())
.unwrap();
assert!(!target.exists());
}
#[test]
fn an_unclaimed_plan_neither_records_nor_checks_reverts() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("src/models/post.rs");
fs::create_dir_all(tmp.path().join("i18n")).unwrap();
fs::write(tmp.path().join("i18n/en.ftl"), "").unwrap();
let mut generated = Plan::new(tmp.path());
generated.create(target.clone(), "// v1 template\n");
generated.push_revert(i18n_revert(tmp.path(), "i18n"));
generated.execute(Flags::default()).unwrap();
let text = fs::read_to_string(tmp.path().join(provenance::MANIFEST_PATH)).unwrap();
assert!(!text.contains("runs"), "{text}");
let mut destroy = Plan::new(tmp.path());
destroy.create(target.clone(), "// v1 template\n");
destroy.revert(Flags::default()).unwrap();
assert!(!target.exists());
}
#[test]
fn a_destroy_prunes_the_recorded_run_with_its_last_file() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
destroy_plan(&tmp, &target, "// v1 template\n", "i18n")
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join(provenance::MANIFEST_PATH).exists());
}
#[test]
fn a_revert_anchor_ignores_template_text_and_the_project_location() {
let here = tempfile::TempDir::new().unwrap();
let there = tempfile::TempDir::new().unwrap();
let schema = |root: &Path, block: &str| Revert::SchemaTable {
path: root.join("src/schema.rs"),
table: "posts".to_owned(),
expected_block: block.to_owned(),
};
assert_eq!(
schema(here.path(), "v1 columns").anchors(here.path()),
schema(there.path(), "v2 columns").anchors(there.path()),
);
assert_ne!(
i18n_revert(here.path(), "i18n").anchors(here.path()),
i18n_revert(here.path(), "translations").anchors(here.path()),
);
}
#[test]
fn a_list_revert_anchors_each_item_so_a_longer_list_is_a_superset() {
let root = Path::new("/p");
let routes = |entries: &[&str]| Revert::RoutesEntries {
path: root.join("src/main.rs"),
entries: entries.iter().map(|e| (*e).to_owned()).collect(),
};
let short = routes(&["a", "b"]).anchors(root);
let long = routes(&["a", "b", "c"]).anchors(root);
assert_eq!(short.len(), 2);
assert!(short.iter().all(|anchor| long.contains(anchor)));
}
#[test]
fn a_revert_the_plan_skips_is_not_a_drop() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
let mut destroy = Plan::new(tmp.path());
destroy.create(target.clone(), "// v1 template\n");
destroy.push_revert(Revert::PwaMainRsInjection {
path: tmp.path().join("src/main.rs"),
});
sole(destroy).revert(Flags::default()).unwrap();
assert!(!target.exists());
}
#[test]
fn an_unclaimed_destroy_does_not_check_recorded_reverts() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
let mut destroy = Plan::new(tmp.path());
destroy.create(target.clone(), "// v1 template\n");
destroy.push_revert(i18n_revert(tmp.path(), "translations"));
destroy.revert(Flags::default()).unwrap();
assert!(!target.exists());
}
#[test]
fn a_finished_destroy_forgets_the_recorded_run() {
let tmp = tempfile::TempDir::new().unwrap();
let target = generated_with_bundle(&tmp);
let stray = tmp.path().join("stray.rs");
fs::write(&stray, "x").unwrap();
let manifest = tmp.path().join(provenance::MANIFEST_PATH);
let mut recorded = provenance::Provenance::load(tmp.path());
recorded.record(
tmp.path(),
&stray,
provenance::text_digest("x"),
"other",
None,
);
recorded.save(tmp.path()).unwrap();
destroy_plan(&tmp, &target, "// v1 template\n", "i18n")
.revert(Flags::default())
.unwrap();
let text = fs::read_to_string(manifest).unwrap();
assert!(!text.contains("I18nFtlKeys"), "{text}");
assert!(text.contains("stray.rs"), "{text}");
}
#[test]
fn a_target_outside_the_project_is_never_recorded() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("src/models/post.rs");
let mut generated = Plan::new(tmp.path());
generated.create(target, "// v1 template\n");
generated.push_revert(Revert::PwaMainRsInjection {
path: PathBuf::from("/elsewhere/main.rs"),
});
assert!(generated.revert_anchors().is_empty());
}
#[test]
fn an_owner_dir_changes_the_anchor_and_none_is_stable() {
let root = Path::new("/p");
let feature = |dir: Option<&str>| Revert::CargoAutumnWebFeature {
path: root.join("Cargo.toml"),
feature: "maud".to_owned(),
owner_dir: dir.map(|d| root.join(d)),
};
assert_eq!(feature(None).anchors(root), feature(None).anchors(root));
assert_ne!(
feature(None).anchors(root),
feature(Some("src/routes")).anchors(root)
);
}
#[test]
#[allow(
clippy::too_many_lines,
reason = "one table row for each Revert variant; splitting it hides a missing row"
)]
fn every_revert_kind_anchors_by_its_values_and_not_by_template_text() {
let root = Path::new("/p");
let path = |p: &str| root.join(p);
let s = |v: &str| v.to_owned();
let kinds: Vec<(&str, Revert, Revert)> = vec![
(
"ModDecl",
Revert::ModDecl {
path: path("a"),
name: s("x"),
},
Revert::ModDecl {
path: path("a"),
name: s("y"),
},
),
(
"RoutesEntries",
Revert::RoutesEntries {
path: path("a"),
entries: vec![s("x")],
},
Revert::RoutesEntries {
path: path("a"),
entries: vec![s("y")],
},
),
(
"SchemaTable",
Revert::SchemaTable {
path: path("a"),
table: s("x"),
expected_block: s("1"),
},
Revert::SchemaTable {
path: path("a"),
table: s("y"),
expected_block: s("1"),
},
),
(
"CargoDeps",
Revert::CargoDeps {
path: path("a"),
names: vec![s("x")],
owner_dir: path("o"),
},
Revert::CargoDeps {
path: path("a"),
names: vec![s("y")],
owner_dir: path("o"),
},
),
(
"CargoAutumnWebFeature",
Revert::CargoAutumnWebFeature {
path: path("a"),
feature: s("x"),
owner_dir: None,
},
Revert::CargoAutumnWebFeature {
path: path("a"),
feature: s("y"),
owner_dir: None,
},
),
(
"CargoAutumnWebDevFeature",
Revert::CargoAutumnWebDevFeature {
path: path("a"),
feature: s("x"),
owner_dir: None,
},
Revert::CargoAutumnWebDevFeature {
path: path("a"),
feature: s("y"),
owner_dir: None,
},
),
(
"JobEntry",
Revert::JobEntry {
path: path("a"),
entry: s("x"),
},
Revert::JobEntry {
path: path("a"),
entry: s("y"),
},
),
(
"JobsRegistration",
Revert::JobsRegistration {
path: path("a"),
owner_dir: path("x"),
},
Revert::JobsRegistration {
path: path("a"),
owner_dir: path("y"),
},
),
(
"PolicyRegistration",
Revert::PolicyRegistration {
path: path("a"),
pascal: s("X"),
snake: s("x"),
},
Revert::PolicyRegistration {
path: path("a"),
pascal: s("Y"),
snake: s("x"),
},
),
(
"MailPreview",
Revert::MailPreview {
path: path("a"),
mailer_type: s("x"),
},
Revert::MailPreview {
path: path("a"),
mailer_type: s("y"),
},
),
(
"InboundMailHandler",
Revert::InboundMailHandler {
path: path("a"),
handler_module_path: s("x"),
},
Revert::InboundMailHandler {
path: path("a"),
handler_module_path: s("y"),
},
),
(
"SystemTestCargoPatch",
Revert::SystemTestCargoPatch {
path: path("a"),
snake_name: s("x"),
},
Revert::SystemTestCargoPatch {
path: path("a"),
snake_name: s("y"),
},
),
(
"PwaMainRsInjection",
Revert::PwaMainRsInjection { path: path("a") },
Revert::PwaMainRsInjection { path: path("b") },
),
(
"AuthOAuthProviderStubs",
Revert::AuthOAuthProviderStubs {
path: path("a"),
providers: vec![s("x")],
},
Revert::AuthOAuthProviderStubs {
path: path("a"),
providers: vec![s("y")],
},
),
(
"AuthWebauthnStub",
Revert::AuthWebauthnStub { path: path("a") },
Revert::AuthWebauthnStub { path: path("b") },
),
(
"WebhookEndpointStub",
Revert::WebhookEndpointStub {
path: path("a"),
name: s("x"),
route_path: s("/r"),
},
Revert::WebhookEndpointStub {
path: path("a"),
name: s("x"),
route_path: s("/q"),
},
),
(
"SubmitTokenValidateExempt",
Revert::SubmitTokenValidateExempt {
path: path("a"),
plural: s("x"),
segment: s("validate"),
},
Revert::SubmitTokenValidateExempt {
path: path("a"),
plural: s("x"),
segment: s("preview"),
},
),
(
"RememberMiddleware",
Revert::RememberMiddleware { path: path("a") },
Revert::RememberMiddleware { path: path("b") },
),
(
"SeedBinLinks",
Revert::SeedBinLinks {
path: path("a"),
owner_dir: path("x"),
},
Revert::SeedBinLinks {
path: path("a"),
owner_dir: path("y"),
},
),
(
"NestedChildSection",
Revert::NestedChildSection {
path: path("a"),
child_plural: s("x"),
},
Revert::NestedChildSection {
path: path("a"),
child_plural: s("y"),
},
),
(
"I18nFtlKeys",
Revert::I18nFtlKeys {
path: path("a"),
pascal: s("X"),
snake: s("x"),
},
Revert::I18nFtlKeys {
path: path("b"),
pascal: s("X"),
snake: s("x"),
},
),
];
for (kind, one, other) in kinds {
let anchors = one.anchors(root);
assert!(!anchors.is_empty(), "{kind}");
assert!(
anchors.iter().all(|a| a.kind() == kind),
"{kind}: {anchors:?}"
);
assert_ne!(
anchors,
other.anchors(root),
"{kind} ignores a value that names the edit"
);
}
}
#[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());
});
}
fn adopted_comments_fixture(tmp: &tempfile::TempDir, plan: &mut Plan) -> PathBuf {
let plain_sql = "CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, \
created_at TIMESTAMP NOT NULL);\n";
let real_dir = tmp.path().join("migrations/20260101000000_create_comments");
fs::create_dir_all(&real_dir).unwrap();
fs::write(real_dir.join("up.sql"), plain_sql).unwrap();
fs::write(real_dir.join("down.sql"), "DROP TABLE comments;\n").unwrap();
let adopt = tmp.path().join("migrations/20260102000000_adopt_comments");
fs::create_dir_all(&adopt).unwrap();
fs::write(
adopt.join("up.sql"),
"ALTER TABLE comments ADD COLUMN commentable_type TEXT, \
ADD COLUMN commentable_id BIGINT, ADD COLUMN parent_id BIGINT, \
ADD COLUMN author_id BIGINT, ADD COLUMN deleted_at TIMESTAMP;\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src/models")).unwrap();
fs::write(
tmp.path().join("src/models/post.rs"),
"#[commentable]\npub struct Post {}\n",
)
.unwrap();
let plan_dir = tmp.path().join("migrations/99999999999999_create_comments");
plan.create(plan_dir.join("up.sql"), plain_sql);
plan.create(plan_dir.join("down.sql"), "DROP TABLE comments;\n");
real_dir
}
#[test]
fn revert_keeps_a_comments_migration_the_shared_table_needs() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
let real_dir = adopted_comments_fixture(&tmp, &mut plan);
plan.revert(Flags::default()).unwrap();
assert!(real_dir.exists(), "`Post` still needs this `CREATE TABLE`");
});
}
#[test]
fn revert_with_force_removes_a_comments_migration_the_shared_table_needs() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
let real_dir = adopted_comments_fixture(&tmp, &mut plan);
plan.revert(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert!(!real_dir.exists());
});
}
#[test]
fn revert_removes_a_comments_migration_no_commentable_model_needs() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
let real_dir = adopted_comments_fixture(&tmp, &mut plan);
fs::remove_file(tmp.path().join("src/models/post.rs")).unwrap();
plan.revert(Flags::default()).unwrap();
assert!(!real_dir.exists());
});
}
#[test]
fn an_applied_needed_comments_migration_gets_the_applied_warning() {
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();
let real_dir = adopted_comments_fixture(&tmp, &mut plan);
let warnings = plan.compute_revert_plan(false).warnings;
assert!(
warnings.iter().any(|w| w.contains("appears to be applied")),
"{warnings:#?}"
);
assert!(
!warnings.iter().any(|w| w.contains("pass --force")),
"{warnings:#?}"
);
assert!(real_dir.exists());
},
);
}
#[test]
fn revert_removes_a_comments_migration_beside_the_generated_shared_one() {
no_db_env(|| {
let (tmp, mut plan) = fixture();
let real_dir = adopted_comments_fixture(&tmp, &mut plan);
fs::remove_dir_all(tmp.path().join("migrations/20260102000000_adopt_comments"))
.unwrap();
let shared = tmp
.path()
.join("migrations/202601010000002_create_comments");
fs::create_dir_all(&shared).unwrap();
fs::write(
shared.join("up.sql"),
super::super::commentable::up_sql(autumn_web::config::DatabaseBackend::Postgres),
)
.unwrap();
plan.revert(Flags::default()).unwrap();
assert!(!real_dir.exists());
assert!(shared.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 match `autumn_web::storage`, 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 storage_and_markdown_markers_match_module_imports() {
let cases = [
("storage", "use autumn_web::storage;\n"),
("storage", "use autumn_web::storage as blobs;\n"),
("markdown", "use autumn_web::markdown;\n"),
("markdown", "use autumn_web::markdown as md;\n"),
];
for (feature, source) in cases {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
let file = tmp.path().join("src/handwritten.rs");
fs::write(&file, source).unwrap();
let still_needed = || {
autumn_web_feature_still_needed_elsewhere(feature, tmp.path(), &[], &HashMap::new())
};
assert!(still_needed(), "{feature} must stay for {source:?}");
fs::remove_file(&file).unwrap();
assert!(!still_needed(), "{feature} must go when no file uses it");
}
}
#[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;"));
}
fn sole(mut plan: Plan) -> Plan {
plan.sole_resource = true;
plan
}
fn generated_v1(tmp: &tempfile::TempDir) -> (PathBuf, PathBuf) {
let old = tmp.path().join("src/widgets/old.rs");
let kept = tmp.path().join("src/kept.rs");
let mut v1 = sole(Plan::new(tmp.path()));
v1.create(old.clone(), "// v1 old\n");
v1.create(kept.clone(), "// kept\n");
v1.execute(Flags::default()).unwrap();
(old, kept)
}
fn renamed_v2(tmp: &tempfile::TempDir) -> Plan {
let mut v2 = sole(Plan::new(tmp.path()));
v2.create(tmp.path().join("src/widgets/new.rs"), "// v2 new\n");
v2.create(tmp.path().join("src/kept.rs"), "// kept\n");
v2
}
#[test]
fn a_claimed_execute_records_owned_and_shared_scopes() {
let (tmp, plan) = fixture();
let mut plan = sole(plan);
plan.create(tmp.path().join("a.rs"), "a");
plan.create_bytes(tmp.path().join("b.png"), vec![1]);
plan.create_if_absent(tmp.path().join("c.html"), "c");
plan.execute(Flags::default()).unwrap();
let recorded = provenance::Provenance::load(tmp.path());
let mut scopes: Vec<_> = recorded
.recorded_by(&plan.invocation)
.map(|r| (r.key.to_owned(), r.scope))
.collect();
scopes.sort();
assert_eq!(
scopes,
vec![
("a.rs".to_owned(), Some(provenance::Scope::Owned)),
("b.png".to_owned(), Some(provenance::Scope::Owned)),
("c.html".to_owned(), Some(provenance::Scope::Shared)),
]
);
}
#[test]
fn an_unclaimed_execute_records_no_scope() {
let (tmp, mut plan) = fixture();
plan.create(tmp.path().join("a.rs"), "a");
plan.execute(Flags::default()).unwrap();
let recorded = provenance::Provenance::load(tmp.path());
let scopes: Vec<_> = recorded
.recorded_by(&plan.invocation)
.map(|r| r.scope)
.collect();
assert_eq!(scopes, vec![None]);
}
#[test]
fn destroy_removes_a_file_the_templates_have_since_renamed() {
let tmp = tempfile::TempDir::new().unwrap();
let (old, kept) = generated_v1(&tmp);
renamed_v2(&tmp).revert(Flags::default()).unwrap();
assert!(!old.exists(), "the stranded file must go");
assert!(!kept.exists());
assert!(
!tmp.path().join("src/widgets").exists(),
"the emptied directory is pruned"
);
assert!(
!tmp.path().join(provenance::MANIFEST_PATH).exists(),
"no entry outlives its file"
);
}
#[test]
fn destroy_keeps_an_edited_renamed_file_even_with_force() {
let tmp = tempfile::TempDir::new().unwrap();
let (old, _) = generated_v1(&tmp);
fs::write(&old, "// mine\n").unwrap();
renamed_v2(&tmp)
.revert(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert!(old.exists(), "an edit is never swept");
assert!(provenance::Provenance::load(tmp.path()).contains("src/widgets/old.rs"));
}
#[test]
fn a_dry_run_lists_the_sweep_and_touches_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let (old, _) = generated_v1(&tmp);
let v2 = renamed_v2(&tmp);
let plan = v2.compute_revert_plan(false);
assert_eq!(plan.swept.keys().collect::<Vec<_>>(), vec![&old]);
v2.revert(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(old.exists());
assert!(provenance::Provenance::load(tmp.path()).contains("src/widgets/old.rs"));
}
#[test]
fn a_swept_file_edited_after_planning_refuses_the_whole_destroy() {
let tmp = tempfile::TempDir::new().unwrap();
let (old, kept) = generated_v1(&tmp);
let v2 = renamed_v2(&tmp);
let plan = v2.compute_revert_plan(false);
fs::write(&old, "// saved late\n").unwrap();
let mut touched = Vec::new();
let mut removed = Removed::default();
let err = v2
.apply_revert(&plan, &mut touched, &mut removed)
.unwrap_err();
assert!(matches!(err, GenerateError::Diverged(ref paths) if paths == &vec![old.clone()]));
assert!(old.exists() && kept.exists(), "nothing is changed");
}
#[test]
fn a_refused_destroy_sweeps_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let (old, kept) = generated_v1(&tmp);
fs::write(&kept, "// mine\n").unwrap();
let err = renamed_v2(&tmp).revert(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Diverged(_)));
assert!(old.exists(), "a refused destroy removes nothing");
}
#[test]
fn an_unclaimed_destroy_never_sweeps() {
let tmp = tempfile::TempDir::new().unwrap();
let mut first = Plan::new(tmp.path());
first.create(tmp.path().join("src/jobs/a.rs"), "a");
first.execute(Flags::default()).unwrap();
let mut second = Plan::new(tmp.path());
second.create(tmp.path().join("src/jobs/b.rs"), "b");
second.execute(Flags::default()).unwrap();
second.revert(Flags::default()).unwrap();
assert!(tmp.path().join("src/jobs/a.rs").exists());
}
#[test]
fn a_claimed_destroy_never_sweeps_an_unclaimed_entry() {
let tmp = tempfile::TempDir::new().unwrap();
let mut first = Plan::new(tmp.path());
first.create(tmp.path().join("src/jobs/a.rs"), "a");
first.execute(Flags::default()).unwrap();
let mut second = sole(Plan::new(tmp.path()));
second.create(tmp.path().join("src/jobs/b.rs"), "b");
second.revert(Flags::default()).unwrap();
assert!(tmp.path().join("src/jobs/a.rs").exists());
}
#[test]
fn a_missing_orphan_loses_its_entry() {
let tmp = tempfile::TempDir::new().unwrap();
let (old, _) = generated_v1(&tmp);
fs::remove_file(&old).unwrap();
renamed_v2(&tmp).revert(Flags::default()).unwrap();
assert!(!tmp.path().join(provenance::MANIFEST_PATH).exists());
}
#[test]
fn a_stale_entry_alone_is_still_pruned() {
let tmp = tempfile::TempDir::new().unwrap();
let old = tmp.path().join("src/old.rs");
let mut v1 = sole(Plan::new(tmp.path()));
v1.create(old.clone(), "x");
v1.execute(Flags::default()).unwrap();
fs::remove_file(&old).unwrap();
sole(Plan::new(tmp.path()))
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join(provenance::MANIFEST_PATH).exists());
}
#[test]
fn a_swept_file_no_longer_keeps_a_dependency_alive() {
no_db_env(|| {
let tmp = tempfile::TempDir::new().unwrap();
let cargo = tmp.path().join("Cargo.toml");
fs::write(
&cargo,
"[package]\nname = \"app\"\n\n[dependencies]\nfoo-crate = \"1\"\n",
)
.unwrap();
let mut v1 = sole(Plan::new(tmp.path()));
v1.create(tmp.path().join("src/old.rs"), "use foo_crate::X;\n");
v1.execute(Flags::default()).unwrap();
let mut v2 = sole(Plan::new(tmp.path()));
v2.create(tmp.path().join("src/new.rs"), "// v2\n");
v2.push_revert(Revert::CargoDeps {
path: cargo.clone(),
names: vec!["foo-crate".to_owned()],
owner_dir: tmp.path().join("src/models"),
});
v2.revert(Flags::default()).unwrap();
assert!(!tmp.path().join("src/old.rs").exists());
assert!(
!fs::read_to_string(&cargo).unwrap().contains("foo-crate"),
"the dependency must go with its last user"
);
});
}
#[test]
fn only_the_first_claim_in_a_process_is_sole() {
let claimed = AtomicBool::new(false);
let (_tmp, mut first) = fixture();
let (_tmp2, mut second) = fixture();
assert!(first.claim_with(&claimed));
assert!(first.sole_resource);
assert!(!second.claim_with(&claimed), "one plan per process");
assert!(!second.sole_resource);
}
}