Stamps

To check whether we need to execute a task, we need to check the dependencies of that task to see if any of them are inconsistent. To make this consistency checking configurable, we will use stamps. A dependency is inconsistent if after stamping, the new stamp is different from the old stamp. Therefore, we will implement a FileStamper that stamps files and produces a FileStamp, and an OutputStamper that stamps task outputs and produces an OutputStamp.

Add the stamp module to pie/src/lib.rs:

This module is public as users of the library will construct stampers.

File stamps

Create the pie/src/stamp.rs file and add:

use std::fmt::Debug;
use std::io;
use std::path::Path;
use std::time::SystemTime;

use crate::fs::metadata;

#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
pub enum FileStamper {
  Exists,
  Modified,
}

#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
pub enum FileStamp {
  Exists(bool),
  Modified(Option<SystemTime>),
}

impl FileStamper {
  pub fn stamp(&self, path: impl AsRef<Path>) -> Result<FileStamp, io::Error> {
    match self {
      FileStamper::Exists => {
        Ok(FileStamp::Exists(path.as_ref().try_exists()?))
      }
      FileStamper::Modified => {
        let Some(metadata) = metadata(path)? else {
          return Ok(FileStamp::Modified(None));
        };
        Ok(FileStamp::Modified(Some(metadata.modified()?)))
      }
    }
  }
}

We’re implementing FileStamper as an enum for simplicity.

A FileStamper has a single method stamp which takes something that can be dereferenced to a path, and produces a FileStamp or an error if creating the stamp failed. For now, we implement only two kinds of file stampers: Exists and Modified. The Exists stamper just returns a boolean indicating whether a file exists. It can be used to create a file dependency where a task behaves differently based on whether a file exists or not. The Modified stamper returns the last modification date if the file exists, or None if the file does not exist.

We derive Eq for stamps so that we can compare them. Equal (same) stamps indicate a consistent dependency, unequal (different) indicates inconsistent. We also derive Eq for stampers, because the stamper of a dependency could change, making the dependency inconsistent.

Task output stamps

We implement task output stampers in a similar way. Add to pie/src/stamp.rs:

#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
pub enum OutputStamper {
  Inconsequential,
  Equals,
}

#[derive(Clone, Eq, PartialEq, Debug)]
pub enum OutputStamp<O> {
  Inconsequential,
  Equals(O),
}

impl OutputStamper {
  pub fn stamp<O>(&self, output: O) -> OutputStamp<O> {
    match self {
      OutputStamper::Inconsequential => OutputStamp::Inconsequential,
      OutputStamper::Equals => OutputStamp::Equals(output),
    }
  }
}

The Inconsequential stamper simply ignores the output and always returns the same stamp (thus is always equal). It can be used to create a task dependency where we are interested in some side effect of a task, but don’t care about its output. The Equals stamper simply wraps the output of a task, so the stamp is equal when the output is equal.

Output stamps are generic over the task output type O.

Trait Bounds and Derive Macros

Because O is used in the enum, the derive attributes on OutputStamp create bounds over O. Thus, OutputStamp is only Clone when O is Clone, OutputStamp is only Eq when O is Eq, and so forth. Because we declared Task::Output with bound Clone + Eq + Debug, we can be sure that OutputStamp is always Clone, Eq, and Debug.

User-Defined Stamps?

FileStamper and OutputStamper could also be a trait which would allow users of the library to implement their own stampers. For simplicity, we do not explore that option in this tutorial. In the actual PIE library, stampers (called checkers) can be implemented by users of the library!

Tests

Finally, we write some tests. Add to pie/src/stamp.rs:

#[cfg(test)]
mod test {
  use std::fs::{remove_file, write};
  use std::io;

  use dev_shared::create_temp_file;

  use super::*;

  #[test]
  fn test_exists_file_stamper() -> Result<(), io::Error> {
    let stamper = FileStamper::Exists;
    let temp_file = create_temp_file()?;
    let stamp = stamper.stamp(&temp_file)?;
    assert_eq!(stamp, stamper.stamp(&temp_file)?);

    remove_file(&temp_file)?;
    assert_ne!(stamp, stamper.stamp(&temp_file)?);

    Ok(())
  }

  #[test]
  fn test_modified_file_stamper() -> Result<(), io::Error> {
    let stamper = FileStamper::Modified;
    let temp_file = create_temp_file()?;
    let stamp = stamper.stamp(&temp_file)?;
    assert_eq!(stamp, stamper.stamp(&temp_file)?);

    write(&temp_file, format!("{:?}", stamp))?;
    let new_stamp = stamper.stamp(&temp_file)?;
    assert_ne!(stamp, new_stamp);
    let stamp = new_stamp;

    remove_file(&temp_file)?;
    assert_ne!(stamp, stamper.stamp(&temp_file)?);

    Ok(())
  }

  #[test]
  fn test_inconsequential_output_stamper() {
    let stamper = OutputStamper::Inconsequential;
    let stamp = stamper.stamp(&1);
    assert_eq!(stamp, stamper.stamp(&1));
    assert_eq!(stamp, stamper.stamp(&2));
  }

  #[test]
  fn test_equals_output_stamper() {
    let stamper = OutputStamper::Equals;
    let stamp = stamper.stamp(&1);
    assert_eq!(stamp, stamper.stamp(&1));
    assert_ne!(stamp, stamper.stamp(&2));
  }
}

We test file stamps by creating a stamp, changing the file, creating a new stamp, and then compare the stamps. We test task output stamps by just passing a different output value to the stamp function, and then compare the stamps.

Run cargo test to test the stamp implementation. However, a test could fail on some operating systems. Do continue to the next subsection if this happens.

Likely Test Failure

Test test_modified_file_stamper will likely fail. Do continue to the next subsection, because we’re going to fix it!

Testing with file modified time, correctly

Unfortunately, these tests may fail on some operating systems (Linux and Windows in my testing), due to an imprecise file last modified timer. What can happen is that we write to a file, making the OS update its modified time to 1000 (as an example, not a real timestamp), then very quickly write to the file again, making the OS update its modified time to 1000 again. Then, our test will fail because the stamp didn’t change even though we expect it to change.

This can happen with an imprecise timer that only increases once every millisecond (again, an example, not a real number) when we perform writes in between that millisecond. Even worse, our test can be flaky, sometimes succeeding if we write in between those milliseconds, sometimes failing if we write within a millisecond.

To solve this, add a function to the filesystem testing utility crate. Change dev_shared/src/lib.rs:

The write_until_modified function writes to the file, but ensures its modified time will change. Now change the tests in pie/src/stamp.rs to use this function:

Now we use write_until_modified to write to the file, ensuring its modified time will change, ensuring the stamp will change when it should. Run cargo test to confirm the stamp implementation, which should succeed now.

Fixed Tests

Test test_modified_file_stamper should now succeed.

Stamps in Context

We now have a module dedicated to stamps. However, stampers are constructed by users of the library that author tasks, and they need to pass in these stampers when creating dependencies. Therefore, we need to update the Context trait to allow passing in these stampers.

Change Context in pie/src/lib.rs:

We add the require_file_with_stamper method which allow passing in a stamper. We add a default implementation for require_file that passes in a default stamper. The default is provided by default_require_file_stamper which can be overridden by context implementations.

Now apply the same to tasks, changing Context again in pie/src/lib.rs:

Update NonIncrementalContext in src/context/non_incremental.rs to implement the new methods:

We just ignore the stampers in NonIncrementalContext, as they are only needed for incrementality.

Run cargo test to confirm everything still works.

Download source code