use std::path::Path;
use crate::generate::emit::Plan;
use super::catalog::CatalogEntry;
use super::install::{PluginError, dependency_line, manifest_path};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wire {
Dependency,
Mount,
}
impl Wire {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Dependency => "the Cargo.toml dependency",
Self::Mount => "the builder-chain mount",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DependencyKept {
StillUsed(String),
NotEditable(String),
}
impl DependencyKept {
#[must_use]
pub fn reason(&self) -> &str {
match self {
Self::StillUsed(reason) | Self::NotEditable(reason) => reason,
}
}
#[must_use]
pub const fn needs_a_hand_edit(&self) -> bool {
matches!(self, Self::NotEditable(_))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DataResidue {
pub migrations: Vec<String>,
pub tables: Vec<String>,
}
impl DataResidue {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.migrations.is_empty() && self.tables.is_empty()
}
}
#[derive(Debug)]
pub enum RemoveOutcome {
Removed {
plan: Box<Plan>,
removed: Vec<Wire>,
missing: Vec<Wire>,
dependency_retained: Option<DependencyKept>,
residue: DataResidue,
},
NotInstalled {
residue: DataResidue,
},
Manual {
reason: String,
dependency_line: Option<String>,
mount_snippet: String,
residue: DataResidue,
},
}
#[must_use]
pub fn residue_for(entry: &CatalogEntry) -> DataResidue {
DataResidue {
migrations: entry.migrations.iter().map(|m| (*m).to_owned()).collect(),
tables: entry.tables.iter().map(|t| (*t).to_owned()).collect(),
}
}
#[must_use]
pub fn drop_data_statements(entry: &CatalogEntry) -> Vec<String> {
let mut out = Vec::new();
for table in entry.tables {
out.push(format!("DROP TABLE IF EXISTS {table};"));
}
if !entry.migrations.is_empty() {
let versions = entry
.migrations
.iter()
.map(|version| format!("'{}'", version.split('_').next().unwrap_or(version)))
.collect::<Vec<_>>()
.join(", ");
out.push(format!(
"DELETE FROM __diesel_schema_migrations WHERE version IN ({versions});"
));
}
out
}
const MOUNT_MARKER: &str = "// added by `autumn plugin add";
fn mount_span(main_rs: &str, entry: &CatalogEntry) -> Option<std::ops::Range<usize>> {
let masked = crate::rust_source::mask_non_code(main_rs);
if let Some((open, close)) = super::install::mount_call_span(&masked, entry, |argument| {
argument.trim_start().starts_with(entry.mount_arg)
}) {
let call_start = open + 1 - entry.mount_call.len();
let line_start = masked[..call_start].rfind('\n').map_or(0, |at| at + 1);
let line_end = masked[close..]
.find('\n')
.map_or(masked.len(), |at| close + at + 1);
if masked[line_start..call_start].trim().is_empty()
&& masked[close + 1..line_end].trim().is_empty()
{
return Some(marker_start(main_rs, line_start, entry.crate_name)..line_end);
}
return None;
}
verbatim_mount_span(main_rs, &masked, entry)
}
fn verbatim_mount_span(
main_rs: &str,
masked: &str,
entry: &CatalogEntry,
) -> Option<std::ops::Range<usize>> {
let mut from = 0usize;
while let Some(found) = main_rs[from..].find(entry.mount) {
let at = from + found;
let end = at + entry.mount.len();
let starts_a_line = at == 0 || main_rs.as_bytes()[at - 1] == b'\n';
if starts_a_line
&& masked[at..end].contains(entry.mount_call)
&& masked[at..end].contains(entry.mount_arg)
{
return Some(at..end);
}
from = at + 1;
}
None
}
fn marker_start(main_rs: &str, line_start: usize, crate_name: &str) -> usize {
if line_start == 0 {
return line_start;
}
let before = &main_rs[..line_start - 1];
let previous_start = before.rfind('\n').map_or(0, |at| at + 1);
let previous = main_rs[previous_start..line_start].trim();
if previous == format!("{MOUNT_MARKER} {crate_name}`") {
previous_start
} else {
line_start
}
}
#[must_use]
pub fn remove_mount(main_rs: &str, entry: &CatalogEntry) -> Option<String> {
let mut current = main_rs.to_owned();
let mut removed_any = false;
while let Some(span) = mount_span(¤t, entry) {
current.replace_range(span, "");
removed_any = true;
}
removed_any.then_some(current)
}
fn still_referenced(
root: &Path,
crate_name: &str,
overrides: &std::collections::HashMap<std::path::PathBuf, String>,
) -> Option<std::path::PathBuf> {
if let Some(site) =
crate::generate::emit::crate_reference_site(root, crate_name, &[], overrides)
{
return Some(site);
}
let markers = crate::generate::emit::crate_reference_markers(crate_name);
super::install::explicit_target_sources(root)
.into_iter()
.find(|path| {
let src = overrides.get(path).map_or_else(
|| {
std::fs::read_to_string(path)
.ok()
.map(std::borrow::Cow::Owned)
},
|src| Some(std::borrow::Cow::Borrowed(src.as_str())),
);
src.is_some_and(|src| markers.iter().any(|marker| src.contains(marker.as_str())))
})
}
fn dependency_cleanly_removed(updated: &str, crate_name: &str) -> bool {
toml::from_str::<toml::Table>(updated).is_ok()
&& !super::install::dependency_present(updated, crate_name)
}
fn undeletable_dependency_reason(crate_name: &str) -> DependencyKept {
DependencyKept::NotEditable(format!(
"The {crate_name} dependency was left in Cargo.toml: it is not declared as a \
plain `{crate_name} = \"…\"` line (a `[dependencies.{crate_name}]` subtable, or a \
multi-line inline table), and rewriting that shape safely is beyond this \
command. Delete it by hand"
))
}
pub fn plan_remove(root: &Path, entry: &CatalogEntry) -> Result<RemoveOutcome, PluginError> {
let manifest = manifest_path(root);
if !manifest.is_file() {
return Err(PluginError::NotInProject);
}
let manifest_src = std::fs::read_to_string(&manifest)?;
let main_path = root.join("src").join("main.rs");
let main_src = std::fs::read_to_string(&main_path).unwrap_or_default();
let residue = residue_for(entry);
let has_dependency = super::install::dependency_present(&manifest_src, entry.crate_name);
let is_mounted = super::install::mount_present(&main_src, entry);
if !has_dependency && !is_mounted {
return Ok(RemoveOutcome::NotInstalled { residue });
}
let unmounted_src = if is_mounted {
match remove_mount(&main_src, entry) {
Some(updated) => Some(updated),
None => {
return Ok(RemoveOutcome::Manual {
reason: format!(
"could not identify `{}`'s mount in {} as a single builder-chain call — nothing was changed",
entry.crate_name,
crate::generate::emit::relative_display(&main_path, root)
),
dependency_line: has_dependency
.then(|| declared_dependency_line(&manifest_src, entry.crate_name)),
mount_snippet: entry.mount.trim_end_matches('\n').to_owned(),
residue,
});
}
}
} else {
None
};
let mut overrides = std::collections::HashMap::new();
if let Some(updated) = &unmounted_src {
overrides.insert(main_path.clone(), updated.clone());
}
let retained_by = if has_dependency {
still_referenced(root, entry.crate_name, &overrides)
} else {
None
};
let mut plan = Plan::new(root);
let mut removed = Vec::new();
let mut missing = Vec::new();
if let Some(updated) = unmounted_src {
plan.modify(main_path, updated);
removed.push(Wire::Mount);
} else {
missing.push(Wire::Mount);
}
let dependency_retained = match (has_dependency, &retained_by) {
(true, None) => {
let updated = crate::generate::model::remove_cargo_dependencies(
&manifest_src,
&[entry.crate_name],
);
if updated != manifest_src && dependency_cleanly_removed(&updated, entry.crate_name) {
plan.modify(manifest, updated);
removed.push(Wire::Dependency);
None
} else {
Some(undeletable_dependency_reason(entry.crate_name))
}
}
(true, Some(site)) => Some(DependencyKept::StillUsed(format!(
"The {} dependency was kept: {} still names the crate, and removing it would \
stop that code compiling",
entry.crate_name,
crate::generate::emit::relative_display(site, root)
))),
(false, _) => {
missing.push(Wire::Dependency);
None
}
};
Ok(RemoveOutcome::Removed {
plan: Box::new(plan),
removed,
missing,
dependency_retained,
residue,
})
}
fn declared_dependency_line(manifest: &str, crate_name: &str) -> String {
super::install::declared_dependency_version(manifest, crate_name).map_or_else(
|| format!("{crate_name} = …"),
|version| dependency_line(crate_name, &version),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DropDataDecision {
NothingToDrop,
Run {
url: String,
statements: Vec<String>,
},
PrintOnly {
reason: String,
statements: Vec<String>,
},
}
#[must_use]
pub fn decide_drop_data(entry: &CatalogEntry, database_url: Option<&str>) -> DropDataDecision {
let statements = drop_data_statements(entry);
if statements.is_empty() {
return DropDataDecision::NothingToDrop;
}
let Some(url) = database_url else {
return DropDataDecision::PrintOnly {
reason: "no database is configured here, so there is nothing to connect to".to_owned(),
statements,
};
};
if !is_postgres_url(url) {
return DropDataDecision::PrintOnly {
reason: format!(
"`--drop-data` drives Postgres only, and this app's database is {}",
backend_label(url)
),
statements,
};
}
DropDataDecision::Run {
url: url.to_owned(),
statements,
}
}
fn is_postgres_url(url: &str) -> bool {
let lowered = url.to_ascii_lowercase();
lowered.starts_with("postgres://") || lowered.starts_with("postgresql://")
}
fn backend_label(url: &str) -> String {
let lowered = url.to_ascii_lowercase();
lowered.split_once("://").map_or_else(
|| "not a recognisable database URL".to_owned(),
|(scheme, _)| scheme.to_owned(),
)
}
pub fn execute_drop_data(url: &str, statements: &[String]) -> Result<(), String> {
use diesel::connection::SimpleConnection as _;
use diesel::{Connection as _, PgConnection};
let mut conn = PgConnection::establish(url)
.map_err(|err| format!("could not connect to the database: {err}"))?;
conn.transaction(|conn| {
for statement in statements {
conn.batch_execute(statement).map_err(|err| {
diesel::result::Error::QueryBuilderError(
format!("`{statement}` failed: {err}").into(),
)
})?;
}
Ok(())
})
.map_err(|err: diesel::result::Error| err.to_string())
}
pub fn plan_remove_community(root: &Path, crate_name: &str) -> Result<RemoveOutcome, PluginError> {
let manifest = manifest_path(root);
if !manifest.is_file() {
return Err(PluginError::NotInProject);
}
let manifest_src = std::fs::read_to_string(&manifest)?;
if !super::install::dependency_present(&manifest_src, crate_name) {
return Ok(RemoveOutcome::NotInstalled {
residue: DataResidue::default(),
});
}
let overrides = std::collections::HashMap::new();
if let Some(site) = still_referenced(root, crate_name, &overrides) {
return Ok(RemoveOutcome::Removed {
plan: Box::new(Plan::new(root)),
removed: Vec::new(),
missing: Vec::new(),
dependency_retained: Some(DependencyKept::StillUsed(format!(
"The {crate_name} dependency was kept: {} still names the crate. `plugin add` \
never wrote that mount, so `remove` will not delete it — delete it yourself, \
then re-run",
crate::generate::emit::relative_display(&site, root)
))),
residue: DataResidue::default(),
});
}
let updated = crate::generate::model::remove_cargo_dependencies(&manifest_src, &[crate_name]);
let mut plan = Plan::new(root);
let mut removed = Vec::new();
let mut dependency_retained = None;
if updated != manifest_src && dependency_cleanly_removed(&updated, crate_name) {
plan.modify(manifest, updated);
removed.push(Wire::Dependency);
} else {
dependency_retained = Some(undeletable_dependency_reason(crate_name));
}
Ok(RemoveOutcome::Removed {
plan: Box::new(plan),
removed,
missing: Vec::new(),
dependency_retained,
residue: DataResidue::default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin::catalog;
const SCAFFOLD_MAIN: &str = r"use autumn_web::prelude::*;
#[autumn_web::main]
async fn main() {
let app = autumn_web::app()
// added by `autumn plugin add autumn-admin-plugin`
.plugin(autumn_admin_plugin::AdminPlugin::new())
.routes(routes![index])
.migrations(MIGRATIONS);
app
.run()
.await;
}
";
const SCAFFOLD_MAIN_BARE: &str = r"use autumn_web::prelude::*;
#[autumn_web::main]
async fn main() {
let app = autumn_web::app()
.routes(routes![index])
.migrations(MIGRATIONS);
app
.run()
.await;
}
";
const SCAFFOLD_CARGO: &str = r#"[package]
name = "demo"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.7.0"
autumn-admin-plugin = "0.7.0"
maud = { version = "0.27", features = ["axum"] }
"#;
fn admin() -> &'static CatalogEntry {
catalog::lookup("autumn-admin-plugin").expect("admin entry")
}
fn media() -> &'static CatalogEntry {
catalog::lookup("autumn-media-plugin").expect("media entry")
}
fn fake_project(main_rs: &str, cargo: &str) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), cargo).unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/main.rs"), main_rs).unwrap();
tmp
}
#[test]
fn the_mount_and_its_marker_comment_are_excised_together() {
let out = remove_mount(SCAFFOLD_MAIN, admin()).expect("excisable");
assert!(!out.contains("AdminPlugin"), "{out}");
assert!(!out.contains("added by `autumn plugin add"), "{out}");
assert!(out.contains(".routes(routes![index])"), "{out}");
assert!(out.contains("let app = autumn_web::app()"), "{out}");
assert!(out.contains(".migrations(MIGRATIONS)"), "{out}");
}
#[test]
fn a_configured_multi_line_mount_is_excised_whole() {
let main = "#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app()\n .plugin(\n autumn_admin_plugin::AdminPlugin::new()\n .require_role(\"staff\"),\n )\n .routes(routes![index]);\n}\n";
let out = remove_mount(main, admin()).expect("excisable");
assert!(!out.contains("AdminPlugin"), "{out}");
assert!(!out.contains("require_role"), "{out}");
assert!(out.contains(".routes(routes![index]);"), "{out}");
assert!(!out.contains(".plugin("), "{out}");
}
#[test]
fn the_storage_block_mount_is_excised_whole() {
let entry = catalog::lookup("autumn-storage-s3").unwrap();
let main = format!(
"#[autumn_web::main]\nasync fn main() {{\n let app = autumn_web::app()\n{} .routes(routes![index]);\n}}\n",
entry.mount
);
let out = remove_mount(&main, entry).expect("excisable");
assert!(!out.contains("S3BlobStore"), "{out}");
assert!(!out.contains("with_blob_store"), "{out}");
assert!(out.contains(".routes(routes![index]);"), "{out}");
}
#[test]
fn a_mount_through_a_variable_is_not_excised() {
let main = "#[autumn_web::main]\nasync fn main() {\n let configured = autumn_admin_plugin::AdminPlugin::new().require_role(\"staff\");\n let app = autumn_web::app()\n .plugin(configured)\n .routes(routes![index]);\n}\n";
assert!(remove_mount(main, admin()).is_none());
}
#[test]
fn a_mount_sharing_a_line_with_other_calls_is_not_excised() {
let main = "#[autumn_web::main]\nasync fn main() {\n autumn_web::app().plugin(autumn_admin_plugin::AdminPlugin::new()).routes(routes![index]).run().await;\n}\n";
assert!(remove_mount(main, admin()).is_none());
}
#[test]
fn a_commented_out_mount_is_not_excised() {
let main = "#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app()\n // .plugin(autumn_admin_plugin::AdminPlugin::new())\n .routes(routes![index]);\n}\n";
assert!(remove_mount(main, admin()).is_none());
}
#[test]
fn a_fully_wired_plugin_has_both_wires_removed() {
let tmp = fake_project(SCAFFOLD_MAIN, SCAFFOLD_CARGO);
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
removed, missing, ..
} = outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(removed.contains(&Wire::Dependency), "{removed:?}");
assert!(removed.contains(&Wire::Mount), "{removed:?}");
assert!(missing.is_empty(), "{missing:?}");
}
#[test]
fn removing_a_plugin_that_is_not_installed_is_a_no_op() {
let tmp = fake_project(
"#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app();\n}\n",
"[package]\nname = \"demo\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\n",
);
assert!(matches!(
plan_remove(tmp.path(), admin()).unwrap(),
RemoveOutcome::NotInstalled { .. }
));
}
#[test]
fn a_dependency_without_a_mount_is_removed_and_the_mount_reported_missing() {
let tmp = fake_project(
"#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app()\n .routes(routes![index]);\n}\n",
SCAFFOLD_CARGO,
);
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
removed, missing, ..
} = outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert_eq!(removed, vec![Wire::Dependency]);
assert_eq!(missing, vec![Wire::Mount]);
}
#[test]
fn a_mount_without_a_dependency_is_removed_and_the_dependency_reported_missing() {
let tmp = fake_project(
SCAFFOLD_MAIN,
"[package]\nname = \"demo\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\n",
);
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
removed, missing, ..
} = outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert_eq!(removed, vec![Wire::Mount]);
assert_eq!(missing, vec![Wire::Dependency]);
}
#[test]
fn an_unexcisable_mount_leaves_the_dependency_alone() {
let main = "#[autumn_web::main]\nasync fn main() {\n let configured = autumn_admin_plugin::AdminPlugin::new();\n let app = autumn_web::app()\n .plugin(configured);\n}\n";
let tmp = fake_project(main, SCAFFOLD_CARGO);
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Manual {
dependency_line,
mount_snippet,
..
} = &outcome
else {
panic!("expected Manual, got {outcome:?}");
};
assert!(
dependency_line
.as_ref()
.is_some_and(|line| line.contains("autumn-admin-plugin")),
"{dependency_line:?}"
);
assert!(mount_snippet.contains("AdminPlugin"), "{mount_snippet}");
}
#[test]
fn a_dependency_still_used_elsewhere_is_retained() {
let tmp = fake_project(SCAFFOLD_MAIN, SCAFFOLD_CARGO);
std::fs::write(
tmp.path().join("src/support.rs"),
"pub fn role() -> autumn_admin_plugin::AdminPlugin { todo!() }\n",
)
.unwrap();
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
removed,
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert_eq!(removed, &vec![Wire::Mount]);
assert!(
dependency_retained
.as_ref()
.is_some_and(|kept| kept.reason().contains("support.rs")),
"{dependency_retained:?}"
);
}
#[test]
fn a_plugin_that_owns_tables_reports_them_as_residue() {
let residue = residue_for(media());
assert!(
residue.tables.iter().any(|t| t == "media_rooms"),
"{residue:?}"
);
assert!(!residue.migrations.is_empty(), "{residue:?}");
}
#[test]
fn a_plugin_that_owns_no_tables_has_no_residue() {
assert!(residue_for(admin()).is_empty());
assert!(residue_for(catalog::lookup("autumn-cache-redis").unwrap()).is_empty());
}
#[test]
fn the_drop_plan_drops_dependents_first_and_forgets_the_migrations() {
let statements = drop_data_statements(media());
let participants = statements
.iter()
.position(|s| s.contains("media_room_participants"))
.expect("participants drop");
let rooms = statements
.iter()
.position(|s| s.contains("DROP TABLE IF EXISTS media_rooms"))
.expect("rooms drop");
assert!(participants < rooms, "{statements:?}");
assert!(
statements
.iter()
.any(|s| s.contains("__diesel_schema_migrations")),
"{statements:?}"
);
}
#[test]
fn a_plugin_with_no_data_has_an_empty_drop_plan() {
assert!(drop_data_statements(admin()).is_empty());
}
#[test]
fn a_plugin_with_no_declared_data_has_nothing_to_drop() {
assert_eq!(
decide_drop_data(admin(), Some("postgres://localhost/app")),
DropDataDecision::NothingToDrop
);
}
#[test]
fn a_postgres_url_gets_the_statements_applied() {
let decision = decide_drop_data(media(), Some("postgres://localhost/app"));
let DropDataDecision::Run { url, statements } = decision else {
panic!("expected Run, got {decision:?}");
};
assert_eq!(url, "postgres://localhost/app");
assert_eq!(statements, drop_data_statements(media()));
}
#[test]
fn no_database_url_prints_the_statements_instead() {
let decision = decide_drop_data(media(), None);
let DropDataDecision::PrintOnly { statements, .. } = decision else {
panic!("expected PrintOnly, got {decision:?}");
};
assert_eq!(statements, drop_data_statements(media()));
}
#[test]
fn a_non_postgres_backend_prints_the_statements_instead() {
let decision = decide_drop_data(media(), Some("sqlite://app.db"));
let DropDataDecision::PrintOnly { reason, statements } = decision else {
panic!("expected PrintOnly, got {decision:?}");
};
assert!(reason.to_lowercase().contains("sqlite"), "{reason}");
assert_eq!(statements, drop_data_statements(media()));
}
const COMMUNITY_CARGO: &str = r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
autumn-web = "0.7.0"
autumn-plugin-live-feed = "0.3.1"
"#;
#[test]
fn a_community_dependency_is_removed_when_nothing_uses_it() {
let tmp = fake_project(
"#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app();\n}\n",
COMMUNITY_CARGO,
);
let outcome = plan_remove_community(tmp.path(), "autumn-plugin-live-feed").unwrap();
let RemoveOutcome::Removed { removed, .. } = &outcome else {
panic!("expected Removed, got {outcome:?}");
};
assert_eq!(removed, &vec![Wire::Dependency]);
}
#[test]
fn a_community_dependency_with_a_hand_written_mount_is_retained() {
let tmp = fake_project(
"#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app()\n .plugin(autumn_plugin_live_feed::LiveFeedPlugin::new());\n}\n",
COMMUNITY_CARGO,
);
let outcome = plan_remove_community(tmp.path(), "autumn-plugin-live-feed").unwrap();
let RemoveOutcome::Removed {
removed,
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(removed.is_empty(), "{removed:?}");
assert!(dependency_retained.is_some(), "{outcome:?}");
}
#[test]
fn removing_an_absent_community_dependency_is_a_no_op() {
let tmp = fake_project(
"#[autumn_web::main]\nasync fn main() {}\n",
"[package]\nname = \"demo\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\n",
);
assert!(matches!(
plan_remove_community(tmp.path(), "autumn-plugin-live-feed").unwrap(),
RemoveOutcome::NotInstalled { .. }
));
}
#[test]
fn every_first_party_mount_round_trips_through_insert_and_remove() {
for entry in catalog::FIRST_PARTY {
let installed = crate::plugin::install::insert_mount(SCAFFOLD_MAIN_BARE, entry.mount)
.unwrap_or_else(|| panic!("{} could not be mounted", entry.crate_name));
assert_ne!(installed, SCAFFOLD_MAIN_BARE, "{}", entry.crate_name);
let removed = remove_mount(&installed, entry)
.unwrap_or_else(|| panic!("{} could not be unmounted", entry.crate_name));
assert_eq!(
removed, SCAFFOLD_MAIN_BARE,
"{} did not round-trip",
entry.crate_name
);
}
}
#[test]
fn removing_one_of_two_stacked_mounts_leaves_the_other_whole() {
let admin = admin();
let search = catalog::lookup("autumn-search").unwrap();
let with_search =
crate::plugin::install::insert_mount(SCAFFOLD_MAIN_BARE, search.mount).unwrap();
let both = crate::plugin::install::insert_mount(&with_search, admin.mount).unwrap();
let without_admin = remove_mount(&both, admin).expect("excisable");
assert_eq!(without_admin, with_search);
let without_search = remove_mount(&both, search).expect("excisable");
assert!(
without_search.contains("AdminPlugin::new()"),
"{without_search}"
);
assert!(
without_search.contains("added by `autumn plugin add autumn-admin-plugin`"),
"{without_search}"
);
assert!(!without_search.contains("SearchPlugin"), "{without_search}");
assert!(
!without_search.contains("added by `autumn plugin add autumn-search`"),
"{without_search}"
);
}
#[test]
fn a_mount_nested_inside_another_plugins_argument_is_not_excised() {
let main = "#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app()\n .plugin(MyWrapperPlugin::new(autumn_admin_plugin::AdminPlugin::new(), my_setting()))\n .routes(routes![index]);\n}\n";
assert!(remove_mount(main, admin()).is_none());
}
#[test]
fn a_conditional_blob_store_is_not_excised() {
let entry = catalog::lookup("autumn-storage-s3").unwrap();
let main = "#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app()\n .with_blob_store(if use_s3 {\n Box::new(autumn_storage_s3::S3BlobStore::from_config(&c).await.unwrap())\n } else {\n Box::new(LocalBlobStore::new(\"./uploads\"))\n })\n .routes(routes![index]);\n}\n";
assert!(remove_mount(main, entry).is_none());
}
#[test]
fn an_annotated_marker_comment_is_left_alone() {
let main = "#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app()\n // added by `autumn plugin add autumn-admin-plugin` -- KEEP: see ticket 42\n .plugin(autumn_admin_plugin::AdminPlugin::new())\n .routes(routes![index]);\n}\n";
let out = remove_mount(main, admin()).expect("excisable");
assert!(!out.contains("AdminPlugin"), "{out}");
assert!(out.contains("KEEP: see ticket 42"), "{out}");
}
#[test]
fn a_commented_out_copy_of_a_block_mount_is_never_excised_instead_of_the_real_one() {
let entry = catalog::lookup("autumn-storage-s3").unwrap();
let main = format!(
"#[autumn_web::main]\nasync fn main() {{\n let app = autumn_web::app()\n/*\n{}*/\n{} .routes(routes![index]);\n}}\n",
entry.mount, entry.mount
);
let out = remove_mount(&main, entry).expect("the live mount is excisable");
assert!(out.contains("/*\n"), "{out}");
assert_eq!(
out.matches("S3BlobStore::from_config(").count(),
1,
"only the commented-out copy should remain:\n{out}"
);
assert!(!crate::plugin::install::mount_present(&out, entry), "{out}");
}
#[test]
fn every_copy_of_a_repeated_mount_is_removed() {
let main = "#[autumn_web::main]\nasync fn main() {\n let app = autumn_web::app()\n .plugin(autumn_admin_plugin::AdminPlugin::new())\n .plugin(autumn_admin_plugin::AdminPlugin::new())\n .routes(routes![index]);\n}\n";
let out = remove_mount(main, admin()).expect("excisable");
assert!(!out.contains("AdminPlugin"), "{out}");
assert!(out.contains(".routes(routes![index])"), "{out}");
}
#[test]
fn a_multi_line_inline_table_dependency_is_never_half_deleted() {
let cargo = "[package]\nname = \"demo\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\nautumn-admin-plugin = { version = \"0.7.0\", features = [\n \"extra\",\n] }\nmaud = \"0.27\"\n";
let tmp = fake_project(SCAFFOLD_MAIN, cargo);
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
plan,
removed,
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert_eq!(removed, &vec![Wire::Mount]);
assert!(
dependency_retained
.as_ref()
.is_some_and(DependencyKept::needs_a_hand_edit),
"{dependency_retained:?}"
);
for action in &plan.actions {
assert!(
!action.path().ends_with("Cargo.toml"),
"Cargo.toml must not be rewritten: {action:?}"
);
}
}
#[test]
fn a_dependency_subtable_is_never_half_deleted() {
let cargo = "[package]\nname = \"demo\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\n\n[dependencies.autumn-admin-plugin]\nversion = \"0.7.0\"\n";
let tmp = fake_project(SCAFFOLD_MAIN, cargo);
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(
dependency_retained
.as_ref()
.is_some_and(DependencyKept::needs_a_hand_edit),
"{outcome:?}"
);
}
#[test]
fn a_dependency_used_only_from_a_build_script_is_retained() {
let tmp = fake_project(SCAFFOLD_MAIN, SCAFFOLD_CARGO);
std::fs::write(
tmp.path().join("build.rs"),
"fn main() { let _ = autumn_admin_plugin::VERSION; }\n",
)
.unwrap();
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(
dependency_retained
.as_ref()
.is_some_and(|kept| kept.reason().contains("build.rs")),
"{dependency_retained:?}"
);
}
#[test]
fn a_dependency_used_only_from_a_custom_target_path_is_retained() {
let cargo =
format!("{SCAFFOLD_CARGO}\n[[bin]]\nname = \"server\"\npath = \"cmd/server.rs\"\n");
let tmp = fake_project(SCAFFOLD_MAIN, &cargo);
std::fs::create_dir_all(tmp.path().join("cmd")).unwrap();
std::fs::write(
tmp.path().join("cmd/server.rs"),
"fn main() { let _ = autumn_admin_plugin::AdminPlugin::new(); }\n",
)
.unwrap();
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(
dependency_retained
.as_ref()
.is_some_and(|kept| kept.reason().contains("server.rs")),
"{dependency_retained:?}"
);
}
fn removal_with_bin_path(
path: &str,
main_rs: &str,
sibling: Option<(&str, &str)>,
) -> (Vec<Wire>, Option<DependencyKept>) {
let cargo = format!("{SCAFFOLD_CARGO}\n[[bin]]\nname = \"app\"\npath = \"{path}\"\n");
let tmp = fake_project(main_rs, &cargo);
if let Some((name, src)) = sibling {
std::fs::write(tmp.path().join("src").join(name), src).unwrap();
}
match plan_remove(tmp.path(), admin()).unwrap() {
RemoveOutcome::Removed {
removed,
dependency_retained,
..
} => (removed, dependency_retained),
other => panic!("expected Removed, got {other:?}"),
}
}
#[test]
fn an_explicit_main_target_does_not_retain_the_mount_being_removed() {
for path in ["src/main.rs", "./src/main.rs"] {
let (removed, kept) = removal_with_bin_path(path, SCAFFOLD_MAIN, None);
assert!(kept.is_none(), "{path}: {kept:?}");
assert!(removed.contains(&Wire::Dependency), "{path}: {removed:?}");
}
}
#[test]
fn an_explicit_main_target_that_still_uses_the_plugin_retains_it() {
let main =
format!("{SCAFFOLD_MAIN}\nfn extra() {{ let _ = autumn_admin_plugin::Other; }}\n");
let (_, kept) = removal_with_bin_path("src/main.rs", &main, None);
assert!(kept.is_some());
}
#[test]
fn an_explicit_main_target_with_a_using_sibling_module_retains_it() {
let sibling = (
"extra.rs",
"pub fn f() { let _ = autumn_admin_plugin::Other; }\n",
);
let (_, kept) = removal_with_bin_path("src/main.rs", SCAFFOLD_MAIN, Some(sibling));
assert!(kept.is_some_and(|kept| kept.reason().contains("extra.rs")));
}
#[test]
fn a_dependency_used_from_a_custom_targets_sibling_module_is_retained() {
let cargo =
format!("{SCAFFOLD_CARGO}\n[[bin]]\nname = \"server\"\npath = \"cmd/server.rs\"\n");
let tmp = fake_project(SCAFFOLD_MAIN, &cargo);
std::fs::create_dir_all(tmp.path().join("cmd")).unwrap();
std::fs::write(
tmp.path().join("cmd/server.rs"),
"mod panel;\nfn main() {}\n",
)
.unwrap();
std::fs::write(
tmp.path().join("cmd/panel.rs"),
"pub use autumn_admin_plugin::AdminPlugin;\n",
)
.unwrap();
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(dependency_retained.is_some(), "{outcome:?}");
}
#[test]
fn a_dependency_used_only_from_a_custom_build_script_is_retained() {
let cargo = SCAFFOLD_CARGO.replace(
"edition = \"2024\"",
"edition = \"2024\"\nbuild = \"tools/build.rs\"",
);
let tmp = fake_project(SCAFFOLD_MAIN, &cargo);
std::fs::create_dir_all(tmp.path().join("tools")).unwrap();
std::fs::write(
tmp.path().join("tools/build.rs"),
"fn main() { let _ = autumn_admin_plugin::VERSION; }\n",
)
.unwrap();
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(dependency_retained.is_some(), "{outcome:?}");
}
#[test]
fn a_custom_target_that_does_not_use_the_plugin_does_not_retain_it() {
let cargo =
format!("{SCAFFOLD_CARGO}\n[[bin]]\nname = \"server\"\npath = \"cmd/server.rs\"\n");
let tmp = fake_project(SCAFFOLD_MAIN, &cargo);
std::fs::create_dir_all(tmp.path().join("cmd")).unwrap();
std::fs::write(tmp.path().join("cmd/server.rs"), "fn main() {}\n").unwrap();
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
removed,
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(dependency_retained.is_none(), "{dependency_retained:?}");
assert!(removed.contains(&Wire::Dependency), "{removed:?}");
}
#[test]
fn a_dependency_reached_through_an_extern_crate_alias_is_retained() {
let tmp = fake_project(SCAFFOLD_MAIN, SCAFFOLD_CARGO);
std::fs::write(
tmp.path().join("src/support.rs"),
"extern crate autumn_admin_plugin as admin;\n\npub fn panel() -> admin::AdminPlugin { todo!() }\n",
)
.unwrap();
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
removed,
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert_eq!(removed, &vec![Wire::Mount]);
assert!(
dependency_retained
.as_ref()
.is_some_and(|kept| kept.reason().contains("support.rs")),
"{dependency_retained:?}"
);
}
#[test]
fn an_extern_crate_alias_in_a_custom_target_path_is_retained() {
let cargo =
format!("{SCAFFOLD_CARGO}\n[[bin]]\nname = \"server\"\npath = \"cmd/server.rs\"\n");
let tmp = fake_project(SCAFFOLD_MAIN, &cargo);
std::fs::create_dir_all(tmp.path().join("cmd")).unwrap();
std::fs::write(
tmp.path().join("cmd/server.rs"),
"extern crate autumn_admin_plugin as admin;\nfn main() { let _ = admin::AdminPlugin::new(); }\n",
)
.unwrap();
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(dependency_retained.is_some(), "{outcome:?}");
}
#[test]
fn a_dependency_reached_through_a_use_alias_is_retained() {
let tmp = fake_project(SCAFFOLD_MAIN, SCAFFOLD_CARGO);
std::fs::write(
tmp.path().join("src/support.rs"),
"use autumn_admin_plugin as admin;\n\npub fn panel() -> admin::AdminPlugin { todo!() }\n",
)
.unwrap();
let outcome = plan_remove(tmp.path(), admin()).unwrap();
let RemoveOutcome::Removed {
dependency_retained,
..
} = &outcome
else {
panic!("expected Removed, got {outcome:?}");
};
assert!(dependency_retained.is_some(), "{outcome:?}");
}
}