use std::collections::BTreeMap;
use std::path::Path;
pub(super) const GENERATION_RECORD: &str = ".alef-generation.toml";
const GENERATION_RECORD_HEADER: &str = "\
# alef generation-inputs record -- COMMIT THIS FILE, do not add it to .gitignore.
#
# Records, per crate, the generation-inputs fingerprint (Rust sources + alef.toml -- see
# `core::hash::compute_inputs_hash`) as of that crate's most recent successful `alef
# generate`/`alef all` run. `alef verify` recomputes the current fingerprint and compares it
# to the value recorded here to detect a stale tree -- inputs changed since the last
# generation -- without folding that fingerprint into every generated file's own
# `alef:hash:` stamp, which used to restamp every file on any unrelated config or source
# change (see `core::hash`'s module doc for the incident this record replaces).
#
# Do not hand-edit; it is rewritten by `alef generate`/`alef all` on every successful run.
";
#[derive(Default, serde::Deserialize)]
struct GenerationRecordFile {
#[serde(default)]
crates: BTreeMap<String, String>,
}
fn generation_record_path(base_dir: &Path) -> std::path::PathBuf {
base_dir.join(GENERATION_RECORD)
}
fn read_generation_record(base_dir: &Path) -> BTreeMap<String, String> {
let Ok(content) = std::fs::read_to_string(generation_record_path(base_dir)) else {
return BTreeMap::new();
};
match toml::from_str::<GenerationRecordFile>(&content) {
Ok(record) => record.crates,
Err(error) => {
tracing::warn!(
manifest = %GENERATION_RECORD,
%error,
"the alef generation-inputs record could not be parsed; treating every crate as \
having no recorded baseline until it is repaired"
);
BTreeMap::new()
}
}
}
pub fn recorded_inputs_hash(base_dir: &Path, crate_name: &str) -> Option<String> {
read_generation_record(base_dir).get(crate_name).cloned()
}
pub fn stale_crate_names<'a>(base_dir: &Path, current: impl IntoIterator<Item = (&'a str, &'a str)>) -> Vec<String> {
let recorded = read_generation_record(base_dir);
current
.into_iter()
.filter_map(|(name, inputs_hash)| {
let previous = recorded.get(name)?;
(previous != inputs_hash).then(|| name.to_string())
})
.collect()
}
fn render_generation_record(crates: &BTreeMap<String, String>) -> String {
let mut body = String::from(GENERATION_RECORD_HEADER);
body.push_str("\n[crates]\n");
for (name, hash) in crates {
body.push_str(name);
body.push_str(" = ");
body.push_str(&toml_edit::Value::from(hash.as_str()).to_string());
body.push('\n');
}
body
}
pub fn record_inputs_hash(base_dir: &Path, crate_name: &str, inputs_hash: &str) -> anyhow::Result<()> {
static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = WRITE_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let mut crates = read_generation_record(base_dir);
if crates.get(crate_name).map(String::as_str) == Some(inputs_hash) {
return Ok(());
}
std::fs::create_dir_all(base_dir)?;
let is_new_record = !generation_record_path(base_dir).is_file();
crates.insert(crate_name.to_string(), inputs_hash.to_string());
std::fs::write(generation_record_path(base_dir), render_generation_record(&crates))?;
if is_new_record {
tracing::info!(
manifest = %GENERATION_RECORD,
"created the alef generation-inputs record: commit it, or a fresh clone/CI cannot \
detect a stale tree until the next generate"
);
super::rearm_untracked_record_notice(base_dir);
}
super::note_untracked_required_records(base_dir);
Ok(())
}
const GENERATION_IN_PROGRESS_MARKER: &str = "generation-in-progress";
fn generation_in_progress_marker_path(base_dir: &Path, crate_name: &str) -> std::path::PathBuf {
base_dir
.join(super::CACHE_DIR)
.join(crate_name)
.join(GENERATION_IN_PROGRESS_MARKER)
}
const GENERATION_IN_PROGRESS_MARKER_CONTENT: &str = "\
alef generation in progress -- if this file is still here, the run that created it did not
finish; rerun `alef all`/`alef generate` for this crate.
";
pub fn mark_generation_in_progress(base_dir: &Path, crate_name: &str) -> anyhow::Result<()> {
super::validate_cache_crate_name(crate_name)?;
let path = generation_in_progress_marker_path(base_dir, crate_name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, GENERATION_IN_PROGRESS_MARKER_CONTENT)?;
Ok(())
}
pub fn clear_generation_in_progress(base_dir: &Path, crate_name: &str) -> anyhow::Result<()> {
match std::fs::remove_file(generation_in_progress_marker_path(base_dir, crate_name)) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
pub fn generation_in_progress(base_dir: &Path, crate_name: &str) -> bool {
generation_in_progress_marker_path(base_dir, crate_name).is_file()
}
pub fn incomplete_crate_names<'a>(base_dir: &Path, crate_names: impl IntoIterator<Item = &'a str>) -> Vec<String> {
crate_names
.into_iter()
.filter(|name| generation_in_progress(base_dir, name))
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recorded_inputs_hash_is_none_when_the_record_is_entirely_absent() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(recorded_inputs_hash(dir.path(), "my-crate"), None);
}
#[test]
fn recorded_inputs_hash_is_none_for_a_crate_the_record_never_mentions() {
let dir = tempfile::tempdir().expect("tempdir");
record_inputs_hash(dir.path(), "other-crate", "hash-a").expect("record other-crate");
assert_eq!(recorded_inputs_hash(dir.path(), "my-crate"), None);
}
#[test]
fn record_inputs_hash_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
record_inputs_hash(dir.path(), "my-crate", "hash-a").expect("record");
assert_eq!(recorded_inputs_hash(dir.path(), "my-crate"), Some("hash-a".to_string()));
}
#[test]
fn record_inputs_hash_overwrites_a_previous_value_for_the_same_crate() {
let dir = tempfile::tempdir().expect("tempdir");
record_inputs_hash(dir.path(), "my-crate", "hash-a").expect("first record");
record_inputs_hash(dir.path(), "my-crate", "hash-b").expect("second record");
assert_eq!(recorded_inputs_hash(dir.path(), "my-crate"), Some("hash-b".to_string()));
}
#[test]
fn record_inputs_hash_keeps_a_sibling_crates_entry_in_a_workspace() {
let dir = tempfile::tempdir().expect("tempdir");
record_inputs_hash(dir.path(), "crate-a", "hash-a").expect("record crate-a");
record_inputs_hash(dir.path(), "crate-b", "hash-b").expect("record crate-b");
assert_eq!(recorded_inputs_hash(dir.path(), "crate-a"), Some("hash-a".to_string()));
assert_eq!(recorded_inputs_hash(dir.path(), "crate-b"), Some("hash-b".to_string()));
}
#[test]
fn record_inputs_hash_is_a_no_op_when_the_value_is_unchanged() {
let dir = tempfile::tempdir().expect("tempdir");
record_inputs_hash(dir.path(), "my-crate", "hash-a").expect("first record");
let before = std::fs::read_to_string(generation_record_path(dir.path())).expect("read record");
record_inputs_hash(dir.path(), "my-crate", "hash-a").expect("second, unchanged record");
let after = std::fs::read_to_string(generation_record_path(dir.path())).expect("read record");
assert_eq!(
before, after,
"recording the same value again must not rewrite the file"
);
}
#[test]
fn stale_crate_names_reports_a_crate_whose_current_inputs_hash_moved() {
let dir = tempfile::tempdir().expect("tempdir");
record_inputs_hash(dir.path(), "my-crate", "hash-old").expect("record baseline");
let stale = stale_crate_names(dir.path(), [("my-crate", "hash-new")]);
assert_eq!(stale, vec!["my-crate".to_string()]);
}
#[test]
fn stale_crate_names_is_silent_when_the_current_hash_still_matches() {
let dir = tempfile::tempdir().expect("tempdir");
record_inputs_hash(dir.path(), "my-crate", "hash-a").expect("record baseline");
let stale = stale_crate_names(dir.path(), [("my-crate", "hash-a")]);
assert!(stale.is_empty());
}
#[test]
fn stale_crate_names_does_not_report_a_crate_with_no_recorded_baseline() {
let dir = tempfile::tempdir().expect("tempdir");
let stale = stale_crate_names(dir.path(), [("my-crate", "hash-anything")]);
assert!(stale.is_empty());
}
#[test]
fn stale_crate_names_only_reports_the_crates_that_actually_moved_in_a_workspace() {
let dir = tempfile::tempdir().expect("tempdir");
record_inputs_hash(dir.path(), "stable-crate", "hash-stable").expect("record stable-crate");
record_inputs_hash(dir.path(), "moved-crate", "hash-old").expect("record moved-crate");
let stale = stale_crate_names(
dir.path(),
[("stable-crate", "hash-stable"), ("moved-crate", "hash-new")],
);
assert_eq!(stale, vec!["moved-crate".to_string()]);
}
#[test]
fn generation_in_progress_is_false_for_a_crate_never_marked() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(!generation_in_progress(dir.path(), "my-crate"));
}
#[test]
fn mark_then_clear_returns_to_not_in_progress() {
let dir = tempfile::tempdir().expect("tempdir");
mark_generation_in_progress(dir.path(), "my-crate").expect("mark");
assert!(generation_in_progress(dir.path(), "my-crate"));
clear_generation_in_progress(dir.path(), "my-crate").expect("clear");
assert!(
!generation_in_progress(dir.path(), "my-crate"),
"a crate whose run completed must be indistinguishable from one never marked"
);
}
#[test]
fn generation_in_progress_survives_as_true_when_never_cleared() {
let dir = tempfile::tempdir().expect("tempdir");
mark_generation_in_progress(dir.path(), "my-crate").expect("mark");
assert!(generation_in_progress(dir.path(), "my-crate"));
}
#[test]
fn clear_generation_in_progress_is_a_no_op_when_no_marker_was_ever_written() {
let dir = tempfile::tempdir().expect("tempdir");
clear_generation_in_progress(dir.path(), "my-crate").expect("clearing an absent marker must not error");
}
#[test]
fn incomplete_crate_names_reports_only_the_marked_crate_in_a_workspace() {
let dir = tempfile::tempdir().expect("tempdir");
mark_generation_in_progress(dir.path(), "interrupted-crate").expect("mark interrupted-crate");
let incomplete = incomplete_crate_names(dir.path(), ["stable-crate", "interrupted-crate"]);
assert_eq!(incomplete, vec!["interrupted-crate".to_string()]);
}
#[test]
fn incomplete_crate_names_is_empty_once_every_marked_crate_is_cleared() {
let dir = tempfile::tempdir().expect("tempdir");
mark_generation_in_progress(dir.path(), "my-crate").expect("mark");
clear_generation_in_progress(dir.path(), "my-crate").expect("clear");
let incomplete = incomplete_crate_names(dir.path(), ["my-crate"]);
assert!(incomplete.is_empty());
}
}