pub mod diff;
pub mod engine;
pub mod migrations;
pub mod scaffold;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use migrations::{AppMigration, Version};
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Default)]
pub struct UpgradeOptions {
pub from: Option<String>,
pub to: Option<String>,
pub apply: bool,
pub json: bool,
pub list: bool,
pub check: bool,
pub accept: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManualEntry {
pub path: Option<String>,
pub line: Option<usize>,
pub migration: &'static str,
pub reason: String,
pub guide: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileReport {
pub path: String,
pub sites: Vec<engine::Site>,
pub diff: String,
pub updated: String,
pub original: String,
pub absolute: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Preview,
Applied,
Partial { written: usize },
}
impl Outcome {
pub const fn label(self) -> &'static str {
match self {
Self::Preview => "preview",
Self::Applied => "applied",
Self::Partial { .. } => "partial",
}
}
}
#[derive(Debug, Clone)]
pub struct Report {
pub from: Version,
pub to: Version,
pub outcome: Outcome,
pub files_scanned: usize,
pub migrations: Vec<&'static AppMigration>,
pub files: Vec<FileReport>,
pub review: Vec<ManualEntry>,
pub manual: Vec<ManualEntry>,
pub skipped: Vec<(String, String)>,
}
impl Report {
pub fn rewritten_sites(&self) -> usize {
self.files.iter().map(|f| f.sites.len()).sum()
}
pub fn written_sites(&self) -> usize {
match self.outcome {
Outcome::Preview => 0,
Outcome::Applied => self.rewritten_sites(),
Outcome::Partial { written } => self
.files
.iter()
.take(written)
.map(|file| file.sites.len())
.sum(),
}
}
}
pub const DRIFT_EXIT_CODE: i32 = 3;
const SKIPPED_DIRS: &[&str] = &["target", "vendor", "node_modules", "dist", "tmp"];
const SKIPPED_HIDDEN_DIRS: &[&str] = &[
".git", ".github", ".gitlab", ".hg", ".svn", ".cargo", ".vscode", ".idea", ".direnv", ".venv",
".tox", ".claude",
];
pub fn render_text(report: &Report) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(
out,
"autumn upgrade - app-code migrations {} -> {}",
report.from, report.to
);
if report.migrations.is_empty() {
let _ = writeln!(
out,
"\nNothing to change: no shipped migration falls in this version range."
);
if report.from >= report.to {
let _ = writeln!(
out,
"This app already records `autumn-web {}`. If you bumped the dependency\n\
before running the codemods, pass the release you came from:\n\
`autumn upgrade --from <previous-version>`.",
report.from
);
}
return out;
}
render_migrations(&mut out, report);
render_diffs(&mut out, report);
render_entries(
&mut out,
"Review - rewritten, but read each of these before committing",
&report.review,
);
render_entries(
&mut out,
"Manual - not rewritten; read the guide section",
&report.manual,
);
render_skipped(&mut out, report);
render_summary(&mut out, report);
out
}
fn render_migrations(out: &mut String, report: &Report) {
use std::fmt::Write as _;
let _ = writeln!(out, "\nMigrations in range ({}):", report.migrations.len());
for migration in &report.migrations {
let _ = writeln!(
out,
" {:<6} {} {}",
migration.confidence.label(),
migration.id,
migration.title
);
let _ = writeln!(out, " {}", migrations::guide_url(migration.guide));
}
}
fn render_diffs(out: &mut String, report: &Report) {
use std::fmt::Write as _;
if report.files.is_empty() {
let human_sites = report
.manual
.iter()
.filter(|entry| entry.path.is_some())
.count();
if human_sites > 0 {
let _ = writeln!(
out,
"\nNothing to rewrite: {human_sites} site{} need{} a human — see Manual below.",
plural(human_sites),
if human_sites == 1 { "s" } else { "" }
);
} else {
let _ = writeln!(
out,
"\nNothing to change: no affected call site found in {} scanned file(s).",
report.files_scanned
);
}
return;
}
let _ = writeln!(
out,
"\n{}:",
match report.outcome {
Outcome::Applied => "Applied",
Outcome::Partial { .. } => "Applied in part — see the error below",
Outcome::Preview => "Preview (nothing is written without --apply)",
}
);
for file in &report.files {
let _ = writeln!(
out,
"\n{} ({} site{})",
file.path,
file.sites.len(),
plural(file.sites.len())
);
out.push_str(&file.diff);
}
}
fn render_entries(out: &mut String, heading: &str, entries: &[ManualEntry]) {
use std::fmt::Write as _;
if entries.is_empty() {
return;
}
let _ = writeln!(out, "\n{heading}:");
for entry in entries {
let _ = writeln!(
out,
" {} {} ({})",
entry.location(),
entry.migration,
entry.reason
);
let _ = writeln!(out, " {}", migrations::guide_url(entry.guide));
}
}
fn render_skipped(out: &mut String, report: &Report) {
use std::fmt::Write as _;
if report.skipped.is_empty() {
return;
}
let _ = writeln!(out, "\nSkipped - left untouched:");
for (path, reason) in &report.skipped {
let _ = writeln!(out, " {path}: {reason}");
}
}
fn render_summary(out: &mut String, report: &Report) {
use std::fmt::Write as _;
let sites = report.rewritten_sites();
if sites == 0 {
return;
}
let files = report.files.len();
match report.outcome {
Outcome::Partial { written } => {
let written_sites = report.written_sites();
let _ = writeln!(
out,
"\n{written_sites} site{} in {written} file{} written before the run stopped; \
{} site{} in {} file{} planned and not written.",
plural(written_sites),
plural(written),
sites - written_sites,
plural(sites - written_sites),
files - written,
plural(files - written),
);
let _ = writeln!(
out,
"`git diff` shows exactly what landed; the error below names the file that stopped it."
);
}
Outcome::Applied => {
let _ = writeln!(
out,
"\n{sites} site{} in {files} file{} rewritten; {} file(s) scanned.",
plural(sites),
plural(files),
report.files_scanned
);
let _ = writeln!(out, "Review the result with `git diff` before committing.");
}
Outcome::Preview => {
let _ = writeln!(
out,
"\n{sites} site{} in {files} file{} would be rewritten; {} file(s) scanned.",
plural(sites),
plural(files),
report.files_scanned
);
let _ = writeln!(
out,
"Nothing was written. Re-run with `--apply` to write these changes."
);
}
}
}
const fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "s" }
}
impl ManualEntry {
fn location(&self) -> String {
match (&self.path, self.line) {
(Some(path), Some(line)) => format!("{path}:{line}"),
(Some(path), None) => path.clone(),
_ => "(whole change)".to_owned(),
}
}
}
fn migrations_json(migrations: &[&'static AppMigration]) -> serde_json::Value {
serde_json::Value::Array(
migrations
.iter()
.map(|migration| {
serde_json::json!({
"id": migration.id,
"version": migration.version,
"title": migration.title,
"confidence": migration.confidence.label(),
"guide": migration.guide,
"guide_url": migrations::guide_url(migration.guide),
"rewrites_code": !matches!(migration.rewrite, migrations::Rewrite::GuideOnly),
})
})
.collect(),
)
}
fn manual_json(entries: &[ManualEntry]) -> serde_json::Value {
serde_json::Value::Array(
entries
.iter()
.map(|entry| {
serde_json::json!({
"path": entry.path,
"line": entry.line,
"migration": entry.migration,
"reason": entry.reason,
"guide": entry.guide,
"guide_url": migrations::guide_url(entry.guide),
})
})
.collect(),
)
}
#[must_use]
pub fn render_json(report: &Report) -> String {
serde_json::to_string_pretty(&json_value(report)).unwrap_or_else(|_| "{}".to_owned())
}
#[must_use]
pub fn json_value(report: &Report) -> serde_json::Value {
let files: Vec<serde_json::Value> = report
.files
.iter()
.map(|file| {
serde_json::json!({
"path": file.path,
"diff": file.diff,
"sites": file.sites.iter().map(|site| serde_json::json!({
"line": site.line,
"column": site.column,
"migration": site.migration,
})).collect::<Vec<_>>(),
})
})
.collect();
let value = serde_json::json!({
"from": report.from.to_string(),
"to": report.to.to_string(),
"outcome": report.outcome.label(),
"applied": report.outcome == Outcome::Applied,
"files_written": match report.outcome {
Outcome::Partial { written } => written,
Outcome::Applied => report.files.len(),
Outcome::Preview => 0,
},
"files_scanned": report.files_scanned,
"rewritten_sites": report.rewritten_sites(),
"written_sites": report.written_sites(),
"migrations": migrations_json(&report.migrations),
"files": files,
"review": manual_json(&report.review),
"manual": manual_json(&report.manual),
"skipped": report.skipped.iter().map(|(path, reason)| serde_json::json!({
"path": path,
"reason": reason,
})).collect::<Vec<_>>(),
});
value
}
fn recorded_version(root: &Path) -> Option<String> {
use crate::doctor::AutumnWebDependency;
fn inherited(from: &Path, key: &str) -> Option<AutumnWebDependency> {
let absolute = std::fs::canonicalize(from).unwrap_or_else(|_| from.to_path_buf());
absolute
.ancestors()
.find_map(|ancestor| crate::doctor::workspace_dependency_for(ancestor, key))
}
let mut lowest: Option<(Version, String)> = None;
for directory in std::iter::once(root.to_path_buf()).chain(member_manifests(root)) {
for declaration in crate::doctor::autumn_web_declarations_at(&directory) {
let declaration = match declaration {
AutumnWebDependency::Inherited(key) => match inherited(&directory, &key) {
Some(resolved) => resolved,
None if key == "autumn-web" => AutumnWebDependency::WithoutVersion,
None => continue,
},
other => other,
};
let requirement = match declaration {
AutumnWebDependency::Absent | AutumnWebDependency::Inherited(_) => continue,
AutumnWebDependency::WithoutVersion | AutumnWebDependency::Unreadable => {
return None;
}
AutumnWebDependency::Version(requirement) => requirement,
};
let version = migrations::parse_version_req(&requirement)?;
if lowest.as_ref().is_none_or(|(lowest, _)| version < *lowest) {
lowest = Some((version, requirement));
}
}
}
lowest.map(|(_, requirement)| requirement)
}
fn member_manifests(root: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
collect_manifests(root, root, &configured_target_dirs(root), &mut found);
found.sort();
found
}
fn is_target_dir(path: &Path, target_dirs: &BTreeSet<PathBuf>) -> bool {
!target_dirs.is_empty()
&& std::fs::canonicalize(path).is_ok_and(|resolved| target_dirs.contains(&resolved))
}
fn collect_manifests(
root: &Path,
dir: &Path,
target_dirs: &BTreeSet<PathBuf>,
found: &mut Vec<PathBuf>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let at_crate_root = dir.join("Cargo.toml").is_file();
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let is_metadata = SKIPPED_HIDDEN_DIRS.contains(&name.as_str());
let is_build_output = at_crate_root && SKIPPED_DIRS.contains(&name.as_str());
let path = entry.path();
if is_metadata || is_build_output || is_target_dir(&path, target_dirs) {
continue;
}
if path.join("Cargo.toml").is_file() && path != root {
found.push(path.clone());
}
collect_manifests(root, &path, target_dirs, found);
}
}
fn configured_target_dirs(root: &Path) -> BTreeSet<PathBuf> {
let forced_target_dir = ["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"]
.into_iter()
.filter_map(std::env::var_os)
.find(|configured| !configured.is_empty())
.and_then(|configured| {
let cwd = std::env::current_dir().ok()?;
resolve_against(&cwd, &configured.to_string_lossy())
});
let target_dir_from_config = forced_target_dir.is_none();
let mut found = BTreeSet::new();
let absolute_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut levels: Vec<(PathBuf, PathBuf)> = absolute_root
.ancestors()
.map(|ancestor| (ancestor.join(".cargo"), ancestor.to_path_buf()))
.collect();
levels.extend(cargo_home().map(|home| (home.clone(), home)));
let mut sources = Sources::new();
let mut nearest_target = None;
for (config_dir, base) in levels.iter().rev() {
let (target, declared) = config_at(config_dir, base, target_dir_from_config);
if target.is_some() {
nearest_target = target;
}
merge_sources(&mut sources, declared);
}
let root_target_dir = cargo_metadata_target_dir(&absolute_root)
.or(forced_target_dir)
.or(nearest_target);
found.extend(root_target_dir);
found.extend(active_vendor_dirs(&sources));
collect_target_dirs(&absolute_root, target_dir_from_config, &sources, &mut found);
found
}
fn cargo_metadata_target_dir(root: &Path) -> Option<PathBuf> {
if !root.join("Cargo.toml").is_file() {
return None;
}
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let output = std::process::Command::new(cargo)
.args(["metadata", "--no-deps", "--format-version", "1"])
.arg("--manifest-path")
.arg(root.join("Cargo.toml"))
.current_dir(root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let target_directory = metadata.get("target_directory")?.as_str()?;
std::fs::canonicalize(target_directory).ok()
}
#[derive(Clone, Default)]
struct SourceEntry {
directory: Option<PathBuf>,
replace_with: Option<String>,
}
type Sources = BTreeMap<String, SourceEntry>;
fn merge_sources(sources: &mut Sources, nearer: Sources) {
for (name, entry) in nearer {
let merged = sources.entry(name).or_default();
if entry.directory.is_some() {
merged.directory = entry.directory;
}
if entry.replace_with.is_some() {
merged.replace_with = entry.replace_with;
}
}
}
fn is_consulted_source(name: &str) -> bool {
name == "crates-io" || name.contains("://")
}
fn active_vendor_dirs(sources: &Sources) -> Vec<PathBuf> {
let mut active: BTreeSet<&str> = BTreeSet::new();
let mut pending: Vec<&str> = sources
.iter()
.filter(|(name, _)| is_consulted_source(name))
.filter_map(|(_, entry)| entry.replace_with.as_deref())
.collect();
while let Some(name) = pending.pop() {
if !active.insert(name) {
continue;
}
pending.extend(
sources
.get(name)
.and_then(|entry| entry.replace_with.as_deref()),
);
}
active
.into_iter()
.filter_map(|name| sources.get(name)?.directory.clone())
.collect()
}
pub fn cargo_home() -> Option<PathBuf> {
if let Some(configured) = std::env::var_os("CARGO_HOME") {
let path = PathBuf::from(configured);
if !path.as_os_str().is_empty() {
return Some(path);
}
}
Some(directories::BaseDirs::new()?.home_dir().join(".cargo"))
}
fn collect_target_dirs(
dir: &Path,
target_dir_from_config: bool,
inherited: &Sources,
found: &mut BTreeSet<PathBuf>,
) {
let (target, declared) = config_at(&dir.join(".cargo"), dir, target_dir_from_config);
found.extend(target);
let mut sources = inherited.clone();
merge_sources(&mut sources, declared);
found.extend(active_vendor_dirs(&sources));
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let at_crate_root = dir.join("Cargo.toml").is_file();
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if SKIPPED_HIDDEN_DIRS.contains(&name.as_str())
|| (at_crate_root && SKIPPED_DIRS.contains(&name.as_str()))
{
continue;
}
let path = entry.path();
if is_target_dir(&path, found) {
continue;
}
collect_target_dirs(&path, target_dir_from_config, &sources, found);
}
}
fn resolve_against(base: &Path, path: &str) -> Option<PathBuf> {
let path = PathBuf::from(path);
let absolute = if path.is_absolute() {
path
} else {
base.join(path)
};
std::fs::canonicalize(absolute).ok()
}
fn config_at(config_dir: &Path, base: &Path, target_dir: bool) -> (Option<PathBuf>, Sources) {
for name in ["config", "config.toml"] {
let Ok(content) = std::fs::read_to_string(config_dir.join(name)) else {
continue;
};
let Ok(table) = toml::from_str::<toml::Table>(&content) else {
continue;
};
let target = table
.get("build")
.and_then(|build| build.get("target-dir"))
.and_then(toml::Value::as_str)
.filter(|_| target_dir)
.and_then(|configured| resolve_against(base, configured));
let mut sources = Sources::new();
if let Some(declared) = table.get("source").and_then(toml::Value::as_table) {
for (source_name, source) in declared {
sources.insert(
source_name.clone(),
SourceEntry {
directory: source
.get("directory")
.and_then(toml::Value::as_str)
.and_then(|directory| resolve_against(base, directory)),
replace_with: source
.get("replace-with")
.and_then(toml::Value::as_str)
.map(str::to_owned),
},
);
}
}
return (target, sources);
}
(None, Sources::new())
}
fn app_sources(root: &Path) -> SourceScan {
let mut scan = SourceScan::default();
collect_sources(root, &configured_target_dirs(root), &mut scan);
scan.files.sort();
scan.symlinks.sort();
scan.unreadable.sort();
scan
}
#[derive(Debug, Default)]
struct SourceScan {
files: Vec<PathBuf>,
symlinks: Vec<PathBuf>,
unreadable: Vec<PathBuf>,
}
fn collect_sources(dir: &Path, target_dirs: &BTreeSet<PathBuf>, scan: &mut SourceScan) {
let Ok(entries) = std::fs::read_dir(dir) else {
scan.unreadable.push(dir.to_path_buf());
return;
};
let at_crate_root = dir.join("Cargo.toml").is_file();
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if file_type.is_dir() {
let is_metadata = SKIPPED_HIDDEN_DIRS.contains(&name.as_str());
let is_build_output = at_crate_root && SKIPPED_DIRS.contains(&name.as_str());
if is_metadata || is_build_output || is_target_dir(&path, target_dirs) {
continue;
}
collect_sources(&path, target_dirs, scan);
} else if file_type.is_symlink() {
let links_to_directory =
std::fs::metadata(&path).is_ok_and(|metadata| metadata.is_dir());
if links_to_directory || path.extension().is_some_and(|ext| ext == "rs") {
scan.symlinks.push(path);
}
} else if file_type.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
scan.files.push(path);
}
}
}
fn display_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
}
fn generated_receivers(files: &[PathBuf]) -> engine::GeneratedRepositories {
let mut generated: engine::GeneratedRepositories = files
.iter()
.filter_map(|path| {
let source = std::fs::read_to_string(path).ok()?;
let prefix = file_module_path(path);
Some(engine::generated_repository_types(&source).into_iter().map(
move |(name, inner)| {
let mut module = prefix.clone();
module.extend(inner);
(name, module)
},
))
})
.flatten()
.collect();
generated.note_handwritten(
files
.iter()
.filter_map(|path| {
let source = std::fs::read_to_string(path).ok()?;
let prefix = file_module_path(path);
Some(
engine::defined_type_names(&source)
.into_iter()
.map(move |(name, inner)| {
let mut module = prefix.clone();
module.extend(inner);
(name, module)
}),
)
})
.flatten(),
);
generated
}
fn file_module_path(path: &Path) -> Vec<String> {
let components: Vec<String> = path
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect();
let Some(src_at) = components.iter().rposition(|component| component == "src") else {
return Vec::new();
};
let mut module: Vec<String> = components[src_at + 1..].to_vec();
let Some(last) = module.pop() else {
return Vec::new();
};
let stem = last.strip_suffix(".rs").unwrap_or(&last);
if !matches!(stem, "mod" | "lib" | "main") {
module.push(stem.to_owned());
}
module
}
fn plan(root: &Path, from: Version, to: Version) -> Report {
let selected = migrations::migrations_between(&from, &to);
plan_with(root, from, to, &selected)
}
fn plan_with(
root: &Path,
from: Version,
to: Version,
selected: &[&'static AppMigration],
) -> Report {
let mut report = Report {
from,
to,
outcome: Outcome::Preview,
files_scanned: 0,
migrations: selected.to_vec(),
files: Vec::new(),
review: Vec::new(),
manual: Vec::new(),
skipped: Vec::new(),
};
for migration in selected {
if matches!(migration.rewrite, migrations::Rewrite::GuideOnly) {
report.manual.push(ManualEntry {
path: None,
line: None,
migration: migration.id,
reason: "no machine-applyable rewrite".to_owned(),
guide: migration.guide,
});
}
}
let selected_by_id = |id: &str| {
selected
.iter()
.copied()
.find(|migration| migration.id == id)
};
let guide_for = |id: &str| selected_by_id(id).map_or("docs/migrations/README.md", |m| m.guide);
let scan = app_sources(root);
for path in scan.symlinks {
report.skipped.push((
display_path(root, &path),
"symbolic link - not followed, migrate it in its own checkout".to_owned(),
));
}
for path in scan.unreadable {
report.skipped.push((
display_path(root, &path),
"directory could not be read - nothing under it was migrated".to_owned(),
));
}
let generated = generated_receivers(&scan.files);
for path in scan.files {
let display = display_path(root, &path);
let source = match std::fs::read_to_string(&path) {
Ok(source) => source,
Err(error) => {
report.skipped.push((display, error.to_string()));
continue;
}
};
report.files_scanned += 1;
let rewrite = match engine::rewrite_source_for_releases(&source, selected, &generated) {
Ok(rewrite) => rewrite,
Err(error) => {
report.skipped.push((display, error));
continue;
}
};
for site in &rewrite.manual {
report.manual.push(ManualEntry {
path: Some(display.clone()),
line: Some(site.line),
migration: site.migration,
reason: site.manual.map_or_else(
|| "not rewritable".to_owned(),
|reason| reason.describe().to_owned(),
),
guide: guide_for(site.migration),
});
}
for site in &rewrite.rewritten {
if selected_by_id(site.migration)
.is_some_and(|m| m.confidence == migrations::Confidence::Review)
{
report.review.push(ManualEntry {
path: Some(display.clone()),
line: Some(site.line),
migration: site.migration,
reason: "rewritten - confirm the new call is what you meant".to_owned(),
guide: guide_for(site.migration),
});
}
}
if let Some(updated) = rewrite.updated {
report.files.push(FileReport {
path: display,
sites: rewrite.rewritten,
diff: diff::render(&source, &updated),
updated,
original: source,
absolute: path,
});
}
}
report
}
fn write_plan(report: &Report) -> Result<(), WriteFailure> {
for (written, file) in report.files.iter().enumerate() {
write_one(file).map_err(|error| WriteFailure {
path: file.path.clone(),
error,
written,
})?;
}
Ok(())
}
#[derive(Debug)]
struct WriteFailure {
path: String,
error: std::io::Error,
written: usize,
}
fn write_one(file: &FileReport) -> std::io::Result<()> {
use std::io::Write as _;
let directory = file.absolute.parent().unwrap_or_else(|| Path::new("."));
let mut temp = tempfile::Builder::new()
.prefix(".autumn-upgrade-")
.suffix(".tmp")
.tempfile_in(directory)?;
temp.write_all(file.updated.as_bytes())?;
temp.flush()?;
temp.as_file().sync_all()?;
if let Ok(metadata) = std::fs::metadata(&file.absolute) {
let _ = temp.as_file().set_permissions(metadata.permissions());
}
match std::fs::read_to_string(&file.absolute) {
Ok(current) if current == file.original => {}
Ok(_) => {
return Err(std::io::Error::other(
"file changed on disk after it was planned - re-run `autumn upgrade` to \
migrate it against its current contents",
));
}
Err(error) => return Err(error),
}
temp.persist(&file.absolute)
.map_err(|error| std::io::Error::other(error.to_string()))?;
Ok(())
}
#[cfg(test)]
mod write_guard_tests {
use super::*;
#[test]
fn a_file_changed_after_planning_is_not_overwritten() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("main.rs");
std::fs::write(&path, "fn main() {}\n").expect("write");
let planned = FileReport {
path: "main.rs".to_owned(),
sites: Vec::new(),
diff: String::new(),
updated: "fn main() { /* rewritten */ }\n".to_owned(),
original: "fn main() {}\n".to_owned(),
absolute: path.clone(),
};
write_one(&planned).expect("an untouched file is written");
assert_eq!(
std::fs::read_to_string(&path).expect("read"),
planned.updated
);
std::fs::write(&path, "fn main() { /* someone else */ }\n").expect("write");
let error = write_one(&planned).expect_err("a changed file must not be overwritten");
assert!(
error.to_string().contains("changed on disk"),
"the reason names the cause: {error}"
);
assert_eq!(
std::fs::read_to_string(&path).expect("read"),
"fn main() { /* someone else */ }\n",
"the newer contents are left alone"
);
}
}
fn plan_scaffold(root: &Path, target: &str, report: &Report) -> Option<scaffold::ScaffoldReport> {
let migrated: BTreeSet<String> = report.files.iter().map(|file| file.path.clone()).collect();
scaffold::is_project(root).then(|| scaffold::plan_after(root, target, &migrated))
}
fn reject_bad_combination(opts: &UpgradeOptions) -> Option<i32> {
if opts.check && opts.apply {
eprintln!(
"autumn upgrade: `--check` reports drift and writes nothing; it cannot be combined with `--apply`."
);
return Some(2);
}
if opts.list && (opts.check || !opts.accept.is_empty()) {
eprintln!(
"autumn upgrade: `--list-migrations` prints the registry and exits; it cannot be \
combined with `--check` or `--accept`."
);
return Some(2);
}
None
}
fn accept_scaffold(root: &Path, paths: &[String], json: bool) -> i32 {
if !scaffold::is_project(root) {
eprintln!(
"autumn upgrade: `{}` is not an Autumn project (no `autumn.toml`).",
root.display()
);
return 2;
}
match scaffold::accept(root, paths) {
Ok(manifest) => {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"accepted": paths,
"pinned": manifest.pinned,
"manifest": scaffold::MANIFEST_PATH,
}))
.unwrap_or_else(|_| "{}".to_owned())
);
return 0;
}
println!(
"Accepted as yours; `autumn upgrade` will leave {} alone:",
if paths.len() == 1 { "it" } else { "them" }
);
for path in paths {
println!(" {path}");
}
println!(
"Recorded in {}. Delete a line from its `pinned` list to bring a file back\n\
under reconciliation.",
scaffold::MANIFEST_PATH
);
0
}
Err(error) => {
eprintln!("autumn upgrade: {error}");
2
}
}
}
fn emit(report: &Report, scaffold_report: Option<&scaffold::ScaffoldReport>, json: bool) {
match (json, scaffold_report) {
(true, None) => println!("{}", render_json(report)),
(true, Some(scaffold_report)) => {
let mut value = json_value(report);
value["scaffold"] = scaffold::json(scaffold_report);
println!(
"{}",
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_owned())
);
}
(false, scaffold_report) => {
print!("{}", render_text(report));
if let Some(scaffold_report) = scaffold_report {
print!("{}", scaffold::render_text(scaffold_report));
}
}
}
}
fn check_scaffold(root: &Path, target: &str, json: bool) -> i32 {
if !scaffold::is_project(root) {
eprintln!(
"autumn upgrade: `{}` is not an Autumn project (no `autumn.toml`).\n\
`--check` gates a project's framework-owned files against the current scaffold.",
root.display()
);
return 2;
}
let report = scaffold::plan(root, target);
if json {
println!(
"{}",
serde_json::to_string_pretty(
&serde_json::json!({ "scaffold": scaffold::json(&report) })
)
.unwrap_or_else(|_| "{}".to_owned())
);
} else {
print!("{}", scaffold::render_summary(&report));
}
if let Some(newer) = &report.scaffolded_by_newer {
eprintln!(
"autumn upgrade: `{}` was scaffolded by autumn-cli {newer}, newer than this one \
({}).\n\
Nothing was checked: reconciling would downgrade its files. Install {newer} or later.",
root.display(),
env!("CARGO_PKG_VERSION")
);
return 2;
}
if !report.named {
eprintln!(
"autumn upgrade: `{}` has no usable `[package] name` in Cargo.toml, and the\n\
scaffold interpolates it. Nothing was checked.",
root.display()
);
return 2;
}
if report.drifted() { DRIFT_EXIT_CODE } else { 0 }
}
fn list_migrations(json: bool) {
let all: Vec<&'static AppMigration> = migrations::app_migrations().iter().collect();
if json {
println!(
"{}",
serde_json::to_string_pretty(
&serde_json::json!({ "migrations": migrations_json(&all) })
)
.unwrap_or_else(|_| "{}".to_owned())
);
return;
}
println!("Shipped app-code migrations ({}):", all.len());
for migration in &all {
println!(
" {:<6} {} {}",
migration.confidence.label(),
migration.id,
migration.title
);
println!(" {}", migrations::guide_url(migration.guide));
}
}
#[must_use]
pub fn run_in(root: &Path, opts: &UpgradeOptions) -> i32 {
if let Some(code) = reject_bad_combination(opts) {
return code;
}
if opts.list {
list_migrations(opts.json);
return 0;
}
let target = opts
.to
.clone()
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_owned());
let Some(to) = migrations::parse_version_req(&target) else {
eprintln!("autumn upgrade: cannot parse --to version `{target}`");
return 2;
};
if std::fs::read_dir(root).is_err() {
eprintln!(
"autumn upgrade: `{}` is not a readable directory.",
root.display()
);
return 2;
}
if !opts.accept.is_empty() {
return accept_scaffold(root, &opts.accept, opts.json);
}
if opts.check {
return check_scaffold(root, &target, opts.json);
}
let recorded = opts.from.clone().or_else(|| recorded_version(root));
let Some(recorded) = recorded else {
eprintln!(
"autumn upgrade: cannot tell which autumn-web version this app is on.\n\
Add an `autumn-web` dependency to Cargo.toml, or pass `--from <version>`."
);
return 2;
};
let Some(from) = migrations::parse_version_req(&recorded) else {
eprintln!(
"autumn upgrade: cannot parse the recorded autumn-web version `{recorded}`.\n\
Pass `--from <version>` with the release this app is upgrading from."
);
return 2;
};
let mut report = plan(root, from, to);
let mut failure = None;
let mut scaffold_failure = None;
if opts.apply {
match write_plan(&report) {
Ok(()) => report.outcome = Outcome::Applied,
Err(write_failure) => {
report.outcome = Outcome::Partial {
written: write_failure.written,
};
failure = Some(write_failure);
}
}
}
let mut scaffold_report = plan_scaffold(root, &target, &report);
if opts.apply
&& failure.is_none()
&& let Some(scaffold_report) = scaffold_report.as_mut()
{
scaffold_failure = scaffold::apply(scaffold_report).err();
}
emit(&report, scaffold_report.as_ref(), opts.json);
if scaffold_report.is_some()
&& opts
.to
.as_deref()
.is_some_and(|to| to != env!("CARGO_PKG_VERSION"))
{
eprintln!(
"autumn upgrade: `--to` selects which app-code migrations run. The scaffold\n\
files were reconciled against {}, the only scaffold this CLI ships.",
env!("CARGO_PKG_VERSION")
);
}
if let Some(WriteFailure { path, error, .. }) = failure {
eprintln!(
"autumn upgrade: failed while writing {path}: {error}\n\
Files listed before it in the report above were already written; \
`git diff` shows exactly what changed."
);
return 1;
}
if let Some(scaffold::WriteFailure {
path,
error,
written,
}) = scaffold_failure
{
eprintln!(
"autumn upgrade: failed while writing the scaffold file {path}: {error}\n\
{written} scaffold file(s) were written before it; \
`git diff` shows exactly what changed."
);
return 1;
}
0
}
#[cfg(test)]
mod tests {
use super::migrations::{CallForm, Confidence, Rewrite};
use super::*;
static AUTO: AppMigration = AppMigration {
id: "0.6.0-pool-rename",
version: "0.6.0",
title: "`with_pool` is renamed to `with_pool_untracked`",
confidence: Confidence::Auto,
guide: "docs/migrations/0.6.0.md#rename",
rewrite: Rewrite::CallRename {
from: "with_pool",
to: "with_pool_untracked",
form: CallForm::AssociatedFunction,
args: 1,
receiver: None,
},
};
static MANUAL: AppMigration = AppMigration {
id: "0.6.0-jwt-secret",
version: "0.6.0",
title: "`jwt_secret` is now a `SecretString`",
confidence: Confidence::Manual,
guide: "docs/migrations/0.6.0.md#secret",
rewrite: Rewrite::GuideOnly,
};
fn version(major: u64, minor: u64, patch: u64) -> Version {
Version::new(major, minor, patch)
}
fn sample(outcome: Outcome) -> Report {
Report {
from: version(0, 5, 0),
to: version(0, 6, 0),
outcome,
files_scanned: 7,
migrations: vec![&AUTO, &MANUAL],
files: vec![FileReport {
path: "src/main.rs".into(),
sites: vec![engine::Site {
line: 12,
column: 19,
migration: AUTO.id,
manual: None,
}],
diff: "@@ line 12 @@\n-a\n+b\n".into(),
updated: "b\n".into(),
original: "a\n".into(),
absolute: PathBuf::from("/tmp/app/src/main.rs"),
}],
review: Vec::new(),
manual: vec![
ManualEntry {
path: Some("src/lib.rs".into()),
line: Some(40),
migration: AUTO.id,
reason: "inside a macro invocation".into(),
guide: AUTO.guide,
},
ManualEntry {
path: None,
line: None,
migration: MANUAL.id,
reason: "no machine-applyable rewrite".into(),
guide: MANUAL.guide,
},
],
skipped: vec![("src/broken.rs".into(), "expected `{`".into())],
}
}
fn empty(outcome: Outcome) -> Report {
Report {
from: version(0, 6, 0),
to: version(0, 6, 0),
outcome,
files_scanned: 3,
migrations: Vec::new(),
files: Vec::new(),
review: Vec::new(),
manual: Vec::new(),
skipped: Vec::new(),
}
}
#[test]
fn rewritten_sites_totals_every_file() {
assert_eq!(sample(Outcome::Preview).rewritten_sites(), 1);
assert_eq!(empty(Outcome::Preview).rewritten_sites(), 0);
}
#[test]
fn preview_text_shows_the_diff_the_counts_and_that_nothing_was_written() {
let out = render_text(&sample(Outcome::Preview));
assert!(out.contains("src/main.rs"), "{out}");
assert!(out.contains("@@ line 12 @@"), "{out}");
assert!(out.contains("1 site"), "site count is reported: {out}");
assert!(
out.contains("--apply"),
"the preview must name the explicit write step: {out}"
);
assert!(out.to_lowercase().contains("nothing was written"), "{out}");
}
#[test]
fn preview_text_lists_every_manual_site_with_location_and_guide() {
let out = render_text(&sample(Outcome::Preview));
assert!(out.contains("src/lib.rs:40"), "{out}");
assert!(out.contains("inside a macro invocation"), "{out}");
assert!(out.contains("docs/migrations/0.6.0.md#rename"), "{out}");
assert!(out.contains("docs/migrations/0.6.0.md#secret"), "{out}");
}
#[test]
fn preview_text_labels_each_selected_migration_with_its_confidence() {
let out = render_text(&sample(Outcome::Preview));
assert!(out.contains("auto 0.6.0-pool-rename"), "{out}");
assert!(out.contains("manual 0.6.0-jwt-secret"), "{out}");
}
#[test]
fn preview_text_reports_skipped_files() {
let out = render_text(&sample(Outcome::Preview));
assert!(out.contains("src/broken.rs"), "{out}");
assert!(out.contains("expected `{`"), "{out}");
}
#[test]
fn applied_text_says_what_was_written_and_not_what_would_be() {
let out = render_text(&sample(Outcome::Applied));
assert!(!out.to_lowercase().contains("nothing was written"), "{out}");
assert!(out.contains("1 site in 1 file rewritten"), "{out}");
}
#[test]
fn an_unaffected_app_reports_nothing_to_change() {
let out = render_text(&empty(Outcome::Preview));
assert!(
out.to_lowercase().contains("nothing to change"),
"an app that never used the affected APIs must say so plainly: {out}"
);
assert!(!out.contains("@@"), "no diff to show: {out}");
}
static REVIEW: AppMigration = AppMigration {
id: "0.6.0-review-rename",
version: "0.6.0",
title: "a rename worth a second look",
confidence: Confidence::Review,
guide: "docs/migrations/0.6.0.md#review",
rewrite: Rewrite::CallRename {
from: "with_pool",
to: "with_pool_untracked",
form: CallForm::AssociatedFunction,
args: 1,
receiver: None,
},
};
#[test]
fn a_review_migration_rewrites_and_flags_every_site_it_touched() {
let root = tempfile::TempDir::new().expect("tempdir");
std::fs::create_dir_all(root.path().join("src")).expect("src");
std::fs::write(
root.path().join("src/main.rs"),
"fn a(p: Pool) {\n Repo::with_pool(p);\n}\n",
)
.expect("source");
let report = plan_with(root.path(), version(0, 5, 0), version(0, 6, 0), &[&REVIEW]);
assert_eq!(
report.rewritten_sites(),
1,
"a review migration still rewrites"
);
assert_eq!(report.review.len(), 1, "and flags the site it rewrote");
assert_eq!(report.review[0].path.as_deref(), Some("src/main.rs"));
assert_eq!(report.review[0].line, Some(2));
assert_eq!(report.review[0].migration, REVIEW.id);
assert_eq!(report.review[0].guide, REVIEW.guide);
assert!(
report.manual.is_empty(),
"a review site is not a manual one"
);
let text = render_text(&report);
assert!(text.contains("Review - rewritten"), "{text}");
assert!(text.contains("src/main.rs:2"), "{text}");
assert!(text.contains("review 0.6.0-review-rename"), "{text}");
let json: serde_json::Value =
serde_json::from_str(&render_json(&report)).expect("valid JSON");
assert_eq!(json["review"][0]["line"], 2);
assert_eq!(json["migrations"][0]["confidence"], "review");
}
#[test]
fn an_auto_migration_flags_no_sites_for_review() {
let root = tempfile::TempDir::new().expect("tempdir");
std::fs::create_dir_all(root.path().join("src")).expect("src");
std::fs::write(
root.path().join("src/main.rs"),
"fn a(p: Pool) {\n Repo::with_pool(p);\n}\n",
)
.expect("source");
let report = plan_with(root.path(), version(0, 5, 0), version(0, 6, 0), &[&AUTO]);
assert_eq!(report.rewritten_sites(), 1);
assert!(
report.review.is_empty(),
"auto sites are not flagged one by one"
);
}
#[test]
fn cargo_metadata_names_the_real_target_directory() {
let root = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
root.path().join("Cargo.toml"),
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.expect("manifest");
std::fs::create_dir_all(root.path().join("src")).expect("src");
std::fs::write(root.path().join("src/main.rs"), "fn main() {}\n").expect("source");
std::fs::create_dir_all(root.path().join("target")).expect("target");
let expected = std::fs::canonicalize(root.path())
.expect("canonicalize")
.join("target");
assert_eq!(cargo_metadata_target_dir(root.path()), Some(expected));
}
#[test]
fn cargo_metadata_target_dir_is_none_without_a_manifest() {
let root = tempfile::TempDir::new().expect("tempdir");
assert_eq!(cargo_metadata_target_dir(root.path()), None);
}
#[test]
fn json_report_carries_the_range_counts_and_labels() {
let out = render_json(&sample(Outcome::Preview));
let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(value["from"], "0.5.0");
assert_eq!(value["to"], "0.6.0");
assert_eq!(value["applied"], false);
assert_eq!(value["files_scanned"], 7);
assert_eq!(value["rewritten_sites"], 1);
assert_eq!(value["migrations"][0]["id"], "0.6.0-pool-rename");
assert_eq!(value["migrations"][0]["confidence"], "auto");
assert_eq!(value["files"][0]["path"], "src/main.rs");
assert_eq!(value["files"][0]["sites"][0]["line"], 12);
assert_eq!(value["manual"][0]["path"], "src/lib.rs");
assert_eq!(value["manual"][0]["line"], 40);
assert_eq!(value["skipped"][0]["path"], "src/broken.rs");
}
#[test]
fn a_partial_apply_never_claims_nothing_was_written() {
let mut report = sample(Outcome::Preview);
report.files.push(FileReport {
path: "src/second.rs".into(),
sites: vec![
engine::Site {
line: 4,
column: 9,
migration: AUTO.id,
manual: None,
},
engine::Site {
line: 7,
column: 9,
migration: AUTO.id,
manual: None,
},
],
diff: "@@ line 4 @@\n-a\n+b\n".into(),
updated: "b\n".into(),
original: "a\n".into(),
absolute: PathBuf::from("/tmp/app/src/second.rs"),
});
report.outcome = Outcome::Partial { written: 1 };
let text = render_text(&report);
assert!(
text.contains("1 site in 1 file written before the run stopped"),
"only what landed is counted as written: {text}"
);
assert!(
text.contains("2 sites in 1 file planned and not written"),
"and the rest is named as not written: {text}"
);
assert!(
!text.contains("3 sites in 2 files rewritten"),
"the whole plan must not be claimed as rewritten: {text}"
);
assert!(
!text.to_lowercase().contains("nothing was written"),
"the tree was modified: {text}"
);
assert!(
!text.contains("Re-run with `--apply`"),
"the apply step already ran: {text}"
);
let json: serde_json::Value =
serde_json::from_str(&render_json(&report)).expect("valid JSON");
assert_eq!(json["outcome"], "partial");
assert_eq!(json["files_written"], 1);
assert_eq!(
json["applied"], false,
"`applied` stays a strict did-everything-land flag"
);
assert_eq!(
json["rewritten_sites"], 3,
"the plan is still reported in full"
);
assert_eq!(
json["written_sites"], 1,
"but only the sites in the files that reached disk are written"
);
}
#[test]
fn a_complete_apply_reports_every_file_it_wrote() {
let json: serde_json::Value =
serde_json::from_str(&render_json(&sample(Outcome::Applied))).expect("valid JSON");
assert_eq!(json["outcome"], "applied");
assert_eq!(json["applied"], true);
assert_eq!(json["files_written"], 1);
assert_eq!(
json["written_sites"], json["rewritten_sites"],
"a complete apply writes everything it planned"
);
}
#[test]
fn a_preview_wrote_nothing_and_says_so_in_json() {
let json: serde_json::Value =
serde_json::from_str(&render_json(&sample(Outcome::Preview))).expect("valid JSON");
assert_eq!(json["outcome"], "preview");
assert_eq!(json["applied"], false);
assert_eq!(json["files_written"], 0);
}
#[test]
fn json_report_omits_the_rewritten_file_bodies() {
let out = render_json(&sample(Outcome::Preview));
assert!(!out.contains("\"updated\""), "{out}");
}
}