| //! Shared check/bless harness for stdarch generators. | |
| use std::error::Error as StdError; | |
| use std::fmt; | |
| use std::fs; | |
| use std::io; | |
| use std::io::Read; | |
| use std::path::{Path, PathBuf}; | |
| /// First-line marker identifying an auto-generated file. Generators emit this | |
| /// as the first line of every file they produce; the harness uses it to | |
| /// discover which files in `committed` are owned by the generator. | |
| pub const GENERATED_MARKER: &str = "// This code is automatically generated. DO NOT MODIFY."; | |
| /// Controls what `run_generator` does with the generator's output. | |
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| pub enum Mode { | |
| /// Verify that the `committed` matches the generator's output for owned files. | |
| /// | |
| /// Runs the generator into a temp directory, then compares each produced | |
| /// file against the committed copy. Returns an error on the first mismatch. | |
| Check, | |
| /// Update the `committed` to match the generator's output for owned files. | |
| /// | |
| /// Runs the generator into a temp directory and copies each produced file | |
| /// into `committed`. If the generator no longer produces an owned file, the | |
| /// committed copy is deleted. Files in `committed` that are not owned | |
| /// are left untouched. | |
| Bless, | |
| } | |
| impl Mode { | |
| /// Read the mode from the `STDARCH_GEN_MODE` environment variable. | |
| /// | |
| /// Recognized values: | |
| /// - `"check"` → [`Mode::Check`] | |
| /// - `"bless"` → [`Mode::Bless`] | |
| /// - unset → [`Mode::Bless`] | |
| /// - any other value → panic | |
| pub fn from_env() -> Self { | |
| match std::env::var("STDARCH_GEN_MODE").as_deref() { | |
| Ok("check") => Mode::Check, | |
| Ok("bless") => Mode::Bless, | |
| Ok(other) => panic!("unknown STDARCH_GEN_MODE value: {other:?}"), | |
| Err(_) => Mode::Bless, | |
| } | |
| } | |
| } | |
| #[derive(Debug)] | |
| pub enum Error { | |
| Io(io::Error), | |
| Mismatch { path: PathBuf, kind: MismatchKind }, | |
| Generator(Box<dyn StdError + Send + Sync>), | |
| } | |
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| pub enum MismatchKind { | |
| /// Owned file produced by the generator but absent from the `committed`. | |
| /// Means the `committed` needs to be regenerated. | |
| MissingInCommitted, | |
| /// Owned file present in the `committed` but the generator no longer | |
| /// produces it. The file must be removed from the `committed` . | |
| ExtraInCommitted, | |
| /// Owned file exists on both sides but contents differ. | |
| ContentsDiffer, | |
| } | |
| impl fmt::Display for Error { | |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| match self { | |
| Error::Io(e) => write!(f, "I/O error: {e}"), | |
| Error::Mismatch { path, kind } => match kind { | |
| MismatchKind::MissingInCommitted => { | |
| write!(f, "{}: generated but not committed", path.display()) | |
| } | |
| MismatchKind::ExtraInCommitted => { | |
| write!(f, "{}: committed but no longer generated", path.display()) | |
| } | |
| MismatchKind::ContentsDiffer => write!(f, "{}: contents differ", path.display()), | |
| }, | |
| Error::Generator(e) => write!(f, "generator failed: {e}"), | |
| } | |
| } | |
| } | |
| impl StdError for Error { | |
| fn source(&self) -> Option<&(dyn StdError + 'static)> { | |
| match self { | |
| Error::Io(e) => Some(e), | |
| Error::Generator(e) => Some(&**e), | |
| _ => None, | |
| } | |
| } | |
| } | |
| impl From<io::Error> for Error { | |
| fn from(e: io::Error) -> Self { | |
| Error::Io(e) | |
| } | |
| } | |
| pub type Result<T> = std::result::Result<T, Error>; | |
| /// Run a generator under the chosen `mode`, reconciling its output with `committed`. | |
| /// | |
| /// Arguments: | |
| /// - `committed` — the directory holding the in-tree (committed) source files. | |
| /// Files inside `committed` whose first line begins with [`GENERATED_MARKER`] | |
| /// are treated as owned by the generator. Anything else is treated as | |
| /// hand-written and is left untouched by `Bless` and ignored by `Check`. | |
| /// So generated files coexist with hand-written files in the same directory. | |
| /// - `mode` — what to do with the generator's output. | |
| /// - `generate` — closure that writes the generator's output into the | |
| /// directory it is given. Its error is wrapped in [`Error::Generator`]. | |
| /// | |
| /// Behavior per mode: | |
| /// - [`Mode::Check`]: runs the generator into a temp dir and compares owned | |
| /// files against the committed copies. Mismatch returns [`Error::Mismatch`]. | |
| /// - [`Mode::Bless`]: runs the generator into a temp dir and copies owned | |
| /// files into `committed`, or removes `committed`'s copy if the generator no | |
| /// longer produces them. | |
| pub fn run_generator<F, E>(committed: &Path, mode: Mode, generate: F) -> Result<()> | |
| where | |
| F: FnOnce(&Path) -> std::result::Result<(), E>, | |
| E: Into<Box<dyn StdError + Send + Sync>>, | |
| { | |
| let scratch = tempfile::tempdir()?; | |
| generate(scratch.path()).map_err(|e| Error::Generator(e.into()))?; | |
| let owned = discover_owned(committed)?; | |
| let produced = discover_all(scratch.path())?; | |
| let mut names: Vec<&String> = owned.iter().chain(produced.iter()).collect(); | |
| names.sort(); | |
| names.dedup(); | |
| for name in names { | |
| match mode { | |
| Mode::Check => compare(scratch.path(), committed, name)?, | |
| Mode::Bless => apply_bless(scratch.path(), committed, name)?, | |
| } | |
| } | |
| Ok(()) | |
| } | |
| /// Returns the names of files in `dir` whose first line begins with | |
| /// [`GENERATED_MARKER`]. Files without the marker are skipped. | |
| fn discover_owned(dir: &Path) -> Result<Vec<String>> { | |
| let mut out = Vec::new(); | |
| for entry in fs::read_dir(dir)? { | |
| let entry = entry?; | |
| if !entry.file_type()?.is_file() { | |
| continue; | |
| } | |
| let Ok(mut file) = fs::File::open(entry.path()) else { | |
| continue; | |
| }; | |
| let mut buf = [0u8; GENERATED_MARKER.len()]; | |
| if file.read_exact(&mut buf).is_err() { | |
| continue; | |
| } | |
| if buf == *GENERATED_MARKER.as_bytes() | |
| && let Some(name) = entry.file_name().to_str() | |
| { | |
| out.push(name.to_owned()); | |
| } | |
| } | |
| out.sort(); | |
| Ok(out) | |
| } | |
| /// Returns every file name in `dir` (scratch dir — all files are generator output). | |
| fn discover_all(dir: &Path) -> Result<Vec<String>> { | |
| let mut out = Vec::new(); | |
| for entry in fs::read_dir(dir)? { | |
| let entry = entry?; | |
| if entry.file_type()?.is_file() | |
| && let Some(name) = entry.file_name().to_str() | |
| { | |
| out.push(name.to_string()); | |
| } | |
| } | |
| Ok(out) | |
| } | |
| fn compare(generated_dir: &Path, committed_dir: &Path, filename: &str) -> Result<()> { | |
| let rel_path = PathBuf::from(filename); | |
| let gen_path = generated_dir.join(&rel_path); | |
| let comm_path = committed_dir.join(&rel_path); | |
| match (gen_path.exists(), comm_path.exists()) { | |
| (true, false) => Err(Error::Mismatch { | |
| path: rel_path, | |
| kind: MismatchKind::MissingInCommitted, | |
| }), | |
| (false, true) => Err(Error::Mismatch { | |
| path: rel_path, | |
| kind: MismatchKind::ExtraInCommitted, | |
| }), | |
| (false, false) => Ok(()), | |
| (true, true) => { | |
| if fs::read(&gen_path)? != fs::read(&comm_path)? { | |
| Err(Error::Mismatch { | |
| path: rel_path, | |
| kind: MismatchKind::ContentsDiffer, | |
| }) | |
| } else { | |
| Ok(()) | |
| } | |
| } | |
| } | |
| } | |
| fn apply_bless(generated_dir: &Path, committed_dir: &Path, filename: &str) -> Result<()> { | |
| fs::create_dir_all(committed_dir)?; | |
| let rel_path = PathBuf::from(filename); | |
| let from = generated_dir.join(&rel_path); | |
| let to = committed_dir.join(&rel_path); | |
| if from.exists() { | |
| if let Some(parent) = to.parent() { | |
| fs::create_dir_all(parent)?; | |
| } | |
| fs::copy(&from, &to)?; | |
| } else if to.exists() { | |
| fs::remove_file(&to)?; | |
| } | |
| Ok(()) | |
| } | |
| // Skipped on iOS because these tests fail on the `x86_64-apple-ios-macabi` CI runner | |
| // with `Os { code: 17, kind: AlreadyExists, message: "File exists" }`. | |
| #[cfg(all(test, not(target_os = "ios")))] | |
| mod tests { | |
| use super::*; | |
| fn write_marker(p: &Path, body: &[u8]) { | |
| if let Some(d) = p.parent() { | |
| fs::create_dir_all(d).unwrap(); | |
| } | |
| let mut bytes = Vec::new(); | |
| bytes.extend_from_slice(GENERATED_MARKER.as_bytes()); | |
| bytes.push(b'\n'); | |
| bytes.extend_from_slice(body); | |
| fs::write(p, bytes).unwrap(); | |
| } | |
| #[test] | |
| fn check_fails_on_byte_diff() { | |
| let tmp = tempfile::tempdir().unwrap(); | |
| let committed = tmp.path().join("c"); | |
| write_marker(&committed.join("a.txt"), b"hi"); | |
| let e = run_generator( | |
| &committed, | |
| Mode::Check, | |
| |out| -> std::result::Result<(), io::Error> { | |
| write_marker(&out.join("a.txt"), b"HI"); | |
| Ok(()) | |
| }, | |
| ) | |
| .unwrap_err(); | |
| assert!(matches!( | |
| e, | |
| Error::Mismatch { | |
| kind: MismatchKind::ContentsDiffer, | |
| .. | |
| } | |
| )); | |
| } | |
| #[test] | |
| fn check_fails_when_owned_file_missing_from_generated() { | |
| let tmp = tempfile::tempdir().unwrap(); | |
| let committed = tmp.path().join("c"); | |
| write_marker(&committed.join("a.txt"), b"hi"); | |
| let e = run_generator( | |
| &committed, | |
| Mode::Check, | |
| |_| -> std::result::Result<(), io::Error> { Ok(()) }, | |
| ) | |
| .unwrap_err(); | |
| assert!(matches!( | |
| e, | |
| Error::Mismatch { | |
| kind: MismatchKind::ExtraInCommitted, | |
| .. | |
| } | |
| )); | |
| } | |
| #[test] | |
| fn check_fails_when_owned_file_missing_from_committed() { | |
| let tmp = tempfile::tempdir().unwrap(); | |
| let committed = tmp.path().join("c"); | |
| fs::create_dir_all(&committed).unwrap(); | |
| let e = run_generator( | |
| &committed, | |
| Mode::Check, | |
| |out| -> std::result::Result<(), io::Error> { | |
| write_marker(&out.join("a.txt"), b"hi"); | |
| Ok(()) | |
| }, | |
| ) | |
| .unwrap_err(); | |
| assert!(matches!( | |
| e, | |
| Error::Mismatch { | |
| kind: MismatchKind::MissingInCommitted, | |
| .. | |
| } | |
| )); | |
| } | |
| #[test] | |
| fn bless_deletes_files_no_longer_produced() { | |
| let tmp = tempfile::tempdir().unwrap(); | |
| let committed = tmp.path().join("c"); | |
| write_marker(&committed.join("keep.txt"), b""); | |
| write_marker(&committed.join("stale.txt"), b""); | |
| run_generator( | |
| &committed, | |
| Mode::Bless, | |
| |out| -> std::result::Result<(), io::Error> { | |
| write_marker(&out.join("keep.txt"), b""); | |
| Ok(()) | |
| }, | |
| ) | |
| .unwrap(); | |
| assert!(committed.join("keep.txt").exists()); | |
| assert!(!committed.join("stale.txt").exists()); | |
| } | |
| #[test] | |
| fn bless_preserves_unowned_files() { | |
| let tmp = tempfile::tempdir().unwrap(); | |
| let committed = tmp.path().join("c"); | |
| fs::create_dir_all(&committed).unwrap(); | |
| fs::write(committed.join("mod.rs"), b"hand-written").unwrap(); | |
| fs::write(committed.join("old.txt"), b"old").unwrap(); | |
| run_generator( | |
| &committed, | |
| Mode::Bless, | |
| |out| -> std::result::Result<(), io::Error> { | |
| write_marker(&out.join("new.txt"), b"new"); | |
| Ok(()) | |
| }, | |
| ) | |
| .unwrap(); | |
| assert_eq!(fs::read(committed.join("mod.rs")).unwrap(), b"hand-written"); | |
| assert_eq!(fs::read(committed.join("old.txt")).unwrap(), b"old"); | |
| assert!(committed.join("new.txt").exists()); | |
| } | |
| } |