SW Design Bites: Plugin Structure

"I am always doing that which I can not do, in order that I may learn how to do it."
- Pablo Picasso

1.0 Introduction

This DesignBite sequence was inspired by BuildOn project TextFinder. As that project is designed and implemented, a number of design decisions are made, consciously or unconsciously. Each of these pages addresses one answer to questions about fundamental decisions of structure. To make discussion pragmatic and concrete, we implement a program that evaluates the number of lines in text files. Processing is quite simple so it allows us to see how each structure alternative is implemented. We consider both package structure and logical structure, e.g., functions and structs used to order design and implementation. In this PlugIn Structure page, code is implemented in a set of packages Executive, Input, Compute, and Output and their structs. That provides all of the organization for processing.

2. Application Structure - PlugIn

This structure is modular with a data flow structure. It differs from the previous TypeErase structure in that:
  • Its ComputeImpl type is not parameterized.
  • That allows the Output type to be determined at run-time, affording application flexability in the way it displays output, based on user input.
  • It also means that Executive instantiates the Output type and delivers that to ComputeImpl.
  • The Rust data model requires that to be a trait object created on the heap (wrapped in a Box).
Figure 5. Plugin Structure

PlugIn Structure

The PlugIn structure is very similar to Type Erasure, but with the flexibility to define output processing at run-time. The Executive creates an OutputImpl type at run-time and configures ComputeImpl with that type. That can be any type that implements the Output trait. We get that freedom by not parameterizing ComputeImpl on OutputImpl, as we did in Type Erasure. Now, ComputeImpl cannot create an instance of the OutputImpl type because it only has the Output trait definition, but no paramaterized type to get an instance using a factory function. This gives the application freedom to choose the type of output processing based on a command line input. PlugIn has most of the characteristics of the Type Erasure structure, with the addition of freedom of choice for output processing. Implementation gets a bit more complicated because Executive has to create an instance of the desired type and give it to ComputeImpl.

Pros:

  1. Same as data flow
  2. The Input, Compute, and Output parts are now decoupled, each depending only on the interfaces it defines and the interfaces of upstream components.
  3. Freedom to define Output processing at run-time through a polymorphic run-time dispatch.

Cons:

  1. The building process becomes more complex. Each component needs to use a factory function to create its downstream component (to maintain type ignorance).
  2. Again, more complex because Executive needs access to Compute to configure it with Output.
Project Notes: Plugin structures use trait objects:
  • A trait object is an instance of a concrete type stored in the heap using a statement like this: let comp : Box<dyn CompTrait> = Box::new(ConcreteType::new()); where ConcreteType is required to imlement CompTrait.
  • Two important things happen here:
    1. The type used on the right hand side can be chosen at run-time. You will see an example of that in the Executive, below. All of the other structure types discussed previously use types that must be defined at compile-time.
    2. The instance on the left, comp, has knowledge of only the CompTrait, not the ConcreteType.
Plugins support choosing a component at run-time. In this example, we will select, using command line arguments, the output component type. One writes line counts to the user's console, the other writes to a file. Citations: ChatGPT 4o was used to:
  • help establish dataflow wiring between components and corresponding complex trait object syntax.
  • write all of the unit tests for each of the libraries in this project.
Executive Output
/////////////////////////////////////////////////////////////
// PluginWithTraitObjects::Executive::main.rs              //
//   - Executive creates and uses all lower level parts    //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////
/*
  Note:
    Executive creates Input Comp and Output instances. 

    cargo run -- console
      or
    cargo run -- file <file name>, e.g., file count.txt

    The file name must not be an existing source code file
*/
use compute::ComputeImpl;
use compute::Output as ComputeOutput;
use input::InputImpl;
use std::env;

fn main() {
  // Choose plugin via CLI: e.g. `--output=file out.txt` or `--output=console`
  //---------------------------------------------------------------------------
  let mut args = env::args().skip(1);
  let plugin = args.next().unwrap_or_else(|| "console".into());

  // plugin = "console".to_string();

  // Instantiate the right boxed Output
  //---------------------------------------------------------------------------
  let out: Box<dyn ComputeOutput> = match plugin.as_str() {
    "console" => Box::new(console_output::ConsoleOutput::new()),
    "file" => {
      let path = args
        .next()
        .expect("usage: executive --output=file <out-path> <files>...");
      Box::new(file_output::FileOutput::new(&path).expect("failed to create FileOutput"))
    }
    other => {
      eprintln!("unknown output plugin "{}"", other);
      std::process::exit(1);
    }
  };

  // Wire Compute and Input
  //---------------------------------------------------------------------------
  let compute = Box::new(ComputeImpl::new(out));
  let mut input = InputImpl::new(compute);

  let mut total = 0;
  for path in &[
    "./src/main.rs",
    "../Input/src/lib.rs",
    "../Compute/src/lib.rs",
    "../ConsoleOutput/src/lib.rs",
    "../FileOutput/src/lib.rs",
    "../Fileutils/src/lib.rs",
  ] {
    total += input.do_input(path);
  }
  println!("\ntotal lines: {:?}\n", total);
}
> cargo run -q console

./src/main.rs: 67 lines

../Input/src/lib.rs: 51 lines

../Compute/src/lib.rs: 62 lines

../ConsoleOutput/src/lib.rs: 36 lines

../FileOutput/src/lib.rs: 35 lines

../Fileutils/src/lib.rs: 86 lines

total lines: 337

------------------------------------------------------

C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Executive
> cargo run -q file count.txt

total lines: 337

------------------------------------------------------
count.txt
------------------------------------------------------
./src/main.rs -> 67 lines
../Input/src/lib.rs -> 51 lines
../Compute/src/lib.rs -> 62 lines
../ConsoleOutput/src/lib.rs -> 36 lines
../FileOutput/src/lib.rs -> 35 lines
../Fileutils/src/lib.rs -> 86 lines
Cargo.toml
[package]
name = "executive"
version = "0.1.0"
edition = "2021"

[dependencies]
input           = { path = "../Input" }
compute         = { path = "../Compute" }
console_output  = { path = "../ConsoleOutput" }
file_output     = { path = "../FileOutput" }
Comments:
  • Executive code resides in the left panel. It creates and uses instances of the types OutputImpl, ComputeImpl, and InputImpl, all stored in heap memory.
  • The instances are wired into a dataflow pipeline: input to compute to output.
  • InputImpl provides function fn do_input(&mut self, name: &str). That attempts to open a named file and return its handle.
  • ComputeImple reads the file into a string and counts its lines. It then calls OutputImpl to display the name and lines.
  • Only Executive knows the concrete type names. The other components simply use trait objects through their trait specified interface.
Input Library Build and Test
/////////////////////////////////////////////////////////////
// PlugInWithTraitObjects::Input::lib.rs                   //
//   - Attempts to return line count from file             //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////
/*
  Note:
    - Input owns and instantiates Compute.
    - It attempts to open file and pass to Compute for
      processing.
    - Returns line count if successful
*/
use file_utils::open_file_for_read;
use std::fs::File;

/// The trait for anything that can count lines.
pub trait Compute {
  fn do_compute(&mut self, name: &str, file: File);
  fn lines(&self) -> usize;
}

/// Your InputImpl just needs a boxed Compute.
pub struct InputImpl {
  compute: Box<dyn Compute>,
}

impl InputImpl {
  /// `compute` is any implementor of the Compute trait.
  pub fn new(compute: Box<dyn Compute>) -> Self {
    InputImpl { compute }
  }

  /// Opens `name`, hands it to compute, returns the line count.
  pub fn do_input(&mut self, name: &str) -> usize {
    if let Ok(f) = open_file_for_read(name) {
      self.compute.do_compute(name, f);
      self.compute.lines()
    } else {
      eprintln!("could not open {:?}", name);
      0
    }
  }
}
#[cfg(test)]
mod tests {
  use super::*;
  use std::cell::RefCell;
  use std::io::Write;
  use std::rc::Rc;
  use tempfile::NamedTempFile;

  /// Stub Compute that records the last filename it saw
  /// and returns a fixed line count.
  struct StubCompute {
    last_name: Rc<RefCell<Option<String>>>,
    return_lines: usize,
  }

  impl StubCompute {
    fn new(record: Rc<RefCell<Option<String>>>, return_lines: usize) -> Self {
      StubCompute {
        last_name: record,
        return_lines,
      }
    }
  }

  impl Compute for StubCompute {
    fn do_compute(&mut self, name: &str, _file: File) {
      *self.last_name.borrow_mut() = Some(name.to_string());
    }
    fn lines(&self) -> usize {
      self.return_lines
    }
  }

  #[test]
  fn missing_file_returns_zero_and_skips_compute() {
    let record = Rc::new(RefCell::new(None));
    let stub = StubCompute::new(Rc::clone(&record), 99);
    let mut inp = InputImpl::new(Box::new(stub));

    let count = inp.do_input("definitely_not_a_file.txt");
    assert_eq!(count, 0, "should return 0 for missing file");
    assert!(record.borrow().is_none(), "do_compute should not be called");
  }

  #[test]
  fn existing_file_calls_compute_and_returns_stub_value() {
    // prepare a real file so open_file_for_read succeeds
    let mut tmp = NamedTempFile::new().expect("create temp file");
    write!(tmp, "ignored").expect("write to temp file");
    tmp.flush().expect("flush temp file");
    let path = tmp.path().to_str().unwrap().to_string();

    let record = Rc::new(RefCell::new(None));
    let stub = StubCompute::new(Rc::clone(&record), 7);
    let mut inp = InputImpl::new(Box::new(stub));

    let count = inp.do_input(&path);
    assert_eq!(count, 7, "should return the stub's line count");
    assert_eq!(
      record.borrow().as_ref(),
      Some(&path),
      "do_compute should be called with the file name"
    );
  }
}
> cargo build --lib
   Compiling file_utils v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Fileutils)
   Compiling input v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Input)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.35s
C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Input
> cargo test --lib
   Compiling windows_x86_64_msvc v0.52.6
   Compiling getrandom v0.3.3
   Compiling cfg-if v1.0.1
   Compiling once_cell v1.21.3
   Compiling fastrand v2.3.0
   Compiling windows-targets v0.52.6
   Compiling windows-sys v0.59.0
   Compiling tempfile v3.20.0
   Compiling input v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Input)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 2.36s
     Running unittests src\lib.rs (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\target\debug\deps\input-3f3680d8940bbc10.exe)

running 2 tests
test tests::missing_file_returns_zero_and_skips_compute ... ok
test tests::existing_file_calls_compute_and_returns_stub_value ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo.toml
[package]
name = "input"
version = "0.1.0"
authors = ["James W. Fawcett "]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
file_utils = { path = "../Fileutils" }

[dev-dependencies]
tempfile = "3"        # tempfile crate from crates.io
Comments:
  • Input library defines the trait Compute and type InputImpl containing one String data member, and a smart Box pointer to trait object Compute in the heap.
  • It defines a fn new(compute: Box<dyn Computer>) that returns an InputImpl holding a smart Box pointer to the trait object Compute.
  • It defines another method fn do_input(&mut self, name:&str) -> Option<File>
  • That attempts to open a named file for reading, and, if successful, calls compute.do_compute(name, file).
  • The Input library depends on the library file_utils.
  • It also depends on a crate, tempfile from crates.io, supporting creation of temporary files that self destruct when no longer referenced.
Comp Library Build and Test
/////////////////////////////////////////////////////////////
// PlugInWithTraitObjects::Compute::lib.rs                 //
//   - Attempts to read opened file to string, count lines //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////
/*
  Note:
    - creates instance of Output
    - attempts to read file to string and count its lines
    - sends results to Output
*/
use file_utils::read_file_to_string;
use input::Compute;
use std::fs::File;
// use output::console_output as OutputTrait;  // downstream abstraction from output crate

/// The trait you expect from any Output plugin.
pub trait Output {
  fn do_output(&self, name: &str, lines: usize);
}

/// Your ComputeImpl just holds a boxed Output.
pub struct ComputeImpl {
  lines: usize,
  out: Box<dyn Output>,
}

impl ComputeImpl {
  /// `out` is any implementor of OutputTrait.
  pub fn new(out: Box<dyn Output>) -> Self {
    ComputeImpl { lines: 0, out }
  }
}

impl Compute for ComputeImpl {
  fn do_compute(&mut self, name: &str, mut file: File) {
    match read_file_to_string(&mut file) {
      Ok(contents) => {
        // count lines
        let mut count = if contents.is_empty() { 0 } else { 1 };
        count += contents.chars().filter(|&c| c == '\n').count();
        self.lines = count;
        self.out.do_output(name, count);
      }
      Err(e) => eprintln!("\ncompute failed to read {:?}: {}\n", name, e),
    }
  }
  fn lines(&self) -> usize {
    self.lines
  }
}

// Re-export Compute trait under its old name if you wish:
pub use input::Compute as ComputeTrait;

#[cfg(test)]
mod tests {
  use super::*; // brings in ComputeImpl, Output, Compute
  use std::cell::RefCell;
  use std::fs::File;
  use std::io::Write;
  use std::rc::Rc;
  use tempfile::NamedTempFile;

  /// A stub Output that records the last (name, lines) it was asked to output.
  struct StubOutput {
    record: Rc<RefCell<Option<(String, usize)>>>,
  }

  impl StubOutput {
    fn new(record: Rc<RefCell<Option<(String, usize)>>>) -> Self {
      StubOutput { record }
    }
  }

  impl Output for StubOutput {
    fn do_output(&self, name: &str, lines: usize) {
      *self.record.borrow_mut() = Some((name.to_string(), lines));
    }
  }

  /// Write `contents` to a temp file and reopen it for reading from the start.
  fn make_file(contents: &str) -> File {
    let mut tmp = NamedTempFile::new().expect("create temp file");
    write!(tmp, "{}", contents).expect("write temp file");
    tmp.flush().expect("flush temp file");
    tmp.reopen().expect("reopen temp file")
  }

  #[test]
  fn empty_file_emits_zero_and_reports_zero() {
    let record = Rc::new(RefCell::new(None));
    let stub = StubOutput::new(Rc::clone(&record));
    let mut comp = ComputeImpl::new(Box::new(stub));

    let file = make_file("");
    comp.do_compute("empty.txt", file);

    assert_eq!(comp.lines(), 0);
    assert_eq!(*record.borrow(), Some(("empty.txt".to_string(), 0)));
  }

  #[test]
  fn single_line_emits_one_and_reports_one() {
    let record = Rc::new(RefCell::new(None));
    let stub = StubOutput::new(Rc::clone(&record));
    let mut comp = ComputeImpl::new(Box::new(stub));

    let file = make_file("just one line");
    comp.do_compute("single.txt", file);

    assert_eq!(comp.lines(), 1);
    assert_eq!(*record.borrow(), Some(("single.txt".to_string(), 1)));
  }

  #[test]
  fn multiple_lines_counted_correctly() {
    let record = Rc::new(RefCell::new(None));
    let stub = StubOutput::new(Rc::clone(&record));
    let mut comp = ComputeImpl::new(Box::new(stub));

    let file = make_file("a\nb\nc");
    comp.do_compute("multi.txt", file);

    assert_eq!(comp.lines(), 3);
    assert_eq!(*record.borrow(), Some(("multi.txt".to_string(), 3)));
  }

  #[test]
  fn trailing_newline_counts_empty_line() {
    let record = Rc::new(RefCell::new(None));
    let stub = StubOutput::new(Rc::clone(&record));
    let mut comp = ComputeImpl::new(Box::new(stub));

    let file = make_file("x\ny\n");
    comp.do_compute("trail.txt", file);

    assert_eq!(comp.lines(), 3);
    assert_eq!(*record.borrow(), Some(("trail.txt".to_string(), 3)));
  }
}
> cargo build --lib
   Compiling file_utils v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Fileutils)
   Compiling input v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Input)
   Compiling compute v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Compute)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.49s
C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Compute
> cargo test --lib
   Compiling windows_x86_64_msvc v0.52.6
   Compiling getrandom v0.3.3
   Compiling cfg-if v1.0.1
   Compiling fastrand v2.3.0
   Compiling once_cell v1.21.3
   Compiling windows-targets v0.52.6
   Compiling windows-sys v0.59.0
   Compiling tempfile v3.20.0
   Compiling compute v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Compute)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 2.58s
     Running unittests src\lib.rs (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\target\debug\deps\compute-061f996749009d21.exe)

running 4 tests
test tests::empty_file_emits_zero_and_reports_zero ... ok
test tests::trailing_newline_counts_empty_line ... ok
test tests::multiple_lines_counted_correctly ... ok
test tests::single_line_emits_one_and_reports_one ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo.toml
[package]
name = "compute"
version = "0.1.0"
edition = "2021"

[dependencies]
# console_output = { path = "../ConsoleOutput" }
file_utils = { path = "../Fileutils" }
input = { path = "../Input" }

[dev-dependencies]
tempfile = "3"
Comments:
  • The Comp library defines the type ComputeImpl with two data members, lines and out, a generic parameter that is required to implement the Output trait, as specified by ComputeImpl<Out: Output>.
  • It provides thre functions fn new(), fn do_compute(&mut self, name: &str, mut file: File) and fn lines(&self) -> usize.
  • do_compute attempts to read contents of file handle file into a String. If successful it counts its lines and stores in self.lines.
  • lines returns the value stored in self.lines.
  • Cargo.toml [dependencies] identifies crates the library depends on.
  • Cargo.toml [dev-dependencies] identifies crates library tests depend on.
  • This library defines unit tests for counting lines in a file, considering several cases indicated with function name and comments.
Console Output Library Build and Test
/////////////////////////////////////////////////////////////
// PlugInWithTraitObjects::ConsoleOutput::lib.rs           //
//   - Sends results to console                            //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////

use compute::Output as ComputeOutput;

/// A trivial console printer
pub struct ConsoleOutput;

impl ConsoleOutput {
  pub fn new() -> Self {
    ConsoleOutput
  }
}

impl Default for ConsoleOutput {
  fn default() -> ConsoleOutput {
    Self::new()
  }
}

impl ComputeOutput for ConsoleOutput {
  fn do_output(&self, name: &str, lines: usize) {
    println!("\n{}: {} lines", name, lines);
  }
}
#[cfg(test)]
mod tests {
  use super::*;
  use compute::Output as ComputeOutput;
  use std::any::{Any, TypeId};

  /// new() and default() both produce the same ConsoleOutput type.
  #[test]
  fn new_and_default_same_type() {
    let a = ConsoleOutput::new();
    let b: ConsoleOutput = ConsoleOutput::default();

    // `type_id()` comes from the Any trait, so import it.
    assert_eq!(TypeId::of::<ConsoleOutput>(), a.type_id());
    assert_eq!(TypeId::of::<ConsoleOutput>(), b.type_id());
  }

  /// do_output() returns the unit value and never panics.
  #[test]
  fn do_output_returns_unit() {
    let out = ConsoleOutput::new();
    let ret = out.do_output("example.rs", 7);
    assert_eq!(ret, ());
  }

  /// It still works via a Box<dyn ComputeOutput> trait object.
  #[test]
  fn trait_object_dispatches() {
    let obj: Box<dyn ComputeOutput> = Box::new(ConsoleOutput::new());
    let ret = obj.do_output("foo.rs", 13);
    assert_eq!(ret, ());
  }
}
> cargo build --lib
   Compiling file_utils v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Fileutils)
   Compiling input v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Input)
   Compiling compute v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Compute)
   Compiling console_output v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\ConsoleOutput)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\ConsoleOutput
> cargo test --lib
   Compiling console_output v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\ConsoleOutput)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running unittests src\lib.rs (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\target\debug\deps\console_output-3f714d74054ccfe7.exe)

running 3 tests
test tests::do_output_returns_unit ... ok
test tests::new_and_default_same_type ... ok
test tests::trait_object_dispatches ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo.toml
[package]
name = "console_output"
version = "0.1.0"
edition = "2021"

[dependencies]
compute = { path = "../Compute" }
Comments:
  • ConsoleOutput library defines the type ConsoleOutput with no data members.
  • It provides two functions new() and fn do_output(&self, name: &str, lines: usize).
  • do_output displays a file name and its line count.
  • This library implements 3 unit tests.
  • Output depends only on Compute, to implement the Output trait, as show in its Cargo.toml file.
File Output Library Build and Test
/////////////////////////////////////////////////////////////
// PlugInWithTraitObjects::FileOutput::lib.rs              //
//   - Sends results to console                            //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////

use compute::Output as ComputeOutput;
use std::fs::File;
use std::io::Write;

/// Writes each output line into a file.
pub struct FileOutput {
  file: File,
}

impl FileOutput {
  pub fn new(path: &str) -> std::io::Result<Self> {
    let f = File::create(path)?;
    Ok(FileOutput { file: f })
  }
}

impl ComputeOutput for FileOutput {
  fn do_output(&self, name: &str, lines: usize) {
    writeln!(&self.file, "{} -> {} lines", name, lines).expect("failed to write to output file");
  }
}
#[cfg(test)]
mod tests {
    use super::*;
    use compute::Output as ComputeOutput;
    use tempfile::NamedTempFile;
    use std::fs;
    use std::io::Read;

    /// Helper to read the entire contents of a file into a String.
    fn read_file_to_string(path: &str) -> String {
        let mut s = String::new();
        let mut f = fs::File::open(path).expect("failed to open output file for reading");
        f.read_to_string(&mut s).expect("failed to read output file");
        s
    }

    #[test]
    fn new_creates_file_and_do_output_writes_expected_line() {
        // Create a temporary file and get its path
        let tmp = NamedTempFile::new().expect("failed to create temp file");
        let path = tmp.path().to_str().unwrap();

        // Instantiate FileOutput on that path
        let fo = FileOutput::new(path).expect("FileOutput::new failed");

        // Write a test line
        fo.do_output("test.rs", 7);

        // Read back the file contents
        let contents = read_file_to_string(path);

        // Expect exactly one line in the format: "name -> lines\n"
        assert_eq!(contents, "test.rs -> 7 lines\n");
    }

    #[test]
    fn multiple_do_output_appends_lines() {
        let tmp = NamedTempFile::new().expect("failed to create temp file");
        let path = tmp.path().to_str().unwrap();

        let fo = FileOutput::new(path).expect("FileOutput::new failed");

        fo.do_output("a.rs", 1);
        fo.do_output("b.rs", 2);
        fo.do_output("c.rs", 3);

        let contents = read_file_to_string(path);
        let expected = "\
a.rs -> 1 lines
b.rs -> 2 lines
c.rs -> 3 lines
";
        assert_eq!(contents, expected);
    }
}
> cargo build --lib
   Compiling file_utils v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Fileutils)
   Compiling input v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Input)
   Compiling compute v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\Compute)
   Compiling file_output v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\FileOutput)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.51s
C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\FileOutput
> cargo test --lib
   Compiling windows_x86_64_msvc v0.52.6
   Compiling getrandom v0.3.3
   Compiling cfg-if v1.0.1
   Compiling fastrand v2.3.0
   Compiling once_cell v1.21.3
   Compiling windows-targets v0.52.6
   Compiling windows-sys v0.59.0
   Compiling tempfile v3.20.0
   Compiling file_output v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\FileOutput)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 2.29s
     Running unittests src\lib.rs (C:\github\JimFawcett\NewSite\Code\DesignStructure\PlugInWithTraitObjects\target\debug\deps\file_output-c072502cc8920022.exe)

running 2 tests
test tests::new_creates_file_and_do_output_writes_expected_line ... ok
test tests::multiple_do_output_appends_lines ... ok        

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo.toml
[package]
name = "file_output"
version = "0.1.0"
edition = "2021"

[dependencies]
compute = { path = "../Compute" }

[dev-dependencies]
tempfile = "3"
Comments:
  • ConsoleOutput library defines the type ConsoleOutput with no data members.
  • It provides two functions new() and fn do_output(&self, name: &str, lines: usize).
  • do_output displays a file name and its line count.
  • This library implements 3 unit tests.
  • Output depends only on Compute, to implement the Output trait, as show in its Cargo.toml file.
Fileutils Library Build and Test
/////////////////////////////////////////////////////////////
// FactoredStructure::Fileutils::file_utilities.rs         //
//   - Input attempts to open named file and return File   //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////
/*
  This code may be useful for other programs so it is
  factored into a module here.
*/
#![allow(dead_code)]

use std::fs::*;
use std::io::{Error, ErrorKind, Read, Write};

pub fn open_file_for_read(file_name: &str) -> Result<File, std::io::Error> {
  let rfile = OpenOptions::new().read(true).open(file_name);
  rfile
}

pub fn read_file_to_string(f: &mut File) -> Result<String, std::io::Error> {
  let mut contents = String::new();
  let bytes_rslt = f.read_to_string(&mut contents);
  if bytes_rslt.is_ok() {
    Ok(contents)
  } else {
    Err(Error::new(ErrorKind::Other, "read error"))
  }
}

pub fn open_file_for_write(file_name: &str) -> Result<File, std::io::Error> {
  let wfile = OpenOptions::new()
    .write(true)
    .create(true)
    .truncate(true)
    .open(file_name);
  wfile
}

pub fn write_string_to_file_handle(s: &str, mut f: std::fs::File) -> std::io::Result<()> {
  f.write_all(s.as_bytes())?;
  f.flush()?;
  Ok(())
}

pub fn write_string_to_file(s: &str, file_name: &str) -> std::io::Result<()> {
  std::fs::write(file_name, s)?;
  Ok(())
}

#[cfg(test)]
mod tests {
  use super::*;
  use tempfile::NamedTempFile;

  /// Test the filename-based API: write_string_to_file + open_file_for_read + read_file_to_string
  #[test]
  fn write_and_read_via_filename() {
    // Create a temporary file and get its path as a String
    let tmp = NamedTempFile::new().expect("failed to create temp file");
    let path = tmp.path().to_str().unwrap().to_owned();

    let test_string = "hello via filename";
    // Write via the file_name API
    write_string_to_file(test_string, &path).expect("write_string_to_file failed");

    // Read back
    let mut f = open_file_for_read(&path).expect("open_file_for_read failed");
    let contents = read_file_to_string(&mut f).expect("read_file_to_string failed");

    assert_eq!(contents, test_string);
  }

  /// Test the handle-based API: open_file_for_write + write_string_to_file_handle + open/read
  #[test]
  fn write_and_read_via_handle() {
    let tmp = NamedTempFile::new()
      .expect("failed to create temp file");
    let path = tmp.path().to_str().unwrap().to_owned();

    let test_string = "hello via handle";
    // Open for write, then write via handle
    let wfile = open_file_for_write(&path)
      .expect("open_file_for_write failed");
    write_string_to_file_handle(test_string, wfile)
      .expect("write_string_to_file_handle failed");

    // Read back
    let mut f2 = open_file_for_read(&path)
      .expect("open_file_for_read failed");
    let contents2 = read_file_to_string(&mut f2)
      .expect("read_file_to_string failed");

    assert_eq!(contents2, test_string);
  }
}
> cargo build --lib
   Compiling file_utils v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\TypeEraseDataFlowStructure\Fileutils)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
C:\github\JimFawcett\NewSite\Code\DesignStructure\TypeEraseDataFlowStructure\Fileutils
> cargo test --lib
   Compiling windows_x86_64_msvc v0.52.6
   Compiling getrandom v0.3.3
   Compiling cfg-if v1.0.1
   Compiling once_cell v1.21.3
   Compiling fastrand v2.3.0
   Compiling windows-targets v0.52.6
   Compiling windows-sys v0.59.0
   Compiling tempfile v3.20.0
   Compiling file_utils v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\TypeEraseDataFlowStructure\Fileutils)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 2.02s
     Running unittests src\lib.rs (target\debug\deps\file_utils-43bed36694d2543d.exe)

running 2 tests
test tests::write_and_read_via_handle ... ok
test tests::write_and_read_via_filename ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo.toml
[package]
name = "file_utils"
version = "0.1.0"
edition = "2024"

[dependencies]


[dev-dependencies]
tempfile = "3"
Comments:
  • Fileutils library has no dependencies other than the Rust standard libraries, as shown in the Cargo.toml above.
  • It does not define any new types, but does define several functions:
    1. fn open_file_for_read(file_name: &str) -> Result<File: std::io::Error>
      Attempts to open named file and return a handle to its String contents. The return type is Result<File, std::io::Error>
    2. fn read_file_to_string(f: &mut File) -> Result<String, std::io::Error>
      Attempts to read contents referred to by file handle and return the string of contents wrapped in Ok(contents).
    3. fn open_file_for_write(file_name: &str) -> Result<File, std::io::Error>
      Attempts to open named file and return a File handle to the opened named file.
    4. fn write_string_to_file_handle(s: &str, mut f: std::fs:::File) -> Result<()>
      Attempts to write string to file referred to by the File handle and flush the handle.
    5. fn write_string_to_file(s: &str, file_name: &str) -> Result<()>
      Attempts to write string to file referred to named file.
  • The Build and Test view above shows successful build and execution of two unit test functions.

3. Epilogue

The five design alternatives considered here:
  1. Monolithic Structure
  2. Factored Structure
  3. DataFlow Structure
  4. TypeErase Structure
  5. PlugIn Structure
are progressively more flexible, eventually resulting in reusable components, but also increasingly complex. Where you settle in these alternatives is determined by design context. Is this a one-of-a-kind project that you want to finish quickly or is it heading for production code that will be maintained by more than one developer?