Incrementality Example

In this example, we will run our build system and show off simple incrementality with a task that reads a string from a file.

ReadStringFromFile task

Create the pie/examples directory, and create the pie/examples/incrementality.rs file with the following contents:

#![allow(unused_imports, unused_variables)]

use std::io::{self, Read};
use std::path::{Path, PathBuf};

use dev_shared::{create_temp_dir, write_until_modified};
use pie::{Context, Task};
use pie::context::top_down::TopDownContext;
use pie::stamp::FileStamper;

/// Task that reads a string from a file.
#[derive(Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
struct ReadStringFromFile(PathBuf, FileStamper);

impl ReadStringFromFile {
  fn new(path: impl AsRef<Path>, stamper: FileStamper) -> Self {
    Self(path.as_ref().to_path_buf(), stamper)
  }
}

impl Task for ReadStringFromFile {
  type Output = Result<String, io::ErrorKind>;
  fn execute<C: Context<Self>>(&self, context: &mut C) -> Self::Output {
    println!("Reading from {} with {:?} stamper", self.0.file_name().unwrap().to_string_lossy(), self.1);
    let file = context.require_file_with_stamper(&self.0, self.1).map_err(|e| e.kind())?;
    if let Some(mut file) = file {
      let mut string = String::new();
      file.read_to_string(&mut string).map_err(|e| e.kind())?;
      Ok(string)
    } else {
      Err(io::ErrorKind::NotFound)
    }
  }
}

The ReadStringFromFile task is similar to the one we defined earlier in a test, but this one accepts a FileStamper as input, and propagates errors by returning a Result. We cannot use std::io::Error as the error in the Result, because it does not implement Clone nor Eq, which need to be implemented for task outputs. Therefore, we use std::io::ErrorKind which does implement these traits.

Exploring incrementality

We’ve implemented the task, now add a main function to pie/examples/incrementality.rs:

fn main() -> Result<(), io::Error> {
  let temp_dir = create_temp_dir()?;
  let input_file = temp_dir.path().join("input.txt");
  write_until_modified(&input_file, "Hi")?;

  let mut context = TopDownContext::new();
  let read_task = ReadStringFromFile::new(&input_file, FileStamper::Modified);

  println!("A) New task: expect `read_task` to execute");
  // `read_task` is new, meaning that we have no cached output for it, thus it must be executed.
  let output = context.require_task(&read_task)?;
  assert_eq!(&output, "Hi");

  Ok(())
}

We create a temporary file, create a task, create a context, and require our first task. Run this example with cargo run --example incremental. You should see the println! in ReadStringFromFile appear in your console as the incremental context correctly determines that this task is new (i.e., has no output) and must be executed. It should look something like:

   Compiling pie v0.1.0 (/pie)
    Finished dev [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/examples/incremental`
A) New task: expect `read_task` to execute
Reading from input.txt with Modified stamper

Reuse

If we require the task again, what should happen?

Insert the following code into the main method:

  println!("\nB) Reuse: expect no execution");
  // `read_task` is not new and its file dependency is still consistent. It is consistent because the modified time of
  // `input_file` has not changed, thus the modified stamp is equal.
  let output = context.require_task(&read_task)?;
  assert_eq!(&output, "Hi");

Running with cargo run --example incremental should produce output like:

   Compiling pie v0.1.0 (/pie)
    Finished dev [unoptimized + debuginfo] target(s) in 0.34s
     Running `target/debug/examples/incremental`
A) New task: expect `read_task` to execute
Reading from input.txt with Modified stamper

B) Reuse: expect no execution

We don’t see the println! from ReadStringFromFile, so it was not executed, so our incremental build system has correctly reused its output!

Normally we would write a test to confirm that the task was executed the first time, and that it was not executed the second time. However, this is not trivial. How do we know if the task was executed? We could track it with a global mutable boolean that ReadStringFromFile keeps track of, but this quickly becomes a mess. Therefore, we will look into creating a proper testing infrastructure in the next chapter.

For now, we will continue this example with a couple more interesting cases. The comments in the code explain in more detail why the build system behaves in this way.

Inconsistent file dependency

Insert into the main method:

  write_until_modified(&input_file, "Hello")?;
  println!("\nC) Inconsistent file dependency: expect `read_task` to execute");
  // The file dependency of `read_task` is inconsistent due to the changed modified time of `input_file`.
  let output = context.require_task(&read_task)?;
  assert_eq!(&output, "Hello");

If we change the file (using write_until_modified to ensure that the modified time changes to trigger the Modified file stamper) and require the task, it should execute, because the file dependency of the task is no longer consistent.

Different tasks

Insert into the main method:

  let input_file_b = temp_dir.path().join("input_b.txt");
  write_until_modified(&input_file_b, "Test")?;
  let read_task_b_modified = ReadStringFromFile::new(&input_file_b, FileStamper::Modified);
  let read_task_b_exists = ReadStringFromFile::new(&input_file_b, FileStamper::Exists);
  println!("\nD) Different tasks: expect `read_task_b_modified` and `read_task_b_exists` to execute");
  // Task `read_task`, `read_task_b_modified` and `read_task_b_exists` are different, due to their `Eq` implementation
  // determining that their paths and stampers are different. Therefore, `read_task_b_modified` and `read_task_b_exists`
  // are new tasks, and must be executed.
  let output = context.require_task(&read_task_b_modified)?;
  assert_eq!(&output, "Test");
  let output = context.require_task(&read_task_b_exists)?;
  assert_eq!(&output, "Test");

The identity of tasks is determined by their Eq and Hash implementations, which are typically derived to compare and hash all their fields. Therefore, if we create read tasks for different input file input_file_b and different stamper FileStamper::Exists, these read tasks are not equal to the existing read task, and thus are new tasks with a different identity. We require read_task_b_modified and read_task_b_exists, they are new, and are therefore executed.

Same file different stampers

Insert into the main method:

  write_until_modified(&input_file_b, "Test Test")?;
  println!("\nE) Different stampers: expect only `read_task_b_modified` to execute");
  // Both `read_task_b_modified` and `read_task_b_exists` read from the same file, but they use different stampers.
  // Therefore, `read_task_b_modified` must be executed because the modified time has changed, but `read_task_b_exists`
  // will not be executed because its file dependency stamper only checks for existence of the file, and the existence
  // of the file has not changed.
  //
  // Note that using an `Exists` stamper for this task does not make a lot of sense, since it will only read the file
  // on first execute and when it is recreated. But this is just to demonstrate different stampers.
  let output = context.require_task(&read_task_b_modified)?;
  assert_eq!(&output, "Test Test");
  let output = context.require_task(&read_task_b_exists)?;
  assert_eq!(&output, "Test");

Here we write to input_file_b and then require read_task_b_modified and read_task_b_exists. We expect read_task_b_modified to be executed, but read_task_b_exists to be skipped, because its file dependency only checks for the existence of the input file, which has not changed. This shows that tasks can depend on the same file with different stampers, which influences whether the tasks are affected by a file change individually.

Of course, using an Exists stamper for ReadStringFromFile does not make a lot of sense, but this is for demonstration purposes only.

Running cargo run --example incremental now should produce output like:

   Compiling pie v0.1.0 (/pie)
    Finished dev [unoptimized + debuginfo] target(s) in 0.35s
     Running `target/debug/examples/incremental`
A) New task: expect `read_task` to execute
Reading from input.txt with Modified stamper

B) Reuse: expect no execution

C) Inconsistent file dependency: expect `read_task` to execute
Reading from input.txt with Modified stamper

D) Different tasks: expect `read_task_b_modified` and `read_task_b_exists` to execute
Reading from input_b.txt with Modified stamper
Reading from input_b.txt with Exists stamper

E) Different stampers: expect only `read_task_b_modified` to execute
Reading from input_b.txt with Modified stamper

Feel free to experiment more with this example (or new example files) before continuing. In the next chapter, we will define minimality and soundness, set up an infrastructure for testing those properties, and fix issues uncovered by testing.

Download source code