use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::new::{GenerateOptions, TemplateVars, framework_owned_files};
pub const MANIFEST_PATH: &str = ".autumn/scaffold.toml";
fn normalize(contents: &str) -> String {
contents.replace("\r\n", "\n")
}
#[must_use]
pub fn digest(contents: &str) -> String {
use sha2::{Digest as _, Sha256};
hex::encode(Sha256::digest(normalize(contents).as_bytes()))
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize, Deserialize)]
struct ManifestFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
written_by: Option<String>,
flavor: String,
#[serde(default)]
i18n: bool,
#[serde(default)]
seed: bool,
#[serde(default)]
daemon: bool,
#[serde(default)]
bundled_pg: bool,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pinned: BTreeSet<String>,
#[serde(default)]
files: BTreeMap<String, String>,
}
const SWAP_ATTEMPTS: u32 = 8;
const CLAIM_SUFFIX: &str = ".old";
const FLAVOR_API: &str = "api";
const FLAVOR_FULLSTACK: &str = "fullstack";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub version: Option<String>,
pub written_by: Option<String>,
pub options: GenerateOptions,
pub digests: BTreeMap<String, String>,
pub pinned: BTreeSet<String>,
}
impl Manifest {
#[must_use]
pub fn for_files<K: AsRef<str>>(
version: &str,
options: GenerateOptions,
files: &BTreeMap<K, String>,
) -> Self {
Self {
version: Some(version.to_owned()),
written_by: Some(version.to_owned()),
options,
digests: files
.iter()
.map(|(path, contents)| (path.as_ref().to_owned(), digest(contents)))
.collect(),
pinned: BTreeSet::new(),
}
}
#[must_use]
pub fn render(&self) -> String {
let file = ManifestFile {
version: self.version.clone(),
written_by: self.written_by.clone(),
flavor: if self.options.with_api {
FLAVOR_API
} else {
FLAVOR_FULLSTACK
}
.to_owned(),
i18n: self.options.with_i18n,
seed: self.options.with_seed,
daemon: self.options.with_daemon,
bundled_pg: self.options.with_bundled_pg,
files: self.digests.clone(),
pinned: self.pinned.clone(),
};
let body = toml::to_string(&file).unwrap_or_default();
format!(
"# Scaffold provenance for `autumn upgrade` (issue #1593).\n\
#\n\
# Written by `autumn new` and refreshed by `autumn upgrade --apply`.\n\
# Commit it: it records which release's scaffold produced this project's\n\
# framework-owned files, and a digest of each file as Autumn wrote it, so a\n\
# later upgrade can tell \"you edited this\" apart from \"the template moved\"\n\
# and never overwrite your work.\n\
#\n\
# Deleting or hand-editing this file costs precision, not correctness: an\n\
# upgrade with no baseline treats every changed file as a conflict to review.\n\
\n{body}"
)
}
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
let file: ManifestFile = toml::from_str(text).ok()?;
Some(Self {
version: file.version,
written_by: file.written_by,
options: GenerateOptions {
with_api: match file.flavor.as_str() {
FLAVOR_API => true,
FLAVOR_FULLSTACK => false,
_ => return None,
},
with_i18n: file.i18n,
with_seed: file.seed,
with_daemon: file.daemon,
with_bundled_pg: file.bundled_pg,
},
digests: file.files,
pinned: file.pinned,
})
.filter(|manifest: &Self| crate::new::check_option_combination(manifest.options).is_ok())
.filter(|manifest: &Self| {
[manifest.version.as_deref(), manifest.written_by.as_deref()]
.into_iter()
.flatten()
.all(|recorded| super::migrations::parse_version_req(recorded).is_some())
})
}
#[must_use]
pub fn load(root: &Path) -> Option<Self> {
match read_current(root, MANIFEST_PATH) {
OnDisk::Text(text) => Self::parse(&text),
_ => None,
}
}
pub fn save(&self, root: &Path) -> std::io::Result<()> {
Self::update(root, |_| Ok(self.clone())).map(drop)
}
pub fn update(
root: &Path,
mut change: impl FnMut(Option<Self>) -> Result<Self, String>,
) -> std::io::Result<Self> {
let path = root.join(MANIFEST_PATH);
for attempt in 1..=SWAP_ATTEMPTS {
let (current, mode) = match read_current(root, MANIFEST_PATH) {
OnDisk::Linked(_) => {
return Err(std::io::Error::other(format!(
"{MANIFEST_PATH} (or a directory on the way to it) is a symlink; \
writing through it could write outside the project"
)));
}
OnDisk::Opaque(_) => {
return Err(std::io::Error::other(claim_in_flight(&path).map_or_else(
|| {
format!(
"{MANIFEST_PATH} cannot be read as text; delete it to start again"
)
},
|held| interrupted(&held),
)));
}
OnDisk::Absent => match claim_in_flight(&path) {
Some(held) => return Err(std::io::Error::other(interrupted(&held))),
None => (None, Publish::Create),
},
OnDisk::Text(text) => (Self::parse(&text), Publish::Swap(text)),
};
let next = change(current).map_err(std::io::Error::other)?;
let rendered = next.render();
let unchanged = match &mode {
Publish::Swap(text) if normalize(text) == normalize(&rendered) => {
Some(text.clone())
}
_ => None,
};
if let Some(text) = unchanged {
if read_current(root, MANIFEST_PATH) == OnDisk::Text(text) {
return Ok(next);
}
back_off(attempt);
continue;
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
match publish(&path, &rendered, mode) {
Ok(()) => return Ok(next),
Err(PublishError::Moved(_)) => back_off(attempt),
Err(
PublishError::Late(error)
| PublishError::Retained(error)
| PublishError::Failed(error),
) => {
return Err(std::io::Error::other(error));
}
}
}
Err(std::io::Error::other(format!(
"{MANIFEST_PATH} kept changing; gave up after {SWAP_ATTEMPTS} tries"
)))
}
}
fn interrupted(held: &Path) -> String {
format!(
"an interrupted upgrade left {} in place of {MANIFEST_PATH}; \
if {MANIFEST_PATH} is still missing, rename it back. Otherwise \
inspect it first: a newer manifest may have replaced it",
held.display()
)
}
fn back_off(attempt: u32) {
std::thread::sleep(std::time::Duration::from_millis(5) * attempt);
}
fn claim_in_flight(path: &Path) -> Option<PathBuf> {
let prefix = claim_prefix(path)?;
std::fs::read_dir(path.parent()?)
.ok()?
.filter_map(Result::ok)
.map(|entry| entry.path())
.find(|held| {
held.file_name().is_some_and(|name| {
let name = name.to_string_lossy();
name.strip_prefix(&prefix)
.and_then(|rest| rest.strip_suffix(CLAIM_SUFFIX))
.is_some_and(|token| !token.contains('.'))
})
})
}
fn claim_prefix(path: &Path) -> Option<String> {
Some(format!(
".autumn-upgrade-{}.",
path.file_name()?.to_string_lossy()
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictReason {
Edited,
NoBaseline,
Unreadable,
Symlink,
MigratedThisRun,
Shadows,
}
impl ConflictReason {
#[must_use]
pub const fn describe(self) -> &'static str {
match self {
Self::Edited => "you changed this since it was scaffolded",
Self::NoBaseline => "no recorded baseline, so an edit cannot be ruled out",
Self::Unreadable => "on disk but unreadable as text, so its contents are unknown",
Self::Symlink => "a symlink; writing through it could write outside the project",
Self::MigratedThisRun => {
"this run's app-code migrations rewrote it; reconcile it by hand"
}
Self::Shadows => "shadows the workspace root copy; delete it",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
UpToDate,
Add,
Update,
Conflict(ConflictReason),
Removed,
Pinned,
}
impl Status {
#[must_use]
pub const fn is_applied(self) -> bool {
matches!(self, Self::Add | Self::Update)
}
#[must_use]
pub const fn is_drift(self) -> bool {
matches!(self, Self::Add | Self::Update | Self::Conflict(_))
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::UpToDate => "current",
Self::Add => "add",
Self::Update => "update",
Self::Conflict(_) => "conflict",
Self::Removed => "removed",
Self::Pinned => "pinned",
}
}
#[must_use]
pub const fn describe(self) -> &'static str {
match self {
Self::UpToDate => "matches the current scaffold",
Self::Add => "this release's scaffold has it; your project does not",
Self::Update => "the template changed and your copy is untouched",
Self::Conflict(reason) => reason.describe(),
Self::Removed => {
"you deleted this; it is not restored (drop its line from the manifest to be offered it again)"
}
Self::Pinned => "you accepted this file as yours; it is left alone",
}
}
}
#[derive(Debug, Clone)]
pub struct Entry {
pub path: String,
pub status: Status,
pub template: String,
current: OnDisk,
pub diff: String,
pub absolute: PathBuf,
}
fn project_name(root: &Path) -> Option<String> {
let name = std::fs::read_to_string(root.join("Cargo.toml"))
.ok()?
.parse::<toml::Table>()
.ok()?
.get("package")?
.get("name")?
.as_str()?
.to_owned();
crate::new::validate_name(&name).ok()?;
Some(name)
}
#[must_use]
pub fn resolve_options(root: &Path, manifest: Option<&Manifest>) -> GenerateOptions {
if let Some(manifest) = manifest {
return manifest.options;
}
let autumn_toml = std::fs::read_to_string(root.join("autumn.toml")).unwrap_or_default();
let bundled_pg = autumn_toml.contains("Managed local Postgres");
let fullstack = root.join("tailwind.config.js").exists()
|| root.join("static/css/input.css").exists()
|| root.join("static/js/htmx.min.js").exists();
GenerateOptions {
with_api: !fullstack,
with_i18n: root.join("i18n").is_dir(),
with_seed: false,
with_daemon: bundled_pg || autumn_toml.contains("this app uses no database"),
with_bundled_pg: bundled_pg,
}
}
const CARGO_ROOT_OWNED: &[&str] = &["clippy.toml", "rustfmt.toml", "rust-toolchain.toml"];
const GIT_ROOT_OWNED: &[&str] = &[
".github/workflows/ci.yml",
".github/workflows/posture-gate.yml",
];
#[derive(Debug, Clone, PartialEq, Eq)]
enum Owner {
Here,
Above(PathBuf),
OutOfScope,
}
struct RootOwners {
cargo: Owner,
workflows: Owner,
}
fn root_owners(root: &Path) -> RootOwners {
let Some(workspace) = workspace_root_above(root) else {
return RootOwners {
cargo: Owner::Here,
workflows: Owner::Here,
};
};
let out_of_scope = RootOwners {
cargo: Owner::OutOfScope,
workflows: Owner::OutOfScope,
};
let Ok(absolute) = root.canonicalize() else {
return out_of_scope;
};
let Some(git) = crate::git_root::find_git_root(&absolute) else {
return out_of_scope;
};
if git == absolute {
return RootOwners {
cargo: Owner::OutOfScope,
workflows: Owner::Here,
};
}
let cargo = if workspace.starts_with(&git) {
Owner::Above(workspace.clone())
} else {
Owner::OutOfScope
};
let workflows = if workspace == git
&& relative_dir(&git, &absolute).is_some_and(|dir| safe_in_yaml(&dir))
&& is_cargo_member(&workspace, &absolute)
{
Owner::Above(git)
} else {
Owner::OutOfScope
};
RootOwners { cargo, workflows }
}
fn is_cargo_member(workspace: &Path, member: &Path) -> bool {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let manifest = plain(member).join("Cargo.toml");
let Ok(output) = std::process::Command::new(cargo)
.args([
"metadata",
"--no-deps",
"--format-version",
"1",
"--manifest-path",
])
.arg(&manifest)
.output()
else {
return false;
};
if !output.status.success() {
return false;
}
let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
return false;
};
let same = |value: Option<&str>, expected: &Path| {
value
.and_then(|path| Path::new(path).canonicalize().ok())
.is_some_and(|path| plain(&path) == plain(expected))
};
same(metadata["workspace_root"].as_str(), workspace)
&& metadata["packages"].as_array().is_some_and(|packages| {
packages
.iter()
.any(|package| same(package["manifest_path"].as_str(), &manifest))
})
}
fn plain(path: &Path) -> PathBuf {
let text = path.to_string_lossy();
match text.strip_prefix(r"\\?\") {
Some(rest) if rest.as_bytes().get(1) == Some(&b':') => PathBuf::from(rest),
_ => path.to_path_buf(),
}
}
fn relative_dir(ancestor: &Path, descendant: &Path) -> Option<String> {
let parts: Option<Vec<&str>> = descendant
.strip_prefix(ancestor)
.ok()?
.components()
.map(|part| part.as_os_str().to_str())
.collect();
Some(parts?.join("/"))
}
fn safe_in_yaml(text: &str) -> bool {
!text.is_empty()
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'))
}
fn shadowing_copies(root: &Path) -> Vec<Entry> {
if !matches!(root_owners(root).cargo, Owner::Above(_)) {
return Vec::new();
}
CARGO_ROOT_OWNED
.iter()
.filter_map(|path| {
let current = read_current(root, path);
(current != OnDisk::Absent).then(|| Entry {
path: (*path).to_owned(),
status: Status::Conflict(ConflictReason::Shadows),
template: String::new(),
current,
diff: String::new(),
absolute: root.join(path),
})
})
.collect()
}
#[must_use]
pub fn workspace_root_above(root: &Path) -> Option<PathBuf> {
if declares_workspace(root) {
return None;
}
let absolute = root.canonicalize().ok()?;
absolute
.ancestors()
.skip(1)
.find(|ancestor| declares_workspace(ancestor))
.map(Path::to_path_buf)
}
fn declares_workspace(directory: &Path) -> bool {
std::fs::read_to_string(directory.join("Cargo.toml"))
.ok()
.and_then(|text| text.parse::<toml::Table>().ok())
.is_some_and(|table| table.contains_key("workspace"))
}
#[must_use]
pub fn current_files(root: &Path, options: GenerateOptions) -> Option<BTreeMap<String, String>> {
let name = project_name(root)?;
let crate_name = name.replace('-', "_");
let vars = TemplateVars {
project_name: &name,
crate_name: &crate_name,
autumn_version: env!("CARGO_PKG_VERSION"),
rust_version: option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("1.88.0"),
};
let mut files: BTreeMap<String, String> = framework_owned_files(&vars, options)
.into_iter()
.map(|(path, contents)| (path.to_owned(), contents))
.collect();
let owners = root_owners(root);
for (group, owner, is_workflow) in [
(CARGO_ROOT_OWNED, &owners.cargo, false),
(GIT_ROOT_OWNED, &owners.workflows, true),
] {
match owner {
Owner::Here => {}
Owner::OutOfScope => {
for path in group {
files.remove(*path);
}
}
Owner::Above(directory) => {
let placed = root.canonicalize().ok().and_then(|absolute| {
let below = absolute.strip_prefix(directory).ok()?;
let up = "../".repeat(below.components().count());
Some((up, relative_dir(directory, &absolute)?))
});
let Some((up, member_dir)) = placed else {
for path in group {
files.remove(*path);
}
continue;
};
for path in group {
let Some(contents) = files.remove(*path) else {
continue;
};
let contents = if is_workflow {
match crate::new::member_workflow(path, &contents, &name, &member_dir) {
Some(rewritten) => rewritten,
None => continue,
}
} else {
contents
};
files.insert(format!("{up}{path}"), contents);
}
}
}
}
Some(files)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum OnDisk {
Absent,
Text(String),
Linked(Option<String>),
Opaque(ConflictReason),
}
fn read_current(root: &Path, relative: &str) -> OnDisk {
for attempt in 1..SWAP_ATTEMPTS {
let now = read_current_once(root, relative);
if now != OnDisk::Absent {
return now;
}
if claim_in_flight(&root.join(relative)).is_none() {
return read_current_once(root, relative);
}
back_off(attempt);
}
let now = read_current_once(root, relative);
if now == OnDisk::Absent && claim_in_flight(&root.join(relative)).is_some() {
return OnDisk::Opaque(ConflictReason::Unreadable);
}
now
}
fn read_current_once(root: &Path, relative: &str) -> OnDisk {
let mut cursor = root.to_path_buf();
let components: Vec<&str> = relative.split('/').collect();
let (_leaf, parents) = components
.split_last()
.expect("a framework-owned path always has at least one component");
for directory in parents {
cursor.push(directory);
match std::fs::symlink_metadata(&cursor) {
Err(_) => break,
Ok(metadata) if metadata.file_type().is_symlink() => {
return OnDisk::Linked(read_text(&root.join(relative)));
}
Ok(_) => {}
}
}
let absolute = root.join(relative);
match std::fs::symlink_metadata(&absolute) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => OnDisk::Absent,
Err(_) => OnDisk::Opaque(ConflictReason::Unreadable),
Ok(metadata) if metadata.file_type().is_symlink() => OnDisk::Linked(read_text(&absolute)),
Ok(_) => {
read_text(&absolute).map_or(OnDisk::Opaque(ConflictReason::Unreadable), OnDisk::Text)
}
}
}
fn read_text(absolute: &Path) -> Option<String> {
std::fs::read_to_string(absolute)
.ok()
.map(|text| normalize(&text))
}
#[derive(Debug, Clone)]
pub struct Planned {
pub original: String,
pub updated: String,
}
#[must_use]
pub fn classify(
root: &Path,
files: &BTreeMap<String, String>,
manifest: Option<&Manifest>,
migrated: &BTreeMap<String, Planned>,
) -> Vec<Entry> {
let recorded = |path: &str| manifest.and_then(|manifest| manifest.digests.get(path));
let pinned = |path: &str| manifest.is_some_and(|manifest| manifest.pinned.contains(path));
let mut entries: Vec<Entry> = files
.iter()
.map(|(path, template)| {
let absolute = root.join(path);
let current = read_current(root, path);
let planned = migrated.get(path);
let seen = match (¤t, planned) {
(OnDisk::Text(text), Some(plan))
if *text == normalize(&plan.original) || *text == normalize(&plan.updated) =>
{
OnDisk::Text(normalize(&plan.updated))
}
_ => current.clone(),
};
let normalized = normalize(template);
let differs = |text: &str| {
let diff = super::diff::render(text, template);
(text != normalized, diff)
};
let (status, diff) = match &seen {
OnDisk::Text(text) if *text == normalized => (Status::UpToDate, String::new()),
_ if pinned(path) => (Status::Pinned, String::new()),
OnDisk::Absent if recorded(path).is_some() => (Status::Removed, String::new()),
OnDisk::Absent => (Status::Add, super::diff::render("", template)),
OnDisk::Opaque(reason) => (Status::Conflict(*reason), String::new()),
OnDisk::Linked(text) => (
Status::Conflict(ConflictReason::Symlink),
text.as_ref()
.map(|text| differs(text).1)
.unwrap_or_default(),
),
OnDisk::Text(text) => {
let status = match recorded(path) {
_ if planned.is_some() => Status::Conflict(ConflictReason::MigratedThisRun),
Some(baseline) if *baseline == digest(text) => Status::Update,
Some(_) => Status::Conflict(ConflictReason::Edited),
None => Status::Conflict(ConflictReason::NoBaseline),
};
(status, differs(text).1)
}
};
Entry {
path: path.clone(),
status,
template: normalize(template),
current,
diff,
absolute,
}
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
entries
}
#[must_use]
pub fn is_project(root: &Path) -> bool {
let manifest = root.join(MANIFEST_PATH);
root.join("autumn.toml").is_file() || manifest.is_file() || claim_in_flight(&manifest).is_some()
}
#[must_use]
pub fn release_guide(target: &str) -> String {
let release = super::migrations::parse_version_req(target);
let file =
release.map(|version| format!("docs/migrations/{}.{}.0.md", version.major, version.minor));
let known = file.as_ref().is_some_and(|file| {
super::migrations::app_migrations()
.iter()
.any(|migration| migration.guide.starts_with(file.as_str()))
});
match file {
Some(file) if known => super::migrations::guide_url(&file),
_ => super::migrations::guide_url("docs/migrations/README.md"),
}
}
#[derive(Debug, Clone)]
pub struct ScaffoldReport {
pub root: PathBuf,
pub baseline: Option<String>,
pub target: String,
pub has_manifest: bool,
pub options: GenerateOptions,
pub named: bool,
pub scaffolded_by_newer: Option<String>,
pub workspace_member: bool,
pub entries: Vec<Entry>,
pub outcome: super::Outcome,
pub guide: String,
}
impl ScaffoldReport {
#[must_use]
pub fn drifted(&self) -> bool {
drifted(&self.entries)
}
#[must_use]
pub fn applicable(&self) -> Vec<&Entry> {
self.entries
.iter()
.filter(|entry| entry.status.is_applied())
.collect()
}
#[must_use]
pub fn conflicts(&self) -> Vec<&Entry> {
self.entries
.iter()
.filter(|entry| matches!(entry.status, Status::Conflict(_)))
.collect()
}
#[must_use]
pub fn needs_baseline_note(&self) -> bool {
!self.has_manifest
|| self
.entries
.iter()
.any(|entry| entry.status == Status::Conflict(ConflictReason::NoBaseline))
}
#[must_use]
pub fn changed(&self) -> Vec<&Entry> {
self.entries
.iter()
.filter(|entry| entry.status != Status::UpToDate)
.collect()
}
fn next_manifest(&self, previous: Option<&Manifest>) -> Manifest {
let owned: BTreeSet<&str> = self
.entries
.iter()
.map(|entry| entry.path.as_str())
.collect();
let mut digests: BTreeMap<String, String> = previous
.map(|manifest| {
manifest
.digests
.iter()
.filter(|(path, _)| owned.contains(path.as_str()))
.map(|(path, digest)| (path.clone(), digest.clone()))
.collect()
})
.unwrap_or_default();
for entry in &self.entries {
if entry.status.is_applied() || entry.status == Status::UpToDate {
digests.insert(entry.path.clone(), digest(&entry.template));
}
}
let version = if self.conflicts().is_empty() {
Some(self.target.clone())
} else {
previous.and_then(|manifest| manifest.version.clone())
};
let written_by = Some(newest(
previous.and_then(|m| m.written_by.as_deref()),
&self.target,
));
Manifest {
version,
written_by,
options: self.options,
digests,
pinned: previous
.map(|manifest| manifest.pinned.clone())
.unwrap_or_default(),
}
}
}
#[derive(Debug, Clone)]
pub struct WriteFailure {
pub path: String,
pub error: String,
pub written: usize,
}
fn newer_release_recorded(manifest: Option<&Manifest>) -> Option<String> {
manifest
.and_then(|manifest| match (&manifest.written_by, &manifest.version) {
(Some(written_by), Some(version)) => Some(newest(Some(version), written_by)),
(recorded, None) | (None, recorded) => recorded.clone(),
})
.filter(|recorded| is_newer_than_this_cli(recorded))
}
#[must_use]
pub fn plan(root: &Path, target: &str) -> ScaffoldReport {
plan_after(root, target, &BTreeMap::new())
}
#[must_use]
pub fn plan_after(
root: &Path,
_target: &str,
migrated: &BTreeMap<String, Planned>,
) -> ScaffoldReport {
let target = env!("CARGO_PKG_VERSION").to_owned();
let manifest = Manifest::load(root);
let options = resolve_options(root, manifest.as_ref());
let scaffolded_by_newer = newer_release_recorded(manifest.as_ref());
let files = scaffolded_by_newer
.is_none()
.then(|| current_files(root, options))
.flatten();
ScaffoldReport {
root: root.to_path_buf(),
options,
baseline: manifest.as_ref().and_then(|m| m.version.clone()),
named: scaffolded_by_newer.is_some() || files.is_some(),
scaffolded_by_newer,
workspace_member: workspace_root_above(root).is_some(),
entries: files
.map(|files| {
let mut entries = classify(root, &files, manifest.as_ref(), migrated);
entries.extend(shadowing_copies(root));
entries.sort_by(|a, b| a.path.cmp(&b.path));
entries
})
.unwrap_or_default(),
guide: release_guide(&target),
target,
has_manifest: manifest.is_some(),
outcome: super::Outcome::Preview,
}
}
fn newest(previous: Option<&str>, candidate: &str) -> String {
match previous {
Some(previous) if is_newer(previous, candidate) => previous.to_owned(),
_ => candidate.to_owned(),
}
}
fn is_newer_than_this_cli(recorded: &str) -> bool {
is_newer(recorded, env!("CARGO_PKG_VERSION"))
}
fn is_newer(recorded: &str, ours: &str) -> bool {
let parse = super::migrations::parse_version_req;
let (Some(recorded), Some(ours)) = (parse(recorded), parse(ours)) else {
return false;
};
precedence(&recorded, &ours) == std::cmp::Ordering::Greater
}
fn precedence(
left: &super::migrations::Version,
right: &super::migrations::Version,
) -> std::cmp::Ordering {
use std::cmp::Ordering;
(left.major, left.minor, left.patch)
.cmp(&(right.major, right.minor, right.patch))
.then_with(
|| match (left.prerelease.as_deref(), right.prerelease.as_deref()) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(left), Some(right)) => prerelease_precedence(left, right),
},
)
}
fn prerelease_precedence(left: &str, right: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let mut left = left.split('.');
let mut right = right.split('.');
loop {
let ordering = match (left.next(), right.next()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(left), Some(right)) => match (left.parse::<u64>(), right.parse::<u64>()) {
(Ok(left), Ok(right)) => left.cmp(&right),
(Ok(_), Err(_)) => Ordering::Less,
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => left.cmp(right),
},
};
if ordering != Ordering::Equal {
return ordering;
}
}
}
pub fn accept(root: &Path, paths: &[String]) -> Result<Manifest, String> {
let mut refusal = None;
let updated = Manifest::update(root, |current| {
let options = resolve_options(root, current.as_ref());
let Some(owned) = current_files(root, options) else {
refusal = Some(
"this project's `Cargo.toml` gives no usable `[package] name`, so the \
scaffold cannot be rendered and there is nothing to accept against"
.to_owned(),
);
return Err(String::new());
};
let unknown: Vec<&str> = paths
.iter()
.map(String::as_str)
.filter(|path| !owned.contains_key(*path))
.collect();
if !unknown.is_empty() {
let hints: Vec<String> = unknown
.iter()
.filter_map(|path| {
owned
.keys()
.find(|key| key.starts_with("../") && key.ends_with(&format!("/{path}")))
.map(|key| format!("`{path}` is `{key}` here"))
})
.collect();
let hint = if hints.is_empty() {
String::new()
} else {
format!(" ({})", hints.join("; "))
};
refusal = Some(format!(
"not framework-owned in this project, so there is nothing to accept: {}{hint}",
unknown.join(", ")
));
return Err(String::new());
}
let mut manifest = current.unwrap_or_else(|| Manifest {
version: None,
written_by: None,
options,
digests: BTreeMap::new(),
pinned: BTreeSet::new(),
});
manifest.options = options;
manifest.pinned.extend(paths.iter().cloned());
Ok(manifest)
});
match (refusal, updated) {
(Some(refusal), _) => Err(refusal),
(None, Ok(manifest)) => Ok(manifest),
(None, Err(error)) => Err(format!("could not write {MANIFEST_PATH}: {error}")),
}
}
pub fn apply(report: &mut ScaffoldReport) -> Result<(), WriteFailure> {
for (written, entry) in report
.entries
.iter()
.filter(|entry| entry.status.is_applied())
.enumerate()
{
if let Err(error) = write_one(entry) {
let written = written + usize::from(error.published);
report.outcome = super::Outcome::Partial { written };
return Err(WriteFailure {
path: entry.path.clone(),
error: error.message,
written,
});
}
}
let written = report.applicable().len();
report.outcome = super::Outcome::Applied;
record_baseline(report).map_err(|error| WriteFailure {
path: MANIFEST_PATH.to_owned(),
error,
written,
})
}
fn record_baseline(report: &ScaffoldReport) -> Result<(), String> {
if report.entries.is_empty() {
return Ok(());
}
Manifest::update(&report.root, |previous| {
if let Some(newer) = newer_release_recorded(previous.as_ref()) {
return Err(format!("a newer release ({newer}) updated this project"));
}
Ok(report.next_manifest(previous.as_ref()))
})
.map(drop)
.map_err(|error| {
format!(
"the scaffold files were written, but the baseline could not be recorded: \
{error}. Until it is, every file this run updated will be reported as a \
conflict and `--check` will not go green."
)
})
}
fn write_one(entry: &Entry) -> Result<(), WriteError> {
let on_disk = read_current_absolute(entry);
if on_disk != entry.current {
return Err(WriteError::from(match entry.current {
OnDisk::Absent => "something appeared at this path after the preview was \
computed; it was left exactly as it is"
.to_owned(),
_ => "this file changed after the preview was computed; \
it was left exactly as it is"
.to_owned(),
}));
}
if matches!(on_disk, OnDisk::Opaque(_) | OnDisk::Linked(_)) {
return Err(WriteError::from(
"this path is a symlink or is unreadable; it was left exactly as it is".to_owned(),
));
}
let directory = entry
.absolute
.parent()
.ok_or_else(|| "no parent directory".to_owned())?;
std::fs::create_dir_all(directory).map_err(|error| error.to_string())?;
let mode = match &entry.current {
OnDisk::Text(text) => Publish::Swap(text.clone()),
_ => Publish::Create,
};
publish(&entry.absolute, &entry.template, mode).map_err(|error| WriteError {
published: error.published(),
message: error.to_string(),
})
}
struct WriteError {
message: String,
published: bool,
}
impl From<String> for WriteError {
fn from(message: String) -> Self {
Self {
message,
published: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Publish {
Create,
Swap(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PublishError {
Moved(String),
Late(String),
#[cfg_attr(windows, allow(dead_code))]
Retained(String),
Failed(String),
}
impl PublishError {
const fn published(&self) -> bool {
matches!(self, Self::Late(_))
}
}
impl From<std::io::Error> for PublishError {
fn from(error: std::io::Error) -> Self {
Self::Failed(error.to_string())
}
}
impl std::fmt::Display for PublishError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (Self::Moved(text) | Self::Late(text) | Self::Retained(text) | Self::Failed(text)) =
self;
f.write_str(text)
}
}
fn publish(absolute: &Path, contents: &str, mode: Publish) -> Result<(), PublishError> {
use std::io::Write as _;
let directory = absolute
.parent()
.ok_or_else(|| PublishError::Failed("no parent directory".to_owned()))?;
let mut temp = tempfile::Builder::new()
.prefix(".autumn-upgrade-")
.suffix(".tmp")
.make_in(directory, |path| {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
})?;
if let Ok(metadata) = std::fs::metadata(absolute) {
let _ = temp.as_file().set_permissions(metadata.permissions());
}
temp.write_all(contents.as_bytes())?;
temp.flush()?;
temp.as_file().sync_all()?;
match mode {
Publish::Create => temp
.persist_noclobber(absolute)
.map(drop)
.map_err(|error| {
if error.error.kind() == std::io::ErrorKind::AlreadyExists {
PublishError::Moved(
"something appeared at this path while it was being written; \
it was left exactly as it is"
.to_owned(),
)
} else {
error.error.into()
}
})?,
Publish::Swap(expected) => swap(temp, absolute, &expected)?,
}
Ok(())
}
#[cfg(not(windows))]
fn swap(
temp: tempfile::NamedTempFile,
absolute: &Path,
expected: &str,
) -> Result<(), PublishError> {
let staged = temp.into_temp_path();
let directory = absolute
.parent()
.ok_or_else(|| PublishError::Failed("no parent directory".to_owned()))?;
let claim = tempfile::Builder::new()
.prefix(
&claim_prefix(absolute)
.ok_or_else(|| PublishError::Failed("no file name".to_owned()))?,
)
.suffix(CLAIM_SUFFIX)
.tempfile_in(directory)?
.into_temp_path();
let _ = std::fs::remove_file(&claim);
match std::fs::rename(absolute, &claim) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(PublishError::Moved(
"this file disappeared after the preview was computed".to_owned(),
));
}
Err(error) => return Err(error.into()),
}
let held = std::fs::symlink_metadata(&claim).is_ok_and(|meta| meta.file_type().is_file())
&& read_text(&claim).is_some_and(|text| text == normalize(expected));
if !held {
return Err(match claim.persist_noclobber(absolute) {
Ok(()) => PublishError::Moved(
"this file changed after the preview was computed; \
it was left exactly as it is"
.to_owned(),
),
Err(error) => {
let restore = error.error.to_string();
let present = error.path.exists();
match error.path.keep() {
Ok(path) => PublishError::Retained(format!(
"this file changed twice after the preview was computed; \
the earlier copy is at {}",
path.display()
)),
Err(error) => PublishError::Failed(format!(
"could not restore the displaced copy ({restore}) or keep it \
(still there: {present}): {error}"
)),
}
}
});
}
match staged.persist_noclobber(absolute) {
Ok(()) => finish_claim(claim, expected),
Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {
finish_claim(claim, expected).map_err(|error| match error {
PublishError::Late(message) => PublishError::Retained(message),
other => other,
})?;
Err(PublishError::Moved(
"something appeared at this path while it was being written; \
it was left exactly as it is"
.to_owned(),
))
}
Err(error) => {
let staging = error.error.to_string();
Err(match claim.persist_noclobber(absolute) {
Ok(()) => PublishError::Failed(staging),
Err(restore) => match restore.path.keep() {
Ok(path) => PublishError::Retained(format!(
"{staging}; the earlier copy is at {}",
path.display()
)),
Err(kept) => PublishError::Failed(format!(
"{staging}; the earlier copy could not be kept: {kept}"
)),
},
})
}
}
}
#[cfg(windows)]
fn swap(
temp: tempfile::NamedTempFile,
absolute: &Path,
expected: &str,
) -> Result<(), PublishError> {
let unchanged = std::fs::symlink_metadata(absolute)
.is_ok_and(|meta| meta.file_type().is_file())
&& read_text(absolute).is_some_and(|text| text == normalize(expected));
if !unchanged {
return Err(PublishError::Moved(
"this file changed after the preview was computed; \
it was left exactly as it is"
.to_owned(),
));
}
temp.persist(absolute)
.map(drop)
.map_err(|error| PublishError::Failed(error.error.to_string()))
}
#[cfg_attr(windows, allow(dead_code))]
fn finish_claim(claim: tempfile::TempPath, expected: &str) -> Result<(), PublishError> {
match read_text(&claim) {
Some(text) if text == normalize(expected) => return Ok(()),
None if !claim.exists() => return Ok(()),
_ => {}
}
match claim.keep() {
Ok(path) => Err(PublishError::Late(format!(
"this file was written during the swap; the earlier copy is at {}",
path.display()
))),
Err(error) => Err(PublishError::Late(format!(
"this file was written during the swap, and the earlier copy could not \
be kept: {error}"
))),
}
}
fn read_current_absolute(entry: &Entry) -> OnDisk {
let mut root = entry.absolute.clone();
for _ in 0..=entry.path.matches('/').count() {
root.pop();
}
read_current(&root, &entry.path)
}
#[must_use]
pub fn render_text(report: &ScaffoldReport) -> String {
render(report, true)
}
#[must_use]
pub fn render_summary(report: &ScaffoldReport) -> String {
render(report, false)
}
fn render(report: &ScaffoldReport, diffs: bool) -> String {
use std::fmt::Write as _;
let mut out = String::new();
if let Some(newer) = &report.scaffolded_by_newer {
let _ = writeln!(out, "\nScaffold files");
let _ = writeln!(
out,
" Skipped: this project was scaffolded by autumn-cli {newer}, which is newer\n \
than this one ({}). Reconciling would render older templates over newer\n \
files — a downgrade, which this command does not do. Install {newer} or\n \
later and run it again.",
report.target
);
let _ = writeln!(out, " Upgrade guide: {}", report.guide);
return out;
}
if !report.named {
let _ = writeln!(out, "\nScaffold files");
let _ = writeln!(
out,
" Skipped: this project's `Cargo.toml` does not give a usable `[package] name`,\n and the scaffold interpolates it. Comparing against a guessed name would\n report files as changed that are not — or worse, rewrite them."
);
return out;
}
let from = report
.baseline
.clone()
.unwrap_or_else(|| "unknown".to_owned());
let _ = writeln!(
out,
"\nScaffold files ({from} -> {target})",
target = report.target
);
if report.needs_baseline_note() {
let _ = writeln!(
out,
" This project predates scaffold provenance, so there is no record of\n \
what Autumn originally wrote. Files it is missing are offered; every\n \
file that differs is a conflict for you to review."
);
}
if report.workspace_member {
render_workspace_note(&mut out, &report.root);
}
let changed = report.changed();
if changed.is_empty() {
let _ = writeln!(
out,
" Your framework-owned files are up to date with this release."
);
let _ = writeln!(out, " Upgrade guide: {}", report.guide);
return out;
}
let width = changed.iter().map(|e| e.path.len()).max().unwrap_or(0);
let _ = writeln!(out, "\n {} file(s) differ:", changed.len());
for entry in &changed {
let _ = writeln!(
out,
" {:<9} {:<width$} {}",
entry.status.label(),
entry.path,
entry.status.describe(),
);
}
if diffs {
for entry in &changed {
if entry.diff.is_empty() {
continue;
}
let _ = writeln!(out, "\n{} ({})", entry.path, entry.status.label());
out.push_str(&entry.diff);
}
}
render_outcome(&mut out, report, diffs);
let _ = writeln!(out, "Upgrade guide: {}", report.guide);
out
}
fn render_workspace_note(out: &mut String, root: &Path) {
use std::fmt::Write as _;
let owners = root_owners(root);
let _ = match owners.cargo {
Owner::Above(_) => writeln!(
out,
" This crate is in a Cargo workspace. clippy.toml, rustfmt.toml and\n \
rust-toolchain.toml are planned at the workspace root (the ../ paths):\n \
a copy in the crate would shadow the workspace's. A root file that\n \
another member wrote is a conflict. The command does not write it.\n \
Use --accept to leave it to that member."
),
_ => writeln!(
out,
" This crate is in a Cargo workspace. clippy.toml, rustfmt.toml and\n \
rust-toolchain.toml are out of scope: a crate-local copy would shadow\n \
the workspace's copy. Reconcile them at the workspace root."
),
};
match owners.workflows {
Owner::Above(_) => {
let _ = writeln!(
out,
" The CI workflows are planned at the git root (the ../ paths): GitHub\n \
runs them only from there. They name this crate, so one workflow\n \
serves one member."
);
}
Owner::OutOfScope => {
let _ = writeln!(
out,
" The CI workflows are out of scope too. They need a git repository\n \
whose root is the workspace root, and a plain directory path. Reconcile\n \
them by hand at the git root."
);
}
Owner::Here => {}
}
}
fn render_outcome(out: &mut String, report: &ScaffoldReport, diffs: bool) {
use std::fmt::Write as _;
let applicable = report.applicable().len();
let conflicts = report.conflicts().len();
let _ = writeln!(out);
match report.outcome {
super::Outcome::Preview if applicable == 0 && conflicts == 0 => {
let _ = writeln!(
out,
"Nothing to do: everything above is a file you removed or accepted as your own."
);
}
super::Outcome::Preview if applicable == 0 => {
let _ = writeln!(
out,
"{conflicts} conflict(s) need review; nothing here can be written for you."
);
let _ = writeln!(out, "{}", review_advice(diffs));
}
super::Outcome::Preview => {
let _ = writeln!(
out,
"{applicable} file(s) would be written; {conflicts} conflict(s) need review."
);
let _ = writeln!(
out,
"Nothing was written. Re-run with `--apply` to take the writable ones."
);
let _ = writeln!(out, "{}", review_advice(diffs));
}
super::Outcome::Applied => {
let _ = writeln!(
out,
"{applicable} file(s) written; {conflicts} conflict(s) left for you."
);
let _ = writeln!(out, "{REVERT_ADVICE}");
}
super::Outcome::Partial { written } => {
let _ = writeln!(
out,
"{written} of {applicable} file(s) written before the run stopped."
);
let _ = writeln!(out, "{REVERT_ADVICE}");
}
}
if conflicts > 0 {
let _ = writeln!(
out,
"Conflicts are never overwritten. Take what you want from this release's\n\
version, or `autumn upgrade --accept <path>` to keep yours for good."
);
}
}
const REVERT_ADVICE: &str = "`git status` and `git diff` show everything this touched: \
`git checkout -- <path>`\nrestores an updated file, `rm <path>` \
removes an added one.";
const fn review_advice(diffs: bool) -> &'static str {
if diffs {
"Each file's diff is above; `git status` and `git diff` show what is yours."
} else {
"Re-run without `--check` to see each file's diff."
}
}
#[must_use]
pub fn json(report: &ScaffoldReport) -> serde_json::Value {
serde_json::json!({
"baseline": report.baseline,
"target": report.target,
"named": report.named,
"scaffolded_by_newer": report.scaffolded_by_newer,
"has_manifest": report.has_manifest,
"outcome": report.outcome.label(),
"drift": report.drifted(),
"workspace_member": report.workspace_member,
"writable": report.applicable().len(),
"written": match report.outcome {
super::Outcome::Preview => 0,
super::Outcome::Applied => report.applicable().len(),
super::Outcome::Partial { written } => written,
},
"conflicts": report.conflicts().len(),
"pinned": report
.entries
.iter()
.filter(|entry| entry.status == Status::Pinned)
.count(),
"guide": report.guide,
"files": report
.entries
.iter()
.map(|entry| serde_json::json!({
"path": entry.path,
"outside_project": entry.path.starts_with("../"),
"status": entry.status.label(),
"reason": entry.status.describe(),
"applied": entry.status.is_applied(),
}))
.collect::<Vec<_>>(),
})
}
#[must_use]
pub fn drifted(entries: &[Entry]) -> bool {
entries.iter().any(|entry| entry.status.is_drift())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use crate::new::GenerateOptions;
use crate::upgrade::Outcome;
fn write(root: &std::path::Path, rel: &str, contents: &str) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
#[test]
fn manifest_round_trips_through_toml() {
let mut digests = BTreeMap::new();
digests.insert("clippy.toml".to_owned(), digest("a\n"));
digests.insert(".github/workflows/ci.yml".to_owned(), digest("b\n"));
let manifest = Manifest {
version: Some("0.7.0".to_owned()),
written_by: Some("0.7.0".to_owned()),
options: GenerateOptions {
with_api: true,
with_i18n: true,
..GenerateOptions::default()
},
digests,
pinned: BTreeSet::new(),
};
let parsed = Manifest::parse(&manifest.render()).expect("round trip");
assert_eq!(parsed.version, manifest.version);
assert_eq!(parsed.options, manifest.options);
assert_eq!(parsed.digests, manifest.digests);
}
#[test]
fn manifest_keys_with_dots_and_slashes_survive_the_round_trip() {
let mut digests = BTreeMap::new();
digests.insert(".github/workflows/ci.yml".to_owned(), digest("x"));
digests.insert(".env.example".to_owned(), digest("y"));
digests.insert("static/css/input.css".to_owned(), digest("z"));
let manifest = Manifest {
version: Some("0.7.0".to_owned()),
written_by: Some("0.7.0".to_owned()),
options: GenerateOptions::default(),
digests: digests.clone(),
pinned: BTreeSet::new(),
};
assert_eq!(
Manifest::parse(&manifest.render()).unwrap().digests,
digests
);
}
#[test]
fn digest_ignores_line_ending_style() {
assert_eq!(digest("a\r\nb\r\n"), digest("a\nb\n"));
assert_ne!(digest("a\n"), digest("b\n"));
}
#[test]
fn a_missing_or_unparsable_manifest_is_absent_not_an_error() {
let tmp = TempDir::new().unwrap();
assert!(Manifest::load(tmp.path()).is_none());
write(tmp.path(), MANIFEST_PATH, "this is not : toml [[[");
assert!(Manifest::load(tmp.path()).is_none());
}
#[test]
fn manifest_is_written_and_read_back_from_disk() {
let tmp = TempDir::new().unwrap();
let mut digests = BTreeMap::new();
digests.insert("clippy.toml".to_owned(), digest("a\n"));
let manifest = Manifest {
version: Some("0.7.0".to_owned()),
written_by: Some("0.7.0".to_owned()),
options: GenerateOptions::default(),
digests,
pinned: BTreeSet::new(),
};
manifest.save(tmp.path()).unwrap();
let loaded = Manifest::load(tmp.path()).expect("written manifest loads");
assert_eq!(loaded.digests, manifest.digests);
assert_eq!(loaded.version.as_deref(), Some("0.7.0"));
}
fn scaffolded(opts: GenerateOptions) -> TempDir {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"Cargo.toml",
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\n",
);
let files = current_files(tmp.path(), opts).expect("named project");
for (path, contents) in &files {
write(tmp.path(), path, contents);
}
Manifest::for_files(env!("CARGO_PKG_VERSION"), opts, &files)
.save(tmp.path())
.unwrap();
tmp
}
fn status_of<'a>(entries: &'a [Entry], path: &str) -> &'a Status {
&entries
.iter()
.find(|entry| entry.path == path)
.unwrap_or_else(|| {
panic!(
"no entry for {path}: {:?}",
entries.iter().map(|e| &e.path).collect::<Vec<_>>()
)
})
.status
}
#[test]
fn a_freshly_scaffolded_project_has_no_drift() {
let tmp = scaffolded(GenerateOptions::default());
let entries = plan_in(tmp.path()).entries;
assert!(
entries.iter().all(|entry| entry.status == Status::UpToDate),
"{:?}",
entries
.iter()
.filter(|e| e.status != Status::UpToDate)
.map(|e| (&e.path, &e.status))
.collect::<Vec<_>>()
);
assert!(!drifted(&entries));
}
#[test]
fn a_file_the_old_release_never_generated_is_offered_as_an_addition() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rust-toolchain.toml")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rust-toolchain.toml");
manifest.version = Some("0.5.0".to_owned());
manifest.save(tmp.path()).unwrap();
let entries = plan_in(tmp.path()).entries;
assert_eq!(status_of(&entries, "rust-toolchain.toml"), &Status::Add);
assert!(drifted(&entries));
}
#[test]
fn an_untouched_file_whose_template_moved_is_an_update() {
let tmp = scaffolded(GenerateOptions::default());
let old = "# an older release's clippy.toml\n";
write(tmp.path(), "clippy.toml", old);
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest
.digests
.insert("clippy.toml".to_owned(), digest(old));
manifest.save(tmp.path()).unwrap();
let entries = plan_in(tmp.path()).entries;
assert_eq!(status_of(&entries, "clippy.toml"), &Status::Update);
}
#[test]
fn a_file_the_developer_edited_is_a_conflict_never_an_update() {
let tmp = scaffolded(GenerateOptions::default());
write(tmp.path(), "Dockerfile", "FROM scratch\n# my own build\n");
let entries = plan_in(tmp.path()).entries;
assert_eq!(
status_of(&entries, "Dockerfile"),
&Status::Conflict(ConflictReason::Edited)
);
}
#[test]
fn a_project_with_no_manifest_gets_conflicts_not_silent_overwrites() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join(MANIFEST_PATH)).unwrap();
write(tmp.path(), "clippy.toml", "# pre-manifest project\n");
let entries = plan_in(tmp.path()).entries;
assert_eq!(
status_of(&entries, "clippy.toml"),
&Status::Conflict(ConflictReason::NoBaseline)
);
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let entries = plan_in(tmp.path()).entries;
assert_eq!(status_of(&entries, "rustfmt.toml"), &Status::Add);
}
#[test]
fn a_file_the_developer_deleted_is_reported_but_not_restored() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join(".env.example")).unwrap();
let entries = plan_in(tmp.path()).entries;
assert_eq!(status_of(&entries, ".env.example"), &Status::Removed);
assert!(
!entries
.iter()
.any(|entry| entry.path == ".env.example" && entry.status.is_applied()),
"a deliberate deletion must never be written back"
);
}
#[test]
fn classification_never_names_a_path_under_src() {
let tmp = scaffolded(GenerateOptions::default());
write(tmp.path(), "src/main.rs", "fn main() {}\n");
for entry in plan_in(tmp.path()).entries {
assert!(!entry.path.starts_with("src/"), "{}", entry.path);
}
}
#[test]
fn api_projects_are_detected_without_a_manifest() {
let tmp = scaffolded(GenerateOptions {
with_api: true,
..GenerateOptions::default()
});
fs::remove_file(tmp.path().join(MANIFEST_PATH)).unwrap();
let entries = plan_in(tmp.path()).entries;
assert!(
!entries
.iter()
.any(|entry| entry.path == "tailwind.config.js"),
"an API project must not be offered the Tailwind config"
);
}
#[test]
fn i18n_projects_are_detected_without_a_manifest() {
let tmp = scaffolded(GenerateOptions {
with_i18n: true,
..GenerateOptions::default()
});
fs::remove_file(tmp.path().join(MANIFEST_PATH)).unwrap();
write(tmp.path(), "i18n/en.ftl", "welcome = hi\n");
let entries = plan_in(tmp.path()).entries;
assert_eq!(status_of(&entries, "autumn.toml"), &Status::UpToDate);
}
#[test]
fn the_project_name_is_read_from_cargo_toml_not_the_directory() {
let tmp = scaffolded(GenerateOptions::default());
let entries = plan_in(tmp.path()).entries;
assert_eq!(status_of(&entries, "autumn.toml"), &Status::UpToDate);
}
fn plan_in(root: &std::path::Path) -> ScaffoldReport {
plan(root, env!("CARGO_PKG_VERSION"))
}
#[test]
fn a_directory_without_autumn_files_is_not_a_project() {
let tmp = TempDir::new().unwrap();
assert!(!is_project(tmp.path()));
write(tmp.path(), "autumn.toml", "[server]\n");
assert!(is_project(tmp.path()));
}
#[test]
fn a_project_known_only_by_its_manifest_is_still_a_project() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
MANIFEST_PATH,
"version = \"0.7.0\"\nflavor = \"api\"\n",
);
assert!(is_project(tmp.path()));
}
#[test]
fn the_report_records_the_recorded_baseline_and_the_target() {
let tmp = scaffolded(GenerateOptions::default());
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.version = Some("0.5.0".to_owned());
manifest.save(tmp.path()).unwrap();
let report = plan_in(tmp.path());
assert_eq!(report.baseline.as_deref(), Some("0.5.0"));
assert_eq!(report.target, env!("CARGO_PKG_VERSION"));
}
#[test]
fn the_summary_links_the_release_upgrade_guide() {
let tmp = scaffolded(GenerateOptions::default());
let report = plan_in(tmp.path());
assert!(
report
.guide
.contains(&format!("docs/migrations/{}.md", env!("CARGO_PKG_VERSION"))),
"{}",
report.guide
);
assert!(render_text(&report).contains(&report.guide));
}
#[test]
fn a_release_with_no_guide_links_the_guide_index() {
let index = release_guide("9.9.0");
assert!(index.ends_with("docs/migrations/README.md"), "{index}");
let known = release_guide("0.7.0");
assert!(known.ends_with("docs/migrations/0.7.0.md"), "{known}");
let junk = release_guide("not-a-version");
assert!(junk.ends_with("docs/migrations/README.md"), "{junk}");
}
#[test]
fn preview_writes_nothing() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest.save(tmp.path()).unwrap();
let report = plan_in(tmp.path());
assert_eq!(report.outcome, Outcome::Preview);
assert!(!tmp.path().join("rustfmt.toml").exists());
assert!(render_text(&report).contains("--apply"));
}
#[test]
fn apply_writes_additions_and_updates_but_never_conflicts() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let stale = "# an older release's clippy.toml\n";
write(tmp.path(), "clippy.toml", stale);
let mine = "FROM scratch\n# mine\n";
write(tmp.path(), "Dockerfile", mine);
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest
.digests
.insert("clippy.toml".to_owned(), digest(stale));
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(report.outcome, Outcome::Applied);
let files = current_files(tmp.path(), GenerateOptions::default()).expect("named project");
assert_eq!(
fs::read_to_string(tmp.path().join("rustfmt.toml")).unwrap(),
files["rustfmt.toml"]
);
assert_eq!(
fs::read_to_string(tmp.path().join("clippy.toml")).unwrap(),
files["clippy.toml"]
);
assert_eq!(
fs::read_to_string(tmp.path().join("Dockerfile")).unwrap(),
mine,
"a conflict must survive --apply byte for byte"
);
}
#[test]
fn apply_creates_missing_parent_directories() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_dir_all(tmp.path().join(".github")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove(".github/workflows/ci.yml");
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert!(tmp.path().join(".github/workflows/ci.yml").is_file());
}
#[test]
fn apply_refreshes_the_manifest_so_a_second_run_is_clean() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest.version = Some("0.5.0".to_owned());
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
let second = plan_in(tmp.path());
assert!(!second.drifted(), "{}", render_text(&second));
assert_eq!(second.baseline.as_deref(), Some(env!("CARGO_PKG_VERSION")));
}
#[test]
fn apply_leaves_the_baseline_version_alone_while_conflicts_remain() {
let tmp = scaffolded(GenerateOptions::default());
write(tmp.path(), "Dockerfile", "FROM scratch\n");
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.version = Some("0.5.0".to_owned());
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
Manifest::load(tmp.path()).unwrap().version.as_deref(),
Some("0.5.0")
);
}
#[test]
fn apply_refuses_a_file_that_changed_after_the_plan_was_made() {
let tmp = scaffolded(GenerateOptions::default());
let stale = "# older\n";
write(tmp.path(), "clippy.toml", stale);
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest
.digests
.insert("clippy.toml".to_owned(), digest(stale));
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
write(tmp.path(), "clippy.toml", "# touched by someone else\n");
let failure = apply(&mut report).expect_err("must refuse a changed file");
assert_eq!(failure.path, "clippy.toml");
assert_eq!(
fs::read_to_string(tmp.path().join("clippy.toml")).unwrap(),
"# touched by someone else\n"
);
assert!(matches!(report.outcome, Outcome::Partial { .. }));
}
#[test]
fn apply_refuses_an_addition_that_appeared_after_the_plan_was_made() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
write(tmp.path(), "rustfmt.toml", "# mine, written just now\n");
let failure = apply(&mut report).expect_err("must refuse to clobber");
assert_eq!(failure.path, "rustfmt.toml");
assert_eq!(
fs::read_to_string(tmp.path().join("rustfmt.toml")).unwrap(),
"# mine, written just now\n"
);
}
#[test]
fn the_text_report_names_every_drifted_file_with_its_status() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
write(tmp.path(), "Dockerfile", "FROM scratch\n");
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest.save(tmp.path()).unwrap();
let text = render_text(&plan_in(tmp.path()));
assert!(text.contains("rustfmt.toml"), "{text}");
assert!(text.contains("add"), "{text}");
assert!(text.contains("Dockerfile"), "{text}");
assert!(text.contains("conflict"), "{text}");
assert!(text.contains("git diff"), "{text}");
assert!(!text.contains("clippy.toml"), "{text}");
}
#[test]
fn a_project_with_no_drift_says_so_without_listing_files() {
let tmp = scaffolded(GenerateOptions::default());
let text = render_text(&plan_in(tmp.path()));
assert!(text.contains("up to date"), "{text}");
}
#[test]
fn the_json_report_carries_a_status_per_file() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest.save(tmp.path()).unwrap();
let value = json(&plan_in(tmp.path()));
assert_eq!(value["target"], env!("CARGO_PKG_VERSION"));
assert_eq!(value["drift"], true);
let files = value["files"].as_array().unwrap();
let entry = files
.iter()
.find(|file| file["path"] == "rustfmt.toml")
.expect("rustfmt.toml in the json report");
assert_eq!(entry["status"], "add");
assert!(value["guide"].as_str().unwrap().contains("docs/migrations"));
}
#[test]
fn a_report_with_only_conflicts_does_not_advertise_apply() {
let tmp = scaffolded(GenerateOptions::default());
write(tmp.path(), "Dockerfile", "FROM scratch\n");
let text = render_text(&plan_in(tmp.path()));
assert!(text.contains("conflict"), "{text}");
assert!(!text.contains("--apply"), "{text}");
assert!(text.contains("git diff"), "{text}");
}
#[test]
fn a_file_that_is_not_readable_text_is_a_conflict_not_an_addition() {
let tmp = scaffolded(GenerateOptions::default());
fs::write(tmp.path().join(".env.example"), [0xff_u8, 0xfe, 0x00, 0x01]).unwrap();
let entries = plan_in(tmp.path()).entries;
assert_eq!(
status_of(&entries, ".env.example"),
&Status::Conflict(ConflictReason::Unreadable)
);
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
fs::read(tmp.path().join(".env.example")).unwrap(),
vec![0xff_u8, 0xfe, 0x00, 0x01],
"an unreadable file must survive --apply byte for byte"
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_scaffold_file_is_a_conflict_and_is_never_written_through() {
let tmp = scaffolded(GenerateOptions::default());
let outside = tmp.path().join("outside.txt");
fs::write(&outside, "not mine to touch\n").unwrap();
let link = tmp.path().join("clippy.toml");
fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(&outside, &link).unwrap();
let entries = plan_in(tmp.path()).entries;
assert_eq!(
status_of(&entries, "clippy.toml"),
&Status::Conflict(ConflictReason::Symlink)
);
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(&outside).unwrap(),
"not mine to touch\n",
"the link target must never be written through"
);
}
#[test]
fn the_scaffold_is_always_this_release_not_an_arbitrary_to_version() {
let tmp = scaffolded(GenerateOptions::default());
let report = plan(tmp.path(), "0.6.0");
assert_eq!(report.target, env!("CARGO_PKG_VERSION"));
}
#[test]
fn an_api_project_that_serves_something_from_static_is_still_an_api_project() {
let tmp = scaffolded(GenerateOptions {
with_api: true,
..GenerateOptions::default()
});
fs::remove_dir_all(tmp.path().join(".autumn")).unwrap();
write(tmp.path(), "static/openapi.json", "{}\n");
let mut report = plan_in(tmp.path());
assert!(
!report
.entries
.iter()
.any(|e| e.path == "tailwind.config.js"),
"{:?}",
report.changed().iter().map(|e| &e.path).collect::<Vec<_>>()
);
apply(&mut report).expect("apply");
assert!(!tmp.path().join("tailwind.config.js").exists());
assert!(!tmp.path().join("static/css/input.css").exists());
}
#[test]
fn a_fullstack_project_missing_its_tailwind_config_is_still_fullstack() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_dir_all(tmp.path().join(".autumn")).unwrap();
fs::remove_file(tmp.path().join("tailwind.config.js")).unwrap();
let entries = plan_in(tmp.path()).entries;
assert_eq!(status_of(&entries, "tailwind.config.js"), &Status::Add);
assert_eq!(
status_of(&entries, "static/css/input.css"),
&Status::UpToDate
);
}
#[test]
fn a_project_whose_name_cannot_be_read_keeps_its_baseline() {
let tmp = scaffolded(GenerateOptions::default());
let before = fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
let mut report = plan_in(tmp.path());
assert!(!report.named);
assert!(report.entries.is_empty());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap(),
before,
"the baseline must survive a run that could not read the project name"
);
}
#[test]
fn a_run_that_changes_nothing_does_not_rewrite_the_manifest() {
let tmp = scaffolded(GenerateOptions::default());
let before = fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap();
let mtime = fs::metadata(tmp.path().join(MANIFEST_PATH))
.unwrap()
.modified()
.unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap(),
before
);
assert_eq!(
fs::metadata(tmp.path().join(MANIFEST_PATH))
.unwrap()
.modified()
.unwrap(),
mtime,
"an unchanged manifest must not be rewritten"
);
}
#[test]
fn an_unnamed_project_is_not_reported_as_drift_free() {
let tmp = scaffolded(GenerateOptions::default());
fs::write(tmp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
let report = plan_in(tmp.path());
assert!(!report.named);
assert!(render_summary(&report).contains("Skipped"));
}
#[cfg(unix)]
#[test]
fn a_symlinked_parent_directory_is_a_conflict_too() {
let tmp = scaffolded(GenerateOptions::default());
let outside = tmp.path().join("outside-workflows");
fs::create_dir_all(&outside).unwrap();
fs::remove_dir_all(tmp.path().join(".github")).unwrap();
std::os::unix::fs::symlink(&outside, tmp.path().join(".github")).unwrap();
let mut report = plan_in(tmp.path());
assert_eq!(
status_of(&report.entries, ".github/workflows/ci.yml"),
&Status::Conflict(ConflictReason::Symlink)
);
apply(&mut report).expect("apply");
assert!(
!outside.join("workflows/ci.yml").exists(),
"nothing may be written through a symlinked parent"
);
}
#[cfg(unix)]
#[test]
fn a_symlink_is_a_conflict_even_when_its_target_matches() {
let tmp = scaffolded(GenerateOptions::default());
let shared = tmp.path().join("shared-clippy.toml");
let current = current_files(tmp.path(), GenerateOptions::default()).unwrap();
fs::write(&shared, ¤t["clippy.toml"]).unwrap();
fs::remove_file(tmp.path().join("clippy.toml")).unwrap();
std::os::unix::fs::symlink(&shared, tmp.path().join("clippy.toml")).unwrap();
let entries = plan_in(tmp.path()).entries;
assert_eq!(
status_of(&entries, "clippy.toml"),
&Status::Conflict(ConflictReason::Symlink)
);
assert!(drifted(&entries));
fs::write(&shared, "# edited elsewhere\n").unwrap();
assert_eq!(
status_of(&plan_in(tmp.path()).entries, "clippy.toml"),
&Status::Conflict(ConflictReason::Symlink)
);
}
#[cfg(unix)]
#[test]
fn accepting_a_matching_symlink_makes_it_pinned_not_drift() {
let tmp = scaffolded(GenerateOptions::default());
let shared = tmp.path().join("shared-clippy.toml");
let current = current_files(tmp.path(), GenerateOptions::default()).unwrap();
fs::write(&shared, ¤t["clippy.toml"]).unwrap();
fs::remove_file(tmp.path().join("clippy.toml")).unwrap();
std::os::unix::fs::symlink(&shared, tmp.path().join("clippy.toml")).unwrap();
accept(tmp.path(), &["clippy.toml".to_owned()]).expect("accept");
let entries = plan_in(tmp.path()).entries;
assert_eq!(status_of(&entries, "clippy.toml"), &Status::Pinned);
assert!(!drifted(&entries));
}
#[cfg(unix)]
#[test]
fn a_pinned_non_matching_symlink_is_pinned_and_never_written() {
let tmp = scaffolded(GenerateOptions::default());
let outside = tmp.path().join("outside.txt");
fs::write(&outside, "not mine to touch\n").unwrap();
let link = tmp.path().join("clippy.toml");
fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(&outside, &link).unwrap();
accept(tmp.path(), &["clippy.toml".to_owned()]).expect("accept");
let mut report = plan_in(tmp.path());
assert_eq!(status_of(&report.entries, "clippy.toml"), &Status::Pinned);
apply(&mut report).expect("apply");
assert_eq!(fs::read_to_string(&outside).unwrap(), "not mine to touch\n");
}
#[cfg(unix)]
#[test]
fn an_added_file_is_as_readable_as_one_the_scaffold_writes() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
let added = fs::metadata(tmp.path().join("rustfmt.toml"))
.unwrap()
.permissions()
.mode();
let scaffolded_by_new = fs::metadata(tmp.path().join("clippy.toml"))
.unwrap()
.permissions()
.mode();
assert_eq!(
added & 0o777,
scaffolded_by_new & 0o777,
"an added file must be as readable as one `autumn new` writes"
);
}
#[test]
fn an_api_project_serving_its_own_stylesheet_is_still_an_api_project() {
let tmp = scaffolded(GenerateOptions {
with_api: true,
..GenerateOptions::default()
});
fs::remove_dir_all(tmp.path().join(".autumn")).unwrap();
write(tmp.path(), "static/css/site.css", "body{}\n");
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert!(!tmp.path().join("tailwind.config.js").exists());
assert!(!tmp.path().join("static/css/input.css").exists());
assert!(Manifest::load(tmp.path()).unwrap().options.with_api);
}
fn member_with_git(git_marker: Option<&str>) -> (TempDir, PathBuf) {
let outer = TempDir::new().unwrap();
fs::write(
outer.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\nresolver = \"3\"\n",
)
.unwrap();
let root = outer.path().join("app");
fs::create_dir_all(&root).unwrap();
write(
&root,
"Cargo.toml",
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
);
write(&root, "autumn.toml", "[server]\n");
write(&root, "src/lib.rs", "");
match git_marker {
Some("dir") => fs::create_dir(root.join(".git")).unwrap(),
Some(file) => write(&root, ".git", file),
None => {}
}
(outer, root)
}
fn offered_paths(report: &ScaffoldReport) -> Vec<&str> {
report.entries.iter().map(|e| e.path.as_str()).collect()
}
const CARGO_FILES: [&str; 3] = ["clippy.toml", "rustfmt.toml", "rust-toolchain.toml"];
const WORKFLOWS: [&str; 2] = [
".github/workflows/ci.yml",
".github/workflows/posture-gate.yml",
];
fn assert_scope(report: &ScaffoldReport, workflows: Option<&str>, cargo_files: Option<&str>) {
let offered = offered_paths(report);
for (group, prefix) in [
(WORKFLOWS.as_slice(), workflows),
(&CARGO_FILES, cargo_files),
] {
for path in group {
let planned: Vec<&&str> = offered.iter().filter(|o| o.ends_with(path)).collect();
match prefix {
Some(prefix) => {
assert_eq!(
planned,
[&format!("{prefix}{path}").as_str()],
"{offered:?}"
);
}
None => assert!(planned.is_empty(), "{path} in {offered:?}"),
}
}
}
}
#[test]
fn a_member_with_no_git_repo_gets_no_root_files() {
let (_outer, root) = member_with_git(None);
let report = plan(&root, "0.7.0");
assert_scope(&report, None, None);
let offered = offered_paths(&report);
assert!(offered.contains(&"Dockerfile"), "{offered:?}");
assert!(offered.contains(&"build.rs"), "{offered:?}");
assert!(render_text(&report).contains("CI workflows are out of scope"));
}
fn layout(git_at: &str, ws_at: &str, member_at: &str) -> (TempDir, PathBuf) {
let outer = TempDir::new().unwrap();
let dir = |rel: &str| outer.path().join(rel);
fs::create_dir_all(dir(git_at).join(".git")).unwrap();
fs::create_dir_all(dir(ws_at)).unwrap();
let listed = Path::new(member_at)
.strip_prefix(ws_at)
.unwrap_or_else(|_| Path::new(member_at))
.to_string_lossy()
.into_owned();
fs::write(
dir(ws_at).join("Cargo.toml"),
format!("[workspace]\nmembers = [\"{listed}\"]\n"),
)
.unwrap();
let root = dir(member_at);
fs::create_dir_all(&root).unwrap();
write(
&root,
"Cargo.toml",
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
);
write(&root, "autumn.toml", "[server]\n");
write(&root, "src/lib.rs", "");
(outer, root)
}
#[test]
fn a_member_two_levels_down_uses_two_levels_of_dots() {
let (_outer, root) = layout(".", ".", "apps/demo");
let report = plan(&root, "0.7.0");
assert_scope(&report, Some("../../"), Some("../../"));
let gate = report
.entries
.iter()
.find(|e| e.path.ends_with("posture-gate.yml"))
.unwrap();
assert!(
gate.template
.contains("POSTURE_MANIFEST: apps/demo/security-posture.json")
);
}
#[test]
fn an_excluded_crate_gets_cargo_files_but_no_workflows() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
fs::write(
outer.path().join("Cargo.toml"),
"[workspace]\nmembers = []\nexclude = [\"app\"]\n",
)
.unwrap();
assert_scope(&plan(&root, "0.7.0"), None, Some("../"));
}
#[test]
fn a_path_dependency_in_the_workspace_is_a_member() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
fs::write(
outer.path().join("Cargo.toml"),
"[package]\nname = \"top\"\nversion = \"0.1.0\"\n\n\
[dependencies]\ndemo = { path = \"app\" }\n\n[workspace]\nmembers = []\n",
)
.unwrap();
write(outer.path(), "src/lib.rs", "");
assert_scope(&plan(&root, "0.7.0"), Some("../"), Some("../"));
}
#[test]
fn a_member_glob_does_not_override_exclude() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
fs::write(
outer.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"*\"]\nexclude = [\"app\"]\n",
)
.unwrap();
assert_scope(&plan(&root, "0.7.0"), None, Some("../"));
}
#[test]
fn a_workspace_root_below_the_git_root_gets_cargo_files_only() {
let (_outer, root) = layout(".", "ws", "ws/app");
assert_scope(&plan(&root, "0.7.0"), None, Some("../"));
}
#[test]
fn a_workspace_root_above_the_git_repo_gets_nothing() {
let (_outer, root) = layout("ws/repo", "ws", "ws/repo/app");
assert_scope(&plan(&root, "0.7.0"), None, None);
}
#[test]
fn a_deleted_root_file_is_removed_not_re_added() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
apply(&mut plan(&root, "0.7.0")).expect("apply");
fs::remove_file(outer.path().join("clippy.toml")).unwrap();
let report = plan(&root, "0.7.0");
assert_eq!(
status_of(&report.entries, "../clippy.toml"),
&Status::Removed
);
}
#[cfg(unix)]
#[test]
fn a_linked_github_directory_at_the_root_is_a_conflict() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
let elsewhere = TempDir::new().unwrap();
std::os::unix::fs::symlink(elsewhere.path(), outer.path().join(".github")).unwrap();
let mut report = plan(&root, "0.7.0");
assert_eq!(
status_of(&report.entries, "../.github/workflows/ci.yml"),
&Status::Conflict(ConflictReason::Symlink)
);
apply(&mut report).expect("apply");
assert_eq!(fs::read_dir(elsewhere.path()).unwrap().count(), 0);
}
#[test]
fn a_member_copy_of_a_root_cargo_file_is_a_shadow_conflict() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
write(&root, "clippy.toml", "# local\n");
let report = plan(&root, "0.7.0");
assert_eq!(
status_of(&report.entries, "clippy.toml"),
&Status::Conflict(ConflictReason::Shadows)
);
assert!(report.drifted());
let mut report = report;
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(root.join("clippy.toml")).unwrap(),
"# local\n"
);
fs::remove_file(root.join("clippy.toml")).unwrap();
let report = plan(&root, "0.7.0");
assert!(!offered_paths(&report).contains(&"clippy.toml"));
}
#[test]
fn accepting_a_bare_root_file_name_points_at_the_real_key() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
let error = accept(&root, &["clippy.toml".to_owned()]).unwrap_err();
assert!(error.contains("`../clippy.toml`"), "{error}");
}
#[test]
fn a_member_below_the_git_root_gets_its_workflows_at_the_git_root() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
let report = plan(&root, "0.7.0");
assert_scope(&report, Some("../"), Some("../"));
assert!(!render_text(&report).contains("CI workflows are out of scope"));
}
#[test]
fn a_nested_git_repo_in_a_workspace_owns_its_workflows_but_not_its_lint_config() {
let (_outer, root) = member_with_git(Some("dir"));
assert_scope(&plan(&root, "0.7.0"), Some(""), None);
}
#[test]
fn a_submodule_in_a_workspace_owns_its_workflows() {
let (_outer, root) = member_with_git(Some("gitdir: ../.git/modules/app\n"));
let report = plan(&root, "0.7.0");
assert_scope(&report, Some(""), None);
assert!(!render_text(&report).contains("CI workflows are out of scope"));
}
#[test]
fn a_stale_workflow_in_a_nested_git_repo_fails_the_gate() {
let (_outer, root) = member_with_git(Some("dir"));
write(&root, ".github/workflows/ci.yml", "name: stale\n");
assert!(plan(&root, "0.7.0").drifted());
}
#[test]
fn the_workspace_note_names_only_what_is_out_of_scope() {
let (_outer, root) = member_with_git(Some("dir"));
let text = render_text(&plan(&root, "0.7.0"));
assert!(text.contains("rustfmt.toml"), "{text}");
assert!(!text.contains("CI workflows are out of scope"), "{text}");
}
#[test]
fn apply_writes_a_members_root_files_at_the_root_and_none_in_the_member() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
let mut report = plan(&root, "0.7.0");
apply(&mut report).expect("apply");
for path in CARGO_FILES {
assert!(outer.path().join(path).is_file(), "{path} at the root");
assert!(!root.join(path).exists(), "{path} in the member");
}
let ci = fs::read_to_string(outer.path().join(".github/workflows/ci.yml")).unwrap();
assert!(ci.contains("working-directory: \"app\""), "{ci}");
let gate =
fs::read_to_string(outer.path().join(".github/workflows/posture-gate.yml")).unwrap();
assert!(
gate.contains("POSTURE_MANIFEST: app/security-posture.json"),
"{gate}"
);
assert!(!root.join(".github").exists());
let again = plan(&root, "0.7.0");
for entry in again.entries.iter().filter(|e| e.path.starts_with("../")) {
assert_eq!(entry.status, Status::UpToDate, "{}", entry.path);
}
let manifest = Manifest::load(&root).expect("manifest");
assert!(
manifest.digests.contains_key("../clippy.toml"),
"{manifest:?}"
);
}
#[test]
fn a_root_file_another_member_wrote_is_a_conflict_not_an_overwrite() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
write(
outer.path(),
".github/workflows/ci.yml",
"name: other member\n",
);
let report = plan(&root, "0.7.0");
assert_eq!(
status_of(&report.entries, "../.github/workflows/ci.yml"),
&Status::Conflict(ConflictReason::NoBaseline)
);
let mut report = report;
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(outer.path().join(".github/workflows/ci.yml")).unwrap(),
"name: other member\n"
);
}
#[test]
fn a_member_in_a_directory_yaml_could_misread_gets_no_workflows() {
let outer = TempDir::new().unwrap();
fs::create_dir(outer.path().join(".git")).unwrap();
fs::write(
outer.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"*\"]\n",
)
.unwrap();
let root = outer.path().join("my app");
fs::create_dir_all(&root).unwrap();
write(
&root,
"Cargo.toml",
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
);
write(&root, "autumn.toml", "[server]\n");
assert_scope(&plan(&root, "0.7.0"), None, Some("../"));
}
#[test]
fn a_root_file_can_be_accepted_by_its_relative_path() {
let (outer, root) = member_with_git(None);
fs::create_dir(outer.path().join(".git")).unwrap();
write(outer.path(), "clippy.toml", "# ours\n");
accept(&root, &["../clippy.toml".to_owned()]).expect("accept");
let report = plan(&root, "0.7.0");
assert_eq!(
status_of(&report.entries, "../clippy.toml"),
&Status::Pinned
);
}
#[test]
fn an_accepted_conflict_stops_holding_the_gate_red() {
let tmp = scaffolded(GenerateOptions::default());
write(tmp.path(), "Dockerfile", "FROM scratch\n# ours\n");
assert!(plan_in(tmp.path()).drifted());
accept(tmp.path(), &["Dockerfile".to_owned()]).expect("accept");
let report = plan_in(tmp.path());
assert_eq!(status_of(&report.entries, "Dockerfile"), &Status::Pinned);
assert!(!report.drifted());
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(tmp.path().join("Dockerfile")).unwrap(),
"FROM scratch\n# ours\n"
);
assert!(
Manifest::load(tmp.path())
.unwrap()
.pinned
.contains("Dockerfile")
);
}
#[test]
fn accepting_a_path_the_scaffold_does_not_own_is_refused() {
let tmp = scaffolded(GenerateOptions::default());
let error = accept(tmp.path(), &["src/main.rs".to_owned()]).expect_err("must refuse");
assert!(error.contains("src/main.rs"), "{error}");
assert!(Manifest::load(tmp.path()).unwrap().pinned.is_empty());
}
#[test]
fn a_completed_apply_leaves_no_scratch_files_behind() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let stale = "# older\n";
write(tmp.path(), "clippy.toml", stale);
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest
.digests
.insert("clippy.toml".to_owned(), digest(stale));
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
let strays: Vec<String> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with(".autumn-upgrade-"))
.collect();
assert!(strays.is_empty(), "scratch files left behind: {strays:?}");
}
#[test]
fn a_file_that_merely_looks_like_scratch_is_neither_deleted_nor_a_blocker() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest.save(tmp.path()).unwrap();
let lookalike = tmp.path().join(".autumn-upgrade-rustfmt.toml.tmp");
fs::write(&lookalike, "not this command's to delete\n").unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("a lookalike is not a blocker");
let files = current_files(tmp.path(), GenerateOptions::default()).unwrap();
assert_eq!(
fs::read_to_string(tmp.path().join("rustfmt.toml")).unwrap(),
files["rustfmt.toml"],
"the addition still lands"
);
assert_eq!(
fs::read_to_string(&lookalike).unwrap(),
"not this command's to delete\n",
"an unowned file must survive untouched"
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_manifest_is_never_written_through() {
let tmp = scaffolded(GenerateOptions::default());
let outside = tmp.path().join("outside.toml");
fs::write(&outside, "not mine to touch\n").unwrap();
fs::remove_file(tmp.path().join(MANIFEST_PATH)).unwrap();
std::os::unix::fs::symlink(&outside, tmp.path().join(MANIFEST_PATH)).unwrap();
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut report = plan_in(tmp.path());
let failure = apply(&mut report).expect_err("the baseline cannot be recorded");
assert_eq!(failure.path, MANIFEST_PATH);
assert_eq!(
fs::read_to_string(&outside).unwrap(),
"not mine to touch\n",
"the link target must never be written through"
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_autumn_directory_is_never_written_through() {
let tmp = scaffolded(GenerateOptions::default());
let outside = tmp.path().join("outside-dir");
fs::create_dir_all(&outside).unwrap();
fs::remove_dir_all(tmp.path().join(".autumn")).unwrap();
std::os::unix::fs::symlink(&outside, tmp.path().join(".autumn")).unwrap();
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut report = plan_in(tmp.path());
let failure = apply(&mut report).expect_err("the baseline cannot be recorded");
assert_eq!(failure.path, MANIFEST_PATH);
assert!(
!outside.join("scaffold.toml").exists(),
"nothing may be written through a symlinked parent"
);
}
#[test]
fn an_unrecognised_flavor_is_no_baseline_rather_than_a_guess() {
let tmp = scaffolded(GenerateOptions {
with_api: true,
..GenerateOptions::default()
});
let text = fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap();
fs::write(
tmp.path().join(MANIFEST_PATH),
text.replace("flavor = \"api\"", "flavor = \"fullstack-v2\""),
)
.unwrap();
assert!(Manifest::load(tmp.path()).is_none());
let before = fs::read_to_string(tmp.path().join("Dockerfile")).unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(tmp.path().join("Dockerfile")).unwrap(),
before,
"an unreadable flavor must fall back to the conservative no-baseline path"
);
assert!(!tmp.path().join("tailwind.config.js").exists());
}
#[test]
fn publishing_an_addition_never_replaces_a_destination_that_appeared() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("arrived.toml");
fs::write(&path, "someone else got here first\n").unwrap();
let error = publish(&path, "ours\n", Publish::Create)
.expect_err("must refuse")
.to_string();
assert!(error.contains("appeared"), "{error}");
assert_eq!(
fs::read_to_string(&path).unwrap(),
"someone else got here first\n"
);
assert!(no_scratch_beside(&path), "no scratch left behind");
}
#[test]
fn publishing_a_replacement_takes_the_destination() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("existing.toml");
fs::write(&path, "old\n").unwrap();
publish(&path, "new\n", Publish::Swap("old\n".to_owned())).expect("replace");
assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
assert!(no_scratch_beside(&path), "no scratch left behind");
}
#[test]
fn a_swap_refuses_a_destination_that_changed_after_the_plan() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("existing.toml");
fs::write(&path, "theirs, written after the plan\n").unwrap();
let error = publish(&path, "new\n", Publish::Swap("planned\n".to_owned()))
.expect_err("must refuse");
assert!(matches!(error, PublishError::Moved(_)), "{error}");
assert_eq!(
fs::read_to_string(&path).unwrap(),
"theirs, written after the plan\n"
);
assert!(no_scratch_beside(&path), "no scratch left behind");
}
#[test]
fn a_swap_refuses_a_destination_that_vanished() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("gone.toml");
let error = publish(&path, "new\n", Publish::Swap("planned\n".to_owned()))
.expect_err("must refuse");
assert!(matches!(error, PublishError::Moved(_)), "{error}");
assert!(!path.exists());
assert!(no_scratch_beside(&path), "no scratch left behind");
}
#[test]
fn a_swap_of_matching_text_ignores_line_endings() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("crlf.toml");
fs::write(&path, "a\r\nb\r\n").unwrap();
publish(&path, "new\n", Publish::Swap("a\nb\n".to_owned())).expect("swap");
assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
}
#[test]
fn an_update_runs_again_when_a_writer_changed_the_manifest_first() {
let tmp = scaffolded(GenerateOptions::default());
let mut runs = 0;
let root = tmp.path().to_path_buf();
let saved = Manifest::update(tmp.path(), |current| {
runs += 1;
let mut manifest = current.expect("manifest");
if runs == 1 {
let mut theirs = manifest.clone();
theirs.pinned.insert("Dockerfile".to_owned());
fs::write(root.join(MANIFEST_PATH), theirs.render()).unwrap();
}
manifest.pinned.insert("build.rs".to_owned());
Ok(manifest)
})
.expect("update");
assert_eq!(runs, 2);
let pinned: Vec<&str> = saved.pinned.iter().map(String::as_str).collect();
assert_eq!(pinned, ["Dockerfile", "build.rs"]);
assert_eq!(Manifest::load(tmp.path()).unwrap(), saved);
}
#[test]
fn an_update_gives_up_when_the_manifest_never_settles() {
let tmp = scaffolded(GenerateOptions::default());
let root = tmp.path().to_path_buf();
let mut runs = 0;
let error = Manifest::update(tmp.path(), |current| {
runs += 1;
let mut manifest = current.expect("manifest");
let mut theirs = manifest.clone();
theirs.pinned.insert(format!("racer-{runs}"));
fs::write(root.join(MANIFEST_PATH), theirs.render()).unwrap();
manifest.pinned.insert("mine".to_owned());
Ok(manifest)
})
.expect_err("must stop");
assert_eq!(runs, SWAP_ATTEMPTS);
assert!(error.to_string().contains("changing"), "{error}");
assert!(!Manifest::load(tmp.path()).unwrap().pinned.contains("mine"));
}
#[test]
fn an_update_never_builds_from_nothing_while_a_claim_is_in_flight() {
let tmp = scaffolded(GenerateOptions::default());
let manifest_path = tmp.path().join(MANIFEST_PATH);
let held = manifest_path.with_file_name(".autumn-upgrade-scaffold.toml.claim.old");
fs::rename(&manifest_path, &held).unwrap();
let mut runs = 0;
let error = Manifest::update(tmp.path(), |current| {
runs += 1;
Ok(current.unwrap_or_else(|| panic!("built from nothing")))
})
.expect_err("must not build from nothing");
assert!(error.to_string().contains("interrupted"), "{error}");
assert_eq!(runs, 0);
assert!(held.exists(), "the held copy is never deleted");
}
#[test]
fn an_accept_judges_ownership_by_the_manifest_not_by_a_claim_gap() {
let tmp = scaffolded(GenerateOptions::default());
for marker in [
"tailwind.config.js",
"static/css/input.css",
"static/js/htmx.min.js",
] {
let _ = fs::remove_file(tmp.path().join(marker));
}
let manifest_path = tmp.path().join(MANIFEST_PATH);
let held = manifest_path.with_file_name(".autumn-upgrade-scaffold.toml.claim.old");
fs::rename(&manifest_path, &held).unwrap();
let restore = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(30));
fs::rename(&held, &manifest_path).unwrap();
});
let accepted = accept(tmp.path(), &["tailwind.config.js".to_owned()]);
restore.join().unwrap();
let manifest = accepted.expect("owned by the recorded flavor");
assert!(manifest.pinned.contains("tailwind.config.js"));
assert!(!manifest.digests.is_empty(), "the history survives");
}
#[test]
fn a_load_waits_out_a_claim_instead_of_seeing_no_manifest() {
let tmp = scaffolded(GenerateOptions::default());
let manifest_path = tmp.path().join(MANIFEST_PATH);
let held = manifest_path.with_file_name(".autumn-upgrade-scaffold.toml.claim.old");
fs::rename(&manifest_path, &held).unwrap();
let restore = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(30));
fs::rename(&held, &manifest_path).unwrap();
});
let loaded = Manifest::load(tmp.path());
restore.join().unwrap();
assert!(loaded.is_some(), "the manifest was only held aside");
}
#[test]
fn a_planning_read_waits_out_a_claim_on_a_scaffold_file() {
let tmp = scaffolded(GenerateOptions::default());
let path = tmp.path().join("Dockerfile");
let held = path.with_file_name(".autumn-upgrade-Dockerfile.claim.old");
fs::rename(&path, &held).unwrap();
let restore = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(30));
fs::rename(&held, &path).unwrap();
});
let report = plan_in(tmp.path());
restore.join().unwrap();
assert_eq!(*status_of(&report.entries, "Dockerfile"), Status::UpToDate);
}
#[test]
fn a_claim_written_through_an_open_handle_is_kept_not_deleted() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("existing.toml");
let claim = tempfile::Builder::new()
.prefix(".autumn-upgrade-existing.toml.")
.suffix(CLAIM_SUFFIX)
.tempfile_in(tmp.path())
.unwrap()
.into_temp_path();
fs::write(&claim, "planned\nand a late write\n").unwrap();
let held = claim.to_path_buf();
let error = finish_claim(claim, "planned\n").expect_err("must keep the copy");
assert!(matches!(error, PublishError::Late(_)), "{error}");
assert!(error.to_string().contains(&*held.to_string_lossy()));
assert_eq!(
fs::read_to_string(&held).unwrap(),
"planned\nand a late write\n"
);
let clean = tempfile::Builder::new()
.prefix(".autumn-upgrade-existing.toml.")
.suffix(CLAIM_SUFFIX)
.tempfile_in(tmp.path())
.unwrap()
.into_temp_path();
fs::write(&clean, "planned\n").unwrap();
let gone = clean.to_path_buf();
finish_claim(clean, "planned\n").expect("unchanged copy is dropped");
assert!(!gone.exists());
assert!(!path.exists());
}
#[test]
fn an_unchanged_update_still_checks_the_manifest_is_current() {
let tmp = scaffolded(GenerateOptions::default());
let root = tmp.path().to_path_buf();
let mut runs = 0;
Manifest::update(tmp.path(), |current| {
runs += 1;
let manifest = current.expect("manifest");
if runs == 1 {
let mut theirs = manifest.clone();
theirs.pinned.insert("Dockerfile".to_owned());
fs::write(root.join(MANIFEST_PATH), theirs.render()).unwrap();
}
Ok(manifest)
})
.expect("update");
assert_eq!(runs, 2, "the stale snapshot must not be reported as done");
}
#[test]
fn a_project_known_only_by_a_held_manifest_is_still_a_project() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("autumn.toml")).unwrap();
let manifest_path = tmp.path().join(MANIFEST_PATH);
fs::rename(
&manifest_path,
manifest_path.with_file_name(".autumn-upgrade-scaffold.toml.claim.old"),
)
.unwrap();
assert!(is_project(tmp.path()));
}
#[test]
fn a_claim_that_never_ends_reads_as_unreadable_not_as_absent() {
let tmp = scaffolded(GenerateOptions::default());
let path = tmp.path().join("Dockerfile");
fs::rename(
&path,
path.with_file_name(".autumn-upgrade-Dockerfile.claim.old"),
)
.unwrap();
let report = plan_in(tmp.path());
assert_eq!(
*status_of(&report.entries, "Dockerfile"),
Status::Conflict(ConflictReason::Unreadable)
);
}
#[test]
fn a_claim_on_one_file_does_not_block_its_siblings() {
let tmp = scaffolded(GenerateOptions::default());
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest.save(tmp.path()).unwrap();
fs::write(
tmp.path().join(".autumn-upgrade-Dockerfile.stale.old"),
"old\n",
)
.unwrap();
let report = plan_in(tmp.path());
assert_eq!(*status_of(&report.entries, "rustfmt.toml"), Status::Add);
}
#[test]
fn an_apply_planned_before_a_newer_release_wrote_refuses_to_record() {
let tmp = scaffolded(GenerateOptions::default());
let stale = "# older\n";
write(tmp.path(), "clippy.toml", stale);
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest
.digests
.insert("clippy.toml".to_owned(), digest(stale));
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
let mut newer = Manifest::load(tmp.path()).unwrap();
newer.version = Some("99.0.0".to_owned());
newer.written_by = Some("99.0.0".to_owned());
newer.save(tmp.path()).unwrap();
let failure = apply(&mut report).expect_err("must not record over a newer manifest");
assert_eq!(failure.path, MANIFEST_PATH);
let kept = Manifest::load(tmp.path()).unwrap();
assert_eq!(kept.written_by.as_deref(), Some("99.0.0"));
}
#[test]
fn only_a_late_write_error_means_the_new_file_is_already_there() {
assert!(PublishError::Late("x".to_owned()).published());
assert!(!PublishError::Moved("x".to_owned()).published());
assert!(!PublishError::Retained("x".to_owned()).published());
assert!(!PublishError::Failed("x".to_owned()).published());
}
#[test]
fn a_stalled_claim_is_not_advised_to_be_renamed_over_a_manifest() {
let tmp = scaffolded(GenerateOptions::default());
let manifest_path = tmp.path().join(MANIFEST_PATH);
fs::rename(
&manifest_path,
manifest_path.with_file_name(".autumn-upgrade-scaffold.toml.stalled.old"),
)
.unwrap();
let error =
Manifest::update(tmp.path(), |_| Err("unreachable".to_owned())).expect_err("must stop");
let text = error.to_string();
assert!(text.contains("still missing"), "{text}");
}
#[cfg(not(windows))]
#[test]
fn two_accepts_at_once_keep_both_pins() {
for _ in 0..20 {
let tmp = scaffolded(GenerateOptions::default());
let root = tmp.path().to_path_buf();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let spawn = |path: &'static str| {
let (root, barrier) = (root.clone(), barrier.clone());
std::thread::spawn(move || {
barrier.wait();
accept(&root, &[path.to_owned()])
})
};
let (left, right) = (spawn("Dockerfile"), spawn("build.rs"));
left.join().unwrap().expect("first accept");
right.join().unwrap().expect("second accept");
let pinned = Manifest::load(tmp.path()).unwrap().pinned;
assert!(pinned.contains("Dockerfile"), "{pinned:?}");
assert!(pinned.contains("build.rs"), "{pinned:?}");
let kept = Manifest::load(tmp.path()).unwrap();
assert!(kept.version.is_some(), "the baseline survives");
assert!(!kept.digests.is_empty(), "the digests survive");
assert!(no_scratch_beside(&root.join(MANIFEST_PATH)));
}
}
fn no_scratch_beside(path: &std::path::Path) -> bool {
!fs::read_dir(path.parent().unwrap())
.unwrap()
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".autumn-upgrade-")
})
}
#[test]
fn a_preview_and_an_apply_agree_about_build_rs() {
let tmp = scaffolded(GenerateOptions::default());
let stale = "fn main() { /* an older release's build.rs */ }\n";
write(tmp.path(), "build.rs", stale);
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest
.digests
.insert("build.rs".to_owned(), digest(stale));
manifest.save(tmp.path()).unwrap();
assert_eq!(
status_of(&plan_in(tmp.path()).entries, "build.rs"),
&Status::Update
);
let migrated = BTreeMap::from([("build.rs".to_owned(), plan_of(stale, stale))]);
let previewed = plan_after(tmp.path(), "0.7.0", &migrated);
assert_eq!(
status_of(&previewed.entries, "build.rs"),
&Status::Conflict(ConflictReason::MigratedThisRun)
);
assert!(
!previewed
.applicable()
.iter()
.any(|entry| entry.path == "build.rs"),
"a file the codemods rewrite is never a writable scaffold update"
);
}
#[test]
fn a_preview_and_an_apply_show_the_same_build_rs_diff() {
let tmp = scaffolded(GenerateOptions::default());
let before = "fn main() { old_call(); }\n";
let after = "fn main() { new_call(); }\n";
write(tmp.path(), "build.rs", before);
let migrated = BTreeMap::from([("build.rs".to_owned(), plan_of(before, after))]);
let preview = plan_after(tmp.path(), "0.7.0", &migrated);
write(tmp.path(), "build.rs", after);
let apply = plan_after(tmp.path(), "0.7.0", &migrated);
let diff_of = |report: &ScaffoldReport| {
report
.entries
.iter()
.find(|entry| entry.path == "build.rs")
.unwrap()
.diff
.clone()
};
assert_eq!(diff_of(&preview), diff_of(&apply));
assert!(diff_of(&preview).contains("new_call"));
assert!(!diff_of(&preview).contains("old_call"));
}
fn plan_of(original: &str, updated: &str) -> Planned {
Planned {
original: original.to_owned(),
updated: updated.to_owned(),
}
}
fn build_rs_verdicts(before: &str, planned: &str) -> (Status, Status) {
let tmp = scaffolded(GenerateOptions::default());
write(tmp.path(), "build.rs", before);
let migrated = BTreeMap::from([("build.rs".to_owned(), plan_of(before, planned))]);
let verdict = |tmp: &TempDir| {
let report = plan_after(tmp.path(), "0.7.0", &migrated);
*status_of(&report.entries, "build.rs")
};
let preview = verdict(&tmp);
write(tmp.path(), "build.rs", planned);
(preview, verdict(&tmp))
}
#[test]
fn a_codemod_that_reaches_the_template_is_up_to_date_in_both_modes() {
let template = current_files(
scaffolded(GenerateOptions::default()).path(),
GenerateOptions::default(),
)
.unwrap()["build.rs"]
.clone();
let (preview, apply) = build_rs_verdicts("fn main() { old_call(); }\n", &template);
assert_eq!(preview, Status::UpToDate);
assert_eq!(apply, Status::UpToDate);
}
#[test]
fn a_codemod_that_leaves_the_template_is_a_conflict_in_both_modes() {
let template = current_files(
scaffolded(GenerateOptions::default()).path(),
GenerateOptions::default(),
)
.unwrap()["build.rs"]
.clone();
let (preview, apply) = build_rs_verdicts(&template, "fn main() { new_call(); }\n");
let conflict = Status::Conflict(ConflictReason::MigratedThisRun);
assert_eq!(preview, conflict);
assert_eq!(apply, conflict);
}
#[test]
fn a_crlf_planned_text_diffs_like_its_lf_form() {
let tmp = scaffolded(GenerateOptions::default());
write(tmp.path(), "build.rs", "fn main() { old_call(); }\n");
let diff = |planned: &str| {
let migrated = BTreeMap::from([(
"build.rs".to_owned(),
plan_of("fn main() { old_call(); }\n", planned),
)]);
let report = plan_after(tmp.path(), "0.7.0", &migrated);
report
.entries
.into_iter()
.find(|entry| entry.path == "build.rs")
.unwrap()
.diff
};
let lf = diff("fn main() { new_call(); }\n");
assert_eq!(lf, diff("fn main() { new_call(); }\r\n"));
assert!(!lf.contains('\r'));
}
#[test]
fn a_file_changed_after_the_scan_keeps_its_real_diff() {
let tmp = scaffolded(GenerateOptions::default());
let template =
current_files(tmp.path(), GenerateOptions::default()).unwrap()["build.rs"].clone();
write(tmp.path(), "build.rs", "fn main() { edited_since(); }\n");
let migrated = BTreeMap::from([(
"build.rs".to_owned(),
plan_of("fn main() { old_call(); }\n", &template),
)]);
let report = plan_after(tmp.path(), "0.7.0", &migrated);
let entry = report
.entries
.iter()
.find(|entry| entry.path == "build.rs")
.unwrap();
assert_eq!(
entry.status,
Status::Conflict(ConflictReason::MigratedThisRun)
);
assert!(entry.diff.contains("edited_since"));
}
#[test]
fn a_migrated_path_the_scaffold_does_not_own_is_ignored() {
let tmp = scaffolded(GenerateOptions::default());
let migrated = BTreeMap::from([("src/main.rs".to_owned(), plan_of("", "fn main() {}\n"))]);
let report = plan_after(tmp.path(), "0.7.0", &migrated);
assert!(
report
.entries
.iter()
.all(|entry| entry.path != "src/main.rs")
);
}
#[test]
fn a_nested_workspace_root_is_a_root_not_a_member() {
let outer = TempDir::new().unwrap();
fs::write(
outer.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\nresolver = \"3\"\n",
)
.unwrap();
let root = outer.path().join("app");
fs::create_dir_all(&root).unwrap();
write(
&root,
"Cargo.toml",
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[workspace]\n",
);
write(&root, "autumn.toml", "[server]\n");
let report = plan(&root, "0.7.0");
assert!(
!report.workspace_member,
"its own `[workspace]` makes it a root"
);
let offered: Vec<&str> = report.entries.iter().map(|e| e.path.as_str()).collect();
for owned in [
"clippy.toml",
"rustfmt.toml",
"rust-toolchain.toml",
".github/workflows/ci.yml",
".github/workflows/posture-gate.yml",
] {
assert!(offered.contains(&owned), "{owned} missing from {offered:?}");
}
}
#[cfg(unix)]
#[test]
fn a_symlinked_manifest_is_not_a_baseline_to_trust() {
let tmp = scaffolded(GenerateOptions::default());
let outside = tmp.path().join("outside-manifest.toml");
let real = fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap();
fs::write(&outside, &real).unwrap();
fs::remove_file(tmp.path().join(MANIFEST_PATH)).unwrap();
std::os::unix::fs::symlink(&outside, tmp.path().join(MANIFEST_PATH)).unwrap();
assert!(
Manifest::load(tmp.path()).is_none(),
"a linked manifest vouches for nothing"
);
let stale = "# older\n";
write(tmp.path(), "clippy.toml", stale);
let mut linked = Manifest::parse(&real).unwrap();
linked
.digests
.insert("clippy.toml".to_owned(), digest(stale));
fs::write(&outside, linked.render()).unwrap();
let entries = plan_in(tmp.path()).entries;
assert_eq!(
status_of(&entries, "clippy.toml"),
&Status::Conflict(ConflictReason::NoBaseline)
);
}
#[cfg(unix)]
#[test]
fn a_newly_created_manifest_is_as_readable_as_the_files_it_describes() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = scaffolded(GenerateOptions::default());
let manifest = fs::metadata(tmp.path().join(MANIFEST_PATH))
.unwrap()
.permissions()
.mode();
let ordinary = fs::metadata(tmp.path().join("clippy.toml"))
.unwrap()
.permissions()
.mode();
assert_eq!(
manifest & 0o777,
ordinary & 0o777,
"the manifest must be as readable as the files it describes"
);
}
#[cfg(unix)]
#[test]
fn an_apply_that_cannot_record_its_baseline_is_not_a_success() {
let tmp = scaffolded(GenerateOptions::default());
let outside = tmp.path().join("outside.toml");
fs::write(&outside, "not mine\n").unwrap();
fs::remove_file(tmp.path().join(MANIFEST_PATH)).unwrap();
std::os::unix::fs::symlink(&outside, tmp.path().join(MANIFEST_PATH)).unwrap();
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut report = plan_in(tmp.path());
let failure = apply(&mut report).expect_err("must not report success");
assert_eq!(failure.path, MANIFEST_PATH);
assert!(failure.error.contains("baseline"), "{}", failure.error);
let files = current_files(tmp.path(), GenerateOptions::default()).unwrap();
assert_eq!(
fs::read_to_string(tmp.path().join("rustfmt.toml")).unwrap(),
files["rustfmt.toml"]
);
assert_eq!(failure.written, report.applicable().len());
assert_eq!(fs::read_to_string(&outside).unwrap(), "not mine\n");
}
#[test]
fn a_project_scaffolded_by_a_newer_release_is_refused_not_downgraded() {
let tmp = scaffolded(GenerateOptions::default());
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.version = Some("99.0.0".to_owned());
manifest.save(tmp.path()).unwrap();
let newer = "# written by a newer release\n";
write(tmp.path(), "clippy.toml", newer);
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest
.digests
.insert("clippy.toml".to_owned(), digest(newer));
manifest.save(tmp.path()).unwrap();
let report = plan_in(tmp.path());
assert!(report.entries.is_empty(), "nothing may be reconciled");
assert!(!report.drifted());
let text = render_text(&report);
assert!(text.contains("newer"), "{text}");
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(tmp.path().join("clippy.toml")).unwrap(),
newer,
"a newer release's file must never be downgraded"
);
}
#[test]
fn an_impossible_option_combination_is_no_baseline() {
let tmp = scaffolded(GenerateOptions::default());
let text = fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap();
fs::write(
tmp.path().join(MANIFEST_PATH),
text.replace("flavor = \"fullstack\"", "flavor = \"api\"")
.replace("daemon = false", "daemon = true"),
)
.unwrap();
assert!(
Manifest::load(tmp.path()).is_none(),
"an incoherent option set vouches for nothing"
);
let before = fs::read_to_string(tmp.path().join("Dockerfile")).unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(tmp.path().join("Dockerfile")).unwrap(),
before
);
}
#[test]
fn refusing_a_newer_scaffold_leaves_its_manifest_untouched() {
let tmp = scaffolded(GenerateOptions::default());
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.version = Some("99.0.0".to_owned());
manifest.save(tmp.path()).unwrap();
let before = fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap();
let mut report = plan_in(tmp.path());
assert!(report.scaffolded_by_newer.is_some());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap(),
before,
"a refused run must not rewrite the baseline it refused over"
);
}
#[cfg(unix)]
#[test]
fn an_added_file_gets_the_mode_an_ordinary_write_would_give_it() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = scaffolded(GenerateOptions::default());
fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o775)).unwrap();
fs::remove_file(tmp.path().join("rustfmt.toml")).unwrap();
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.digests.remove("rustfmt.toml");
manifest.save(tmp.path()).unwrap();
let probe = tmp.path().join("probe.txt");
fs::write(&probe, "x").unwrap();
let expected = fs::metadata(&probe).unwrap().permissions().mode() & 0o777;
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
let added = fs::metadata(tmp.path().join("rustfmt.toml"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
added, expected,
"an added file must carry the mode an ordinary write would give it"
);
}
#[test]
fn a_partial_upgrade_by_a_newer_release_still_refuses_an_older_cli() {
let tmp = scaffolded(GenerateOptions::default());
let newer = "# written by a newer release\n";
write(tmp.path(), "clippy.toml", newer);
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.version = Some("0.5.0".to_owned()); manifest.written_by = Some("99.0.0".to_owned()); manifest
.digests
.insert("clippy.toml".to_owned(), digest(newer));
manifest.save(tmp.path()).unwrap();
let report = plan_in(tmp.path());
assert_eq!(report.scaffolded_by_newer.as_deref(), Some("99.0.0"));
assert!(report.entries.is_empty());
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(tmp.path().join("clippy.toml")).unwrap(),
newer,
"a newer release's file must never be downgraded"
);
}
#[test]
fn the_renderer_high_water_mark_advances_even_when_the_baseline_cannot() {
let tmp = scaffolded(GenerateOptions::default());
write(tmp.path(), "Dockerfile", "FROM scratch\n");
let stale = "# older\n";
write(tmp.path(), "clippy.toml", stale);
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.version = Some("0.5.0".to_owned());
manifest.written_by = Some("0.5.0".to_owned());
manifest
.digests
.insert("clippy.toml".to_owned(), digest(stale));
manifest.save(tmp.path()).unwrap();
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
let after = Manifest::load(tmp.path()).unwrap();
assert_eq!(
after.version.as_deref(),
Some("0.5.0"),
"the baseline still waits on the conflict"
);
assert_eq!(
after.written_by.as_deref(),
Some(env!("CARGO_PKG_VERSION")),
"but this renderer wrote these digests"
);
}
#[test]
fn a_new_project_records_which_renderer_wrote_it() {
let tmp = scaffolded(GenerateOptions::default());
assert_eq!(
Manifest::load(tmp.path()).unwrap().written_by.as_deref(),
Some(env!("CARGO_PKG_VERSION"))
);
}
#[test]
fn an_unparsable_recorded_version_is_no_baseline() {
for field in ["version", "written_by"] {
let tmp = scaffolded(GenerateOptions::default());
let text = fs::read_to_string(tmp.path().join(MANIFEST_PATH)).unwrap();
fs::write(
tmp.path().join(MANIFEST_PATH),
text.replace(
&format!("{field} = \"{}\"", env!("CARGO_PKG_VERSION")),
&format!("{field} = \"not-a-version\""),
),
)
.unwrap();
assert!(
Manifest::load(tmp.path()).is_none(),
"{field}: an unreadable version vouches for nothing"
);
let newer = "# written by a newer release\n";
write(tmp.path(), "clippy.toml", newer);
let entries = plan_in(tmp.path()).entries;
assert_eq!(
status_of(&entries, "clippy.toml"),
&Status::Conflict(ConflictReason::NoBaseline),
"{field}"
);
let mut report = plan_in(tmp.path());
apply(&mut report).expect("apply");
assert_eq!(
fs::read_to_string(tmp.path().join("clippy.toml")).unwrap(),
newer,
"{field}"
);
}
}
#[test]
fn an_absent_version_is_still_a_usable_baseline() {
let tmp = scaffolded(GenerateOptions::default());
let mut manifest = Manifest::load(tmp.path()).unwrap();
manifest.version = None;
manifest.save(tmp.path()).unwrap();
let loaded = Manifest::load(tmp.path()).expect("still a baseline");
assert!(loaded.version.is_none());
assert!(!loaded.digests.is_empty());
}
fn newer(recorded: &str) -> bool {
is_newer(recorded, "0.8.0-rc.1")
}
#[test]
fn provenance_compares_prereleases_by_full_semver_precedence() {
assert!(newer("0.8.0-rc.2"), "rc.2 is newer than rc.1");
assert!(!newer("0.8.0-rc.1"), "the same release is not newer");
assert!(!newer("0.8.0-rc.0"), "rc.0 is older");
assert!(newer("0.8.0"));
assert!(!is_newer("0.8.0-rc.9", "0.8.0"));
assert!(is_newer("0.8.0-rc.10", "0.8.0-rc.9"));
assert!(!is_newer("0.8.0-rc.9", "0.8.0-rc.10"));
assert!(is_newer("0.8.0-alpha", "0.8.0-1"));
assert!(is_newer("0.8.0-rc.1", "0.8.0-rc"));
assert!(is_newer("0.9.0-rc.1", "0.8.0"));
assert!(!is_newer("0.8.0", "0.9.0-rc.1"));
}
}