use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::config::GENERATE_CONFIG_FILENAME;
use super::spelling;
pub const MANIFEST_PATH: &str = ".autumn/generated.toml";
const UNIT_SEPARATOR: char = '\u{1f}';
const RECORD_SEPARATOR: char = '\u{1e}';
fn normalize(contents: &str) -> String {
contents.replace("\r\n", "\n")
}
#[must_use]
pub fn text_digest(contents: &str) -> String {
digest(normalize(contents).as_bytes())
}
#[must_use]
pub fn bytes_digest(bytes: &[u8]) -> String {
digest(bytes)
}
fn digest(bytes: &[u8]) -> String {
use sha2::{Digest as _, Sha256};
hex::encode(Sha256::digest(bytes))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
Owned,
Shared,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Recorded<'a> {
pub key: &'a str,
pub digest: &'a str,
pub scope: Option<Scope>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Entry {
digest: String,
invocation: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "lenient_scope"
)]
scope: Option<Scope>,
}
fn lenient_scope<'de, D>(deserializer: D) -> Result<Option<Scope>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<toml::Value>::deserialize(deserializer)?;
Ok(match value.as_ref().and_then(toml::Value::as_str) {
Some("owned") => Some(Scope::Owned),
Some("shared") => Some(Scope::Shared),
_ => None,
})
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct RevertAnchor {
pub path: String,
pub revert: String,
}
impl RevertAnchor {
#[must_use]
pub fn kind(&self) -> &str {
self.revert.split('(').next().unwrap_or(&self.revert)
}
#[must_use]
pub fn is_project_relative(&self) -> bool {
Path::new(&self.path)
.components()
.all(|c| matches!(c, Component::Normal(_)))
}
}
impl std::fmt::Display for RevertAnchor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.path, self.revert)
}
}
#[derive(Default, Serialize, Deserialize)]
struct ManifestFile {
#[serde(default)]
files: BTreeMap<String, Entry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
runs: BTreeMap<String, Vec<RevertAnchor>>,
}
fn run_id(invocation: &str) -> String {
digest(invocation.as_bytes())
}
#[must_use]
pub fn current_invocation(root: &Path) -> String {
let args: Vec<String> = std::env::args_os()
.skip(1)
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
let inputs = normalize_invocation(args.iter().cloned());
let config = config_fingerprint(root, &args);
let backend = backend_fingerprint(root);
format!("{inputs}{RECORD_SEPARATOR}{config}{RECORD_SEPARATOR}{backend}")
}
#[must_use]
pub fn resolved_invocation(root: &Path, parts: &[&str]) -> String {
let inputs = parts.join(&UNIT_SEPARATOR.to_string());
let backend = backend_fingerprint(root);
format!("{inputs}{RECORD_SEPARATOR}{backend}")
}
fn backend_fingerprint(root: &Path) -> &'static str {
match super::detect_backend(root) {
autumn_web::config::DatabaseBackend::Postgres => "postgres",
autumn_web::config::DatabaseBackend::Sqlite => "sqlite",
}
}
fn config_fingerprint(root: &Path, args: &[String]) -> String {
let mut sources = vec![root.join(GENERATE_CONFIG_FILENAME)];
let mut next_is_path = false;
for arg in args {
if next_is_path {
sources.push(PathBuf::from(arg));
next_is_path = false;
} else if let Some(path) = arg.strip_prefix("--config=") {
sources.push(PathBuf::from(path));
} else if arg == "--config" {
next_is_path = true;
}
}
let mut material = String::new();
let mut seen: Vec<&Path> = Vec::new();
for source in &sources {
if seen.contains(&source.as_path()) {
continue; }
seen.push(source);
let contents = std::fs::read(source).map_or_else(
|_| "absent".to_owned(),
|bytes| std::str::from_utf8(&bytes).map_or_else(|_| digest(&bytes), text_digest),
);
material.push_str(&contents);
material.push(RECORD_SEPARATOR);
}
digest(material.as_bytes())
}
fn normalize_invocation(args: impl Iterator<Item = String>) -> String {
let mut verb_seen = false;
let mut parts = Vec::new();
for arg in args {
if !verb_seen && (arg == "generate" || arg == "destroy") {
verb_seen = true;
continue;
}
if matches!(arg.as_str(), "--force" | "--dry-run") {
continue;
}
parts.push(arg);
}
parts.join(&UNIT_SEPARATOR.to_string())
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Provenance {
entries: BTreeMap<String, Entry>,
runs: BTreeMap<String, Vec<RevertAnchor>>,
}
impl Provenance {
#[must_use]
pub fn load(root: &Path) -> Self {
if refuse_symlink(root, &root.join(MANIFEST_PATH)).is_err() {
return Self::default();
}
let text = std::fs::read_to_string(root.join(MANIFEST_PATH)).unwrap_or_default();
let file: ManifestFile = toml::from_str(&text).unwrap_or_default();
Self {
entries: file.files,
runs: file.runs,
}
}
#[cfg(test)]
pub fn contains(&self, key: &str) -> bool {
self.entries.contains_key(key)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn is_ours(&self, root: &Path, path: &Path, digest: &str, invocation: &str) -> bool {
self.owners(root, path)
.iter()
.any(|e| e.digest == digest && e.invocation == invocation)
}
#[must_use]
pub fn was_written(&self, root: &Path, path: &Path, digest: &str) -> bool {
self.owners(root, path).iter().any(|e| e.digest == digest)
}
pub fn recorded_by<'a>(&'a self, invocation: &'a str) -> impl Iterator<Item = Recorded<'a>> {
self.entries
.iter()
.filter(move |(_, e)| e.invocation == invocation)
.map(|(key, e)| Recorded {
key,
digest: &e.digest,
scope: e.scope,
})
}
pub fn record(
&mut self,
root: &Path,
path: &Path,
digest: String,
invocation: &str,
scope: Option<Scope>,
) {
if let Some(k) = key(root, path) {
self.forget(root, path);
self.entries.insert(
k,
Entry {
digest,
invocation: invocation.to_owned(),
scope,
},
);
}
}
pub fn record_reverts(&mut self, invocation: &str, anchors: Vec<RevertAnchor>) {
if anchors.is_empty() {
return;
}
let run = self.runs.entry(run_id(invocation)).or_default();
run.extend(anchors);
run.sort();
run.dedup();
}
#[must_use]
pub fn dropped_reverts(
&self,
root: &Path,
invocation: &str,
planned: &[RevertAnchor],
) -> Vec<RevertAnchor> {
self.runs
.get(&run_id(invocation))
.into_iter()
.flatten()
.filter(|anchor| !planned.contains(anchor))
.filter(|anchor| planned.iter().any(|p| p.kind() == anchor.kind()))
.filter(|anchor| anchor.is_project_relative() && root.join(&anchor.path).exists())
.cloned()
.collect()
}
pub fn forget_run(&mut self, invocation: &str) {
self.runs.remove(&run_id(invocation));
}
pub fn forget(&mut self, root: &Path, path: &Path) {
for k in self.keys_for(root, path) {
self.entries.remove(&k);
}
}
#[must_use]
pub fn keys_for(&self, root: &Path, path: &Path) -> Vec<String> {
let Some(own) = key(root, path) else {
return Vec::new();
};
self.entries
.keys()
.filter(|k| **k == own || is_alias(root, k, &own, path))
.cloned()
.collect()
}
fn owners(&self, root: &Path, path: &Path) -> Vec<&Entry> {
let Some(own) = key(root, path) else {
return Vec::new();
};
if let Some(entry) = self.entries.get(&own) {
return vec![entry];
}
self.entries
.iter()
.filter(|(k, _)| is_alias(root, k, &own, path))
.map(|(_, e)| e)
.collect()
}
pub fn forget_key(&mut self, k: &str) {
self.entries.remove(k);
}
pub fn forget_dir(&mut self, root: &Path, dir: &Path) {
let Some(prefix) = key(root, dir) else { return };
let prefix = format!("{prefix}/");
self.entries.retain(|k, _| !k.starts_with(&prefix));
}
pub fn save(&self, root: &Path) -> std::io::Result<()> {
let path = root.join(MANIFEST_PATH);
refuse_symlink(root, &path)?;
if self.entries.is_empty() {
return match std::fs::remove_file(&path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
};
}
let live: std::collections::BTreeSet<String> = self
.entries
.values()
.map(|e| run_id(&e.invocation))
.collect();
let runs = self
.runs
.iter()
.filter(|(id, _)| live.contains(*id))
.map(|(id, anchors)| (id.clone(), anchors.clone()))
.collect();
let body = toml::to_string(&ManifestFile {
files: self.entries.clone(),
runs,
})
.map_err(std::io::Error::other)?;
let directory = path
.parent()
.ok_or_else(|| std::io::Error::other("manifest path has no parent directory"))?;
std::fs::create_dir_all(directory)?;
write_atomically(directory, &path, &body)
}
}
fn write_atomically(directory: &Path, path: &Path, body: &str) -> std::io::Result<()> {
use std::io::Write as _;
let mut temp = tempfile::Builder::new()
.prefix(".autumn-generated-")
.suffix(".tmp")
.make_in(directory, |p| {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(p)
})?;
temp.as_file_mut().write_all(body.as_bytes())?;
temp.as_file_mut().sync_all()?;
temp.persist(path).map_err(|e| e.error)?;
Ok(())
}
fn refuse_symlink(root: &Path, path: &Path) -> std::io::Result<()> {
let mut cursor = root.to_path_buf();
for component in MANIFEST_PATH.split('/') {
cursor.push(component);
match std::fs::symlink_metadata(&cursor) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
Ok(m) if m.file_type().is_symlink() => {
return Err(std::io::Error::other(format!(
"{} is a symlink; reading or writing through it could reach \
outside the project",
path.display()
)));
}
Ok(_) => {}
}
}
Ok(())
}
fn is_alias(root: &Path, k: &str, own: &str, path: &Path) -> bool {
k.eq_ignore_ascii_case(own) && spelling::one_entry(&root.join(k), path)
}
pub(super) fn key(root: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(root).ok()?;
let mut parts = Vec::new();
for component in relative.components() {
match component {
Component::Normal(part) => parts.push(part.to_str()?),
Component::Prefix(_)
| Component::RootDir
| Component::CurDir
| Component::ParentDir => return None,
}
}
(!parts.is_empty()).then(|| parts.join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> tempfile::TempDir {
tempfile::TempDir::new().unwrap()
}
const OWNER: &str = "model\u{1f}Post\u{1f}title:String";
#[test]
fn records_and_reloads_a_digest() {
let dir = tmp();
let file = dir.path().join("src/models/post.rs");
let mut p = Provenance::default();
p.record(dir.path(), &file, text_digest("// model\n"), OWNER, None);
p.save(dir.path()).unwrap();
let reloaded = Provenance::load(dir.path());
assert!(reloaded.is_ours(dir.path(), &file, &text_digest("// model\n"), OWNER));
assert!(!reloaded.is_ours(dir.path(), &file, &text_digest("// edited\n"), OWNER));
}
#[test]
fn a_different_command_never_owns_a_recorded_file() {
let dir = tmp();
let file = dir.path().join("src/models/post.rs");
let mut p = Provenance::default();
p.record(dir.path(), &file, text_digest("// model\n"), OWNER, None);
assert!(!p.is_ours(
dir.path(),
&file,
&text_digest("// model\n"),
"model\u{1f}Post"
));
}
#[test]
fn the_invocation_drops_the_verb_and_the_run_only_flags() {
let normalize = |args: &[&str]| normalize_invocation(args.iter().map(|a| (*a).to_owned()));
assert_eq!(
normalize(&["generate", "model", "Post", "title:String"]),
normalize(&["destroy", "model", "Post", "title:String", "--force"]),
);
assert_eq!(
normalize(&["generate", "model", "Post", "--dry-run"]),
normalize(&["generate", "model", "Post"]),
);
assert_ne!(
normalize(&["generate", "model", "Post", "title:String"]),
normalize(&["destroy", "model", "Post"]),
"omitted fields are different arguments"
);
assert_ne!(
normalize(&["new", "--starter", "saas", "app"]),
normalize(&["destroy", "auth", "User"]),
"a starter scaffold never owns a generator's output"
);
}
#[test]
fn arguments_cannot_collide_by_concatenation() {
let normalize = |args: &[&str]| normalize_invocation(args.iter().map(|a| (*a).to_owned()));
assert_ne!(
normalize(&["model", "Post Tag"]),
normalize(&["model", "Post", "Tag"])
);
}
#[test]
fn crlf_hashes_the_same_as_lf() {
assert_eq!(text_digest("a\r\nb\r\n"), text_digest("a\nb\n"));
}
#[test]
fn bytes_are_hashed_verbatim() {
assert_ne!(bytes_digest(b"a\r\nb"), bytes_digest(b"a\nb"));
}
#[test]
fn an_unrecorded_file_is_never_ours() {
let dir = tmp();
let p = Provenance::default();
assert!(!p.is_ours(
dir.path(),
&dir.path().join("x.rs"),
&text_digest(""),
OWNER
));
}
#[test]
fn a_path_outside_the_project_is_dropped() {
let dir = tmp();
let mut p = Provenance::default();
p.record(
dir.path(),
Path::new("/etc/passwd"),
text_digest(""),
OWNER,
None,
);
assert_eq!(p, Provenance::default());
}
#[test]
fn a_path_escaping_through_parent_segments_is_dropped() {
let dir = tmp();
let mut p = Provenance::default();
p.record(
dir.path(),
&dir.path().join("../secret.toml"),
text_digest(""),
OWNER,
None,
);
assert_eq!(p, Provenance::default());
}
#[test]
fn the_identity_does_not_depend_on_where_the_project_sits() {
let here = tmp();
let there = tmp();
for root in [here.path(), there.path()] {
std::fs::write(
root.join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\nfields = [\"title:String\"]\n",
)
.unwrap();
}
assert_eq!(
current_invocation(here.path()),
current_invocation(there.path())
);
}
#[test]
fn a_crlf_checkout_of_the_config_is_the_same_input() {
let lf = tmp();
let crlf = tmp();
std::fs::write(
lf.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\nfields = [\"title:String\"]\n",
)
.unwrap();
std::fs::write(
crlf.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\r\nfields = [\"title:String\"]\r\n",
)
.unwrap();
assert_eq!(
current_invocation(lf.path()),
current_invocation(crlf.path())
);
}
#[test]
fn the_identity_still_moves_when_the_config_contents_differ() {
let here = tmp();
let there = tmp();
std::fs::write(
here.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\n",
)
.unwrap();
std::fs::write(
there.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Tag]\n",
)
.unwrap();
assert_ne!(
current_invocation(here.path()),
current_invocation(there.path())
);
}
#[test]
fn an_absent_config_is_distinguishable_from_a_present_one() {
let absent = tmp();
let present = tmp();
std::fs::write(
present.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\n",
)
.unwrap();
assert_ne!(
current_invocation(absent.path()),
current_invocation(present.path())
);
}
fn no_db_env<R>(f: impl FnOnce() -> R) -> R {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
f,
)
}
#[test]
fn the_identity_moves_with_the_database_backend() {
let sqlite = tmp();
let postgres = tmp();
std::fs::write(
sqlite.path().join("autumn.toml"),
"[database]\nurl = \"sqlite://app.db\"\n",
)
.unwrap();
std::fs::write(
postgres.path().join("autumn.toml"),
"[database]\nurl = \"postgres://localhost/app\"\n",
)
.unwrap();
no_db_env(|| {
assert_ne!(
current_invocation(sqlite.path()),
current_invocation(postgres.path())
);
});
}
#[test]
fn two_projects_on_the_same_backend_share_an_identity() {
let here = tmp();
let there = tmp();
for root in [here.path(), there.path()] {
std::fs::write(
root.join("autumn.toml"),
"[database]\nurl = \"sqlite://app.db\"\n",
)
.unwrap();
}
no_db_env(|| {
assert_eq!(
current_invocation(here.path()),
current_invocation(there.path())
);
});
}
#[test]
fn a_malformed_manifest_reads_as_no_baseline() {
let dir = tmp();
std::fs::create_dir_all(dir.path().join(".autumn")).unwrap();
std::fs::write(dir.path().join(MANIFEST_PATH), "not : toml [[[").unwrap();
assert_eq!(Provenance::load(dir.path()), Provenance::default());
}
#[test]
fn saving_an_empty_manifest_removes_it() {
let dir = tmp();
let file = dir.path().join("a.rs");
let mut p = Provenance::default();
p.record(dir.path(), &file, text_digest("x"), OWNER, None);
p.save(dir.path()).unwrap();
assert!(dir.path().join(MANIFEST_PATH).is_file());
p.forget(dir.path(), &file);
p.save(dir.path()).unwrap();
assert!(!dir.path().join(MANIFEST_PATH).exists());
}
#[test]
fn forget_dir_drops_only_that_directory() {
let dir = tmp();
let mut p = Provenance::default();
p.record(
dir.path(),
&dir.path().join("migrations/a/up.sql"),
"1".to_owned(),
OWNER,
None,
);
p.record(
dir.path(),
&dir.path().join("migrations/ab/up.sql"),
"2".to_owned(),
OWNER,
None,
);
p.forget_dir(dir.path(), &dir.path().join("migrations/a"));
assert!(!p.contains("migrations/a/up.sql"));
assert!(p.contains("migrations/ab/up.sql"));
}
#[test]
fn a_scope_survives_a_save_and_load() {
let dir = tmp();
let owned = dir.path().join("src/a.rs");
let shared = dir.path().join("templates/_layout.html");
let mut p = Provenance::default();
p.record(
dir.path(),
&owned,
"1".to_owned(),
OWNER,
Some(Scope::Owned),
);
p.record(
dir.path(),
&shared,
"2".to_owned(),
OWNER,
Some(Scope::Shared),
);
p.save(dir.path()).unwrap();
let reloaded = Provenance::load(dir.path());
let mut seen: Vec<_> = reloaded.recorded_by(OWNER).collect();
seen.sort_by_key(|r| r.key);
assert_eq!(
seen,
vec![
Recorded {
key: "src/a.rs",
digest: "1",
scope: Some(Scope::Owned)
},
Recorded {
key: "templates/_layout.html",
digest: "2",
scope: Some(Scope::Shared)
},
]
);
}
#[test]
fn an_entry_without_a_scope_writes_no_scope_key() {
let dir = tmp();
let mut p = Provenance::default();
p.record(
dir.path(),
&dir.path().join("a.rs"),
"1".to_owned(),
OWNER,
None,
);
p.save(dir.path()).unwrap();
let text = std::fs::read_to_string(dir.path().join(MANIFEST_PATH)).unwrap();
assert!(!text.contains("scope"), "{text}");
}
#[test]
fn a_legacy_entry_loads_with_no_scope() {
let dir = tmp();
std::fs::create_dir_all(dir.path().join(".autumn")).unwrap();
std::fs::write(
dir.path().join(MANIFEST_PATH),
"[files.\"src/a.rs\"]\ndigest = \"1\"\ninvocation = \"x\"\n",
)
.unwrap();
let p = Provenance::load(dir.path());
let seen: Vec<_> = p.recorded_by("x").collect();
assert_eq!(
seen,
vec![Recorded {
key: "src/a.rs",
digest: "1",
scope: None
}]
);
}
#[test]
fn an_unknown_scope_reads_as_none_and_keeps_the_other_entries() {
let dir = tmp();
std::fs::create_dir_all(dir.path().join(".autumn")).unwrap();
std::fs::write(
dir.path().join(MANIFEST_PATH),
"[files.\"a.rs\"]\ndigest = \"1\"\ninvocation = \"x\"\nscope = \"Owned\"\n\n\
[files.\"b.rs\"]\ndigest = \"2\"\ninvocation = \"x\"\nscope = 7\n\n\
[files.\"c.rs\"]\ndigest = \"3\"\ninvocation = \"x\"\nscope = \"owned\"\n",
)
.unwrap();
let p = Provenance::load(dir.path());
let mut seen: Vec<_> = p.recorded_by("x").map(|r| (r.key, r.scope)).collect();
seen.sort_unstable();
assert_eq!(
seen,
vec![("a.rs", None), ("b.rs", None), ("c.rs", Some(Scope::Owned))]
);
}
#[test]
fn recorded_by_lists_only_that_invocation() {
let dir = tmp();
let mut p = Provenance::default();
p.record(
dir.path(),
&dir.path().join("a.rs"),
"1".to_owned(),
OWNER,
None,
);
p.record(
dir.path(),
&dir.path().join("b.rs"),
"2".to_owned(),
"other",
None,
);
let keys: Vec<_> = p.recorded_by(OWNER).map(|r| r.key).collect();
assert_eq!(keys, vec!["a.rs"]);
}
fn anchor(path: &str, revert: &str) -> RevertAnchor {
RevertAnchor {
path: path.to_owned(),
revert: revert.to_owned(),
}
}
fn recorded_run(dir: &Path, anchors: Vec<RevertAnchor>) -> Provenance {
let mut p = Provenance::default();
p.record(dir, &dir.join("a.rs"), text_digest("x"), OWNER, None);
p.record_reverts(OWNER, anchors);
p.save(dir).unwrap();
Provenance::load(dir)
}
#[test]
fn reverts_survive_a_save_and_load() {
let dir = tmp();
let a = anchor("i18n/en.ftl", "I18nFtlKeys(Post)");
let p = recorded_run(dir.path(), vec![a.clone()]);
std::fs::create_dir_all(dir.path().join("i18n")).unwrap();
std::fs::write(dir.path().join("i18n/en.ftl"), "").unwrap();
let moved = anchor("elsewhere", "I18nFtlKeys(Post)");
assert_eq!(p.dropped_reverts(dir.path(), OWNER, &[moved]), vec![a]);
}
#[test]
fn a_revert_that_vanished_is_a_skip_not_a_drop() {
let dir = tmp();
let p = recorded_run(
dir.path(),
vec![anchor(
"autumn.toml",
"SubmitTokenValidateExempt(posts, validate)",
)],
);
std::fs::write(dir.path().join("autumn.toml"), "").unwrap();
assert!(
p.dropped_reverts(dir.path(), OWNER, &[anchor("src/main.rs", "ModDecl(post)")])
.is_empty()
);
}
#[test]
fn a_revert_that_moved_within_its_kind_is_a_drop() {
let dir = tmp();
let old = anchor("i18n/en.ftl", "I18nFtlKeys(Post)");
let p = recorded_run(dir.path(), vec![old.clone()]);
std::fs::create_dir_all(dir.path().join("i18n")).unwrap();
std::fs::write(dir.path().join("i18n/en.ftl"), "").unwrap();
assert_eq!(
p.dropped_reverts(
dir.path(),
OWNER,
&[anchor("translations/en.ftl", "I18nFtlKeys(Post)")]
),
vec![old]
);
}
#[test]
fn a_recorded_path_that_leaves_the_project_is_ignored() {
let dir = tmp();
let outside = tmp();
std::fs::write(outside.path().join("x"), "").unwrap();
let absolute = outside.path().join("x").to_string_lossy().into_owned();
let parent = format!(
"../{}/x",
outside.path().file_name().unwrap().to_string_lossy()
);
for path in [absolute, parent] {
let p = recorded_run(dir.path(), vec![anchor(&path, "A(x)")]);
let planned = [anchor("elsewhere", "A(y)")];
assert!(
p.dropped_reverts(dir.path(), OWNER, &planned).is_empty(),
"{path}"
);
}
}
#[test]
fn the_kind_is_the_text_before_the_first_paren() {
assert_eq!(anchor("a", "ModDecl(post)").kind(), "ModDecl");
assert_eq!(
anchor("a", "PwaMainRsInjection").kind(),
"PwaMainRsInjection"
);
}
#[test]
fn forgetting_a_run_leaves_another_invocations_run() {
let dir = tmp();
let mut p = recorded_run(dir.path(), vec![anchor("a", "A(x)")]);
p.record(
dir.path(),
&dir.path().join("b.rs"),
"2".to_owned(),
"other",
None,
);
p.record_reverts("other", vec![anchor("b", "B(y)")]);
p.forget_run(OWNER);
p.save(dir.path()).unwrap();
let text = std::fs::read_to_string(dir.path().join(MANIFEST_PATH)).unwrap();
assert!(text.contains("B(y)") && !text.contains("A(x)"), "{text}");
}
#[test]
fn a_recorded_revert_that_is_still_planned_is_not_dropped() {
let dir = tmp();
let a = anchor("i18n/en.ftl", "I18nFtlKeys(Post)");
let p = recorded_run(dir.path(), vec![a.clone()]);
std::fs::create_dir_all(dir.path().join("i18n")).unwrap();
std::fs::write(dir.path().join("i18n/en.ftl"), "").unwrap();
assert!(p.dropped_reverts(dir.path(), OWNER, &[a]).is_empty());
}
#[test]
fn an_extra_planned_revert_is_not_a_drop() {
let dir = tmp();
let a = anchor("i18n/en.ftl", "I18nFtlKeys(Post)");
let extra = anchor("i18n/fr.ftl", "I18nFtlKeys(Post)");
let p = recorded_run(dir.path(), vec![a.clone()]);
std::fs::create_dir_all(dir.path().join("i18n")).unwrap();
std::fs::write(dir.path().join("i18n/en.ftl"), "").unwrap();
assert!(p.dropped_reverts(dir.path(), OWNER, &[a, extra]).is_empty());
}
#[test]
fn a_dropped_revert_whose_target_is_gone_is_not_reported() {
let dir = tmp();
let p = recorded_run(dir.path(), vec![anchor("i18n/en.ftl", "I18nFtlKeys(Post)")]);
assert!(p.dropped_reverts(dir.path(), OWNER, &[]).is_empty());
}
#[test]
fn another_invocations_reverts_are_not_this_ones() {
let dir = tmp();
let p = recorded_run(dir.path(), vec![anchor("i18n/en.ftl", "I18nFtlKeys(Post)")]);
std::fs::create_dir_all(dir.path().join("i18n")).unwrap();
std::fs::write(dir.path().join("i18n/en.ftl"), "").unwrap();
let moved = [anchor("elsewhere", "I18nFtlKeys(Post)")];
assert!(p.dropped_reverts(dir.path(), "other", &moved).is_empty());
}
#[test]
fn a_manifest_without_runs_records_no_reverts() {
let dir = tmp();
std::fs::create_dir_all(dir.path().join(".autumn")).unwrap();
std::fs::write(
dir.path().join(MANIFEST_PATH),
"[files.\"a.rs\"]\ndigest = \"1\"\ninvocation = \"x\"\n",
)
.unwrap();
let p = Provenance::load(dir.path());
assert!(p.dropped_reverts(dir.path(), "x", &[]).is_empty());
}
#[test]
fn a_run_with_no_entries_is_pruned_on_save() {
let dir = tmp();
let mut p = recorded_run(dir.path(), vec![anchor("a", "A")]);
p.forget(dir.path(), &dir.path().join("a.rs"));
p.record(
dir.path(),
&dir.path().join("b.rs"),
"2".to_owned(),
"other",
None,
);
p.save(dir.path()).unwrap();
let text = std::fs::read_to_string(dir.path().join(MANIFEST_PATH)).unwrap();
assert!(!text.contains("runs"), "{text}");
}
#[test]
fn a_run_without_reverts_writes_no_runs_key() {
let dir = tmp();
let _ = recorded_run(dir.path(), Vec::new());
let text = std::fs::read_to_string(dir.path().join(MANIFEST_PATH)).unwrap();
assert!(!text.contains("runs"), "{text}");
}
#[test]
fn a_later_record_adds_to_the_earlier_one() {
let dir = tmp();
let mut p = recorded_run(dir.path(), vec![anchor("old", "Old(a)")]);
std::fs::write(dir.path().join("old"), "").unwrap();
p.record_reverts(OWNER, vec![anchor("new", "Old(b)")]);
assert_eq!(
p.dropped_reverts(dir.path(), OWNER, &[anchor("new", "Old(b)")]),
vec![anchor("old", "Old(a)")],
"the first run's edit is still in its file"
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_manifest_is_refused() {
let dir = tmp();
let outside = tmp();
std::fs::create_dir_all(dir.path().join(".autumn")).unwrap();
std::os::unix::fs::symlink(
outside.path().join("escaped.toml"),
dir.path().join(MANIFEST_PATH),
)
.unwrap();
let mut p = Provenance::default();
p.record(
dir.path(),
&dir.path().join("a.rs"),
text_digest("x"),
OWNER,
None,
);
assert!(p.save(dir.path()).is_err());
assert!(!outside.path().join("escaped.toml").exists());
}
fn written(p: &mut Provenance, root: &Path, rel: &str, contents: &str) -> PathBuf {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, contents).unwrap();
p.record(
root,
&path,
text_digest(contents),
OWNER,
Some(Scope::Owned),
);
path
}
#[test]
fn a_case_only_alias_is_ours_only_when_it_is_one_entry() {
let dir = tmp();
let root = dir.path();
let mut p = Provenance::default();
let old = written(&mut p, root, "src/Foo.rs", "// v1\n");
let new = root.join("src/foo.rs");
let one_entry = new.exists();
let digest = text_digest("// v1\n");
assert_eq!(p.is_ours(root, &new, &digest, OWNER), one_entry);
assert_eq!(p.was_written(root, &new, &digest), one_entry);
assert!(!p.is_ours(root, &new, &digest, "other"), "another command");
let aliases: Vec<String> = if one_entry {
vec!["src/Foo.rs".to_owned()]
} else {
Vec::new()
};
assert_eq!(p.keys_for(root, &new), aliases);
assert_eq!(p.keys_for(root, &old), vec!["src/Foo.rs".to_owned()]);
}
#[test]
fn a_second_file_with_a_case_only_name_is_not_an_alias() {
let dir = tmp();
let root = dir.path();
let mut p = Provenance::default();
written(&mut p, root, "src/Foo.rs", "// v1\n");
let new = root.join("src/foo.rs");
if new.exists() {
return; }
std::fs::write(&new, "// v1\n").unwrap();
let digest = text_digest("// v1\n");
assert!(!p.is_ours(root, &new, &digest, OWNER));
assert!(!p.was_written(root, &new, &digest));
assert!(p.keys_for(root, &new).is_empty());
p.forget(root, &new);
assert!(p.contains("src/Foo.rs"));
}
#[test]
fn a_hard_linked_case_only_name_is_not_an_alias() {
let dir = tmp();
let root = dir.path();
let mut p = Provenance::default();
let old = written(&mut p, root, "src/Foo.rs", "// v1\n");
let new = root.join("src/foo.rs");
if new.exists() || std::fs::hard_link(&old, &new).is_err() {
return; }
assert!(!p.is_ours(root, &new, &text_digest("// v1\n"), OWNER));
assert!(p.keys_for(root, &new).is_empty());
}
#[test]
fn forget_drops_a_case_only_alias_while_the_file_exists() {
let dir = tmp();
let root = dir.path();
let mut p = Provenance::default();
written(&mut p, root, "src/Foo.rs", "// v1\n");
let new = root.join("src/foo.rs");
if !new.exists() {
return; }
p.forget(root, &new);
assert!(!p.contains("src/Foo.rs"));
}
#[cfg(unix)]
#[test]
fn a_symlinked_case_only_name_is_not_an_alias() {
let dir = tmp();
let root = dir.path();
let mut p = Provenance::default();
let old = written(&mut p, root, "src/Foo.rs", "// v1\n");
let new = root.join("src/foo.rs");
if new.exists() {
return; }
std::os::unix::fs::symlink(&old, &new).unwrap();
assert!(!p.is_ours(root, &new, &text_digest("// v1\n"), OWNER));
assert!(p.keys_for(root, &new).is_empty());
}
#[test]
fn the_exact_entry_decides_when_both_spellings_are_recorded() {
let dir = tmp();
let root = dir.path();
let mut p = Provenance::default();
written(&mut p, root, "src/Foo.rs", "// v1\n");
let new = root.join("src/foo.rs");
if !new.exists() {
return; }
p.entries.insert(
"src/foo.rs".to_owned(),
Entry {
digest: text_digest("// v1\n"),
invocation: "newer".to_owned(),
scope: None,
},
);
let digest = text_digest("// v1\n");
assert!(p.is_ours(root, &new, &digest, "newer"));
assert!(!p.is_ours(root, &new, &digest, OWNER), "a stale alias");
let mut keys = p.keys_for(root, &new);
keys.sort();
assert_eq!(keys, vec!["src/Foo.rs".to_owned(), "src/foo.rs".to_owned()]);
}
#[test]
fn record_replaces_the_entry_of_another_spelling() {
let dir = tmp();
let root = dir.path();
let mut p = Provenance::default();
written(&mut p, root, "src/Foo.rs", "// v1\n");
let new = root.join("src/foo.rs");
let one_entry = new.exists();
if !one_entry {
std::fs::write(&new, "// v2\n").unwrap(); }
p.record(root, &new, text_digest("// v2\n"), OWNER, None);
assert!(p.contains("src/foo.rs"));
assert_eq!(p.contains("src/Foo.rs"), !one_entry);
}
#[test]
fn forget_after_the_delete_finds_only_the_exact_key() {
let dir = tmp();
let root = dir.path();
let mut p = Provenance::default();
let old = written(&mut p, root, "src/Foo.rs", "// v1\n");
let new = root.join("src/foo.rs");
std::fs::remove_file(&old).unwrap();
p.forget(root, &new);
assert!(p.contains("src/Foo.rs"));
p.forget_key("src/Foo.rs");
assert!(!p.contains("src/Foo.rs"));
}
#[test]
fn forget_key_drops_only_that_key() {
let dir = tmp();
let root = dir.path();
let mut p = Provenance::default();
written(&mut p, root, "src/a.rs", "a");
written(&mut p, root, "src/b.rs", "b");
p.forget_key("src/a.rs");
assert!(!p.contains("src/a.rs"));
assert!(p.contains("src/b.rs"));
}
}