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 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(
version: &str,
options: GenerateOptions,
files: &BTreeMap<&'static str, String>,
) -> Self {
Self {
version: Some(version.to_owned()),
written_by: Some(version.to_owned()),
options,
digests: files
.iter()
.map(|(path, contents)| ((*path).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> {
if matches!(read_current(root, MANIFEST_PATH), OnDisk::Linked(_)) {
return None;
}
Self::parse(&std::fs::read_to_string(root.join(MANIFEST_PATH)).ok()?)
}
pub fn save(&self, root: &Path) -> std::io::Result<()> {
if matches!(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"
)));
}
let path = root.join(MANIFEST_PATH);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
publish(&path, &self.render(), Publish::Replace).map_err(std::io::Error::other)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictReason {
Edited,
NoBaseline,
Unreadable,
Symlink,
MigratedThisRun,
}
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"
}
}
}
}
#[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 WORKSPACE_ROOT_OWNED: &[&str] = &[
"clippy.toml",
"rustfmt.toml",
"rust-toolchain.toml",
".github/workflows/ci.yml",
".github/workflows/posture-gate.yml",
];
#[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<&'static str, 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 = framework_owned_files(&vars, options);
if workspace_root_above(root).is_some() {
for path in WORKSPACE_ROOT_OWNED {
files.remove(path);
}
}
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 {
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))
}
#[must_use]
pub fn classify(
root: &Path,
files: &BTreeMap<&'static str, String>,
manifest: Option<&Manifest>,
migrated: &BTreeSet<String>,
) -> 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 normalized = normalize(template);
let differs = |text: &str| {
let diff = super::diff::render(text, template);
(text != normalized, diff)
};
let (status, diff) = match ¤t {
OnDisk::Text(text) | OnDisk::Linked(Some(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 = if migrated.contains(*path) {
Status::Conflict(ConflictReason::MigratedThisRun)
} else {
match recorded(path) {
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).to_owned(),
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 {
root.join("autumn.toml").is_file() || root.join(MANIFEST_PATH).is_file()
}
#[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,
}
#[must_use]
pub fn plan(root: &Path, target: &str) -> ScaffoldReport {
plan_after(root, target, &BTreeSet::new())
}
#[must_use]
pub fn plan_after(root: &Path, _target: &str, migrated: &BTreeSet<String>) -> 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 = manifest
.as_ref()
.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));
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| classify(root, &files, manifest.as_ref(), migrated))
.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 manifest = Manifest::load(root);
let options = resolve_options(root, manifest.as_ref());
let owned = current_files(root, options).ok_or_else(|| {
"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()
})?;
let unknown: Vec<&str> = paths
.iter()
.map(String::as_str)
.filter(|path| !owned.contains_key(path))
.collect();
if !unknown.is_empty() {
return Err(format!(
"not framework-owned in this project, so there is nothing to accept: {}",
unknown.join(", ")
));
}
let mut manifest = manifest.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());
manifest
.save(root)
.map_err(|error| format!("could not write {MANIFEST_PATH}: {error}"))?;
Ok(manifest)
}
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) {
report.outcome = super::Outcome::Partial { written };
return Err(WriteFailure {
path: entry.path.clone(),
error,
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(());
}
let previous = Manifest::load(&report.root);
let next = report.next_manifest(previous.as_ref());
let rendered = next.render();
let path = report.root.join(MANIFEST_PATH);
if std::fs::read_to_string(&path).is_ok_and(|current| current == rendered) {
return Ok(());
}
next.save(&report.root).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<(), String> {
let on_disk = read_current_absolute(entry);
if on_disk != entry.current {
return Err(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(
"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())?;
publish(
&entry.absolute,
&entry.template,
if entry.current == OnDisk::Absent {
Publish::Create
} else {
Publish::Replace
},
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Publish {
Create,
Replace,
}
fn publish(absolute: &Path, contents: &str, mode: Publish) -> Result<(), String> {
use std::io::Write as _;
let directory = absolute
.parent()
.ok_or_else(|| "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)
})
.map_err(|error| error.to_string())?;
if let Ok(metadata) = std::fs::metadata(absolute) {
let _ = temp.as_file().set_permissions(metadata.permissions());
}
temp.write_all(contents.as_bytes())
.map_err(|error| error.to_string())?;
temp.flush().map_err(|error| error.to_string())?;
temp.as_file()
.sync_all()
.map_err(|error| error.to_string())?;
match mode {
Publish::Create => temp.persist_noclobber(absolute).map_err(|error| {
if error.error.kind() == std::io::ErrorKind::AlreadyExists {
"something appeared at this path while it was being written; \
it was left exactly as it is"
.to_owned()
} else {
error.error.to_string()
}
})?,
Publish::Replace => temp
.persist(absolute)
.map_err(|error| error.error.to_string())?,
};
Ok(())
}
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 {
let _ = writeln!(
out,
" This crate sits inside a Cargo workspace, so the files that workspace owns\n \
at its root — clippy.toml, rustfmt.toml, rust-toolchain.toml and the CI\n \
workflow — are out of scope here: a crate-local copy would shadow the\n \
workspace's, not add to it. Reconcile those at the workspace 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_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,
"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_whose_content_is_already_current_is_not_drift() {
use std::os::unix::fs::PermissionsExt as _;
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 _ = fs::metadata(&shared).map(|m| m.permissions().mode());
let entries = plan_in(tmp.path()).entries;
assert_eq!(status_of(&entries, "clippy.toml"), &Status::UpToDate);
assert!(!drifted(&entries));
}
#[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);
}
#[test]
fn a_workspace_member_is_not_given_the_files_a_workspace_root_owns() {
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");
let report = plan(&root, "0.7.0");
let offered: Vec<&str> = report.entries.iter().map(|e| e.path.as_str()).collect();
for root_owned in [
"clippy.toml",
"rustfmt.toml",
"rust-toolchain.toml",
".github/workflows/ci.yml",
] {
assert!(
!offered.contains(&root_owned),
"{root_owned} in {offered:?}"
);
}
assert!(offered.contains(&"Dockerfile"), "{offered:?}");
assert!(offered.contains(&"build.rs"), "{offered:?}");
assert!(
render_text(&report).contains("workspace"),
"{}",
render_text(&report)
);
}
#[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");
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::Replace).expect("replace");
assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
assert!(no_scratch_beside(&path), "no scratch left behind");
}
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: BTreeSet<String> = std::iter::once("build.rs".to_owned()).collect();
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_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"] {
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"));
}
}