SW Design Bites: Factored Structure

"The measure of success is not whether you have a tough problem to deal with, but whether it is the same problem you had last year."
- John Foster Dulles

1.0 Introduction

This DesignBite sequence was inspired by 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 about structure. This bite factors program activities into a main Executive, a module with small utilities used by Executive, and three libraries, one each for input, file processing, and output. All of the parts are created by the Executive and called in the order input, processing, and output. We consider both package structure and logical structure, e.g., functions and structs used to order design and implementation. In this Factored 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 - Factored

This structure is modular with a classic input-process-output structure. It differs from the previous Basic structure in that:
  • It consists of four packages.
  • Its code size is about 3 times that of the basic structure. That's because each package provides a user-defined type and testing code.
  • For small projects this additional structure may not always be warranted.
  • For bigger projects it is much easier to understand and test.
Figure 2. Factored Pkg Structure

Basic Factored Structure

Program's operations are implemented in separate packages. Some of the factored packages may, themselves, be factored.

Pros:

  1. If only one package is changed, only that one is compiled
  2. Much easier to understand and test

Cons:

  1. Project setup gets more complex
  2. More pieces to track and deploy
  3. Executive has to participate in each stage of the processing
Project Notes: Rust has two mechanisms for factoring code:
  • Statically linked libraries are sharable and have built in hooks for setting up unit tests. Dependencies are declared in Cargo.toml files, one for each library and executive main.
  • Modules are files that reside in the same directory or child directory as the using code. They are imported into using code, but sharing between multiple users is more difficult.
We will use libraries exclusively in these demonstration projects.
Factored Code Repository
Executive Output
/////////////////////////////////////////////////////////////
// FactoredStructure::Executive::main.rs                   //
//   - Executive creates and uses all lower level parts    //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////
#![allow(dead_code)]
#![allow(unused_imports)]
use compute::*;
use input::*;
use output::*;

/*-- attempt to open file and count its lines of code --*/
fn test_ops(name: &str) -> usize {
  let mut inp = Input::new();
  let mut cmp = Compute::new();
  let out = Output::new();

  let mut lines: usize = 0;
  let opt = inp.do_input(name);
  if let Some(file) = opt {
    cmp.do_compute(name, file);
    lines = cmp.lines();
    out.do_output(name, lines);
  } else {
    print!("\n  couldn't process {:?}", name);
  }
  lines
}
/*-- run test_ops for each package in this project --*/
fn run_test_ops() -> usize {
  let name = "../Executive/src/main.rs";
  let mut lines = 0;
  lines += test_ops(name);
  println!();

  let name = "../Input/src/lib.rs";
  lines += test_ops(name);
  let name = "../Input/examples/test1.rs";
  lines += test_ops(name);
  println!();

  let name = "../Compute/src/lib.rs";
  lines += test_ops(name);
  let name = "../Compute/examples/test1.rs";
  lines += test_ops(name);
  println!();

  let name = "../Output/src/lib.rs";
  lines += test_ops(name);
  let name = "../Output/examples/test1.rs";
  lines += test_ops(name);
  println!();

  let name = "../Fileutils/src/lib.rs";
  lines += test_ops(name);
  println!();

  let name = "no-exist";
  lines += test_ops(name);
  println!();

  lines
}
/*-- Executive processing --*/
fn main() {
  print!("\n  -- FactoredStructure::Executive --\n");

  let lines = run_test_ops();
  print!("\n  total lines: {}", lines);

  print!("\n\n  That's all Folks!\n\n");
}            
> cargo run -q

  -- FactoredStructure::Executive --

    73 lines in file "../Executive/src/main.rs"

    60 lines in file "../Input/src/lib.rs"
    22 lines in file "../Input/examples/test1.rs"

    61 lines in file "../Compute/src/lib.rs"
    42 lines in file "../Compute/examples/test1.rs"        

    24 lines in file "../Output/src/lib.rs"
    16 lines in file "../Output/examples/test1.rs"

    91 lines in file "../Fileutils/src/lib.rs"

  can't open file "no-exist"
  couldn't process "no-exist"

  total lines: 389

  That's all Folks!
Cargo.toml
[package]
name = "executive"
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]
input = { path = "../Input" }
compute = { path = "../Compute" }
output = { path = "../Output" }            
Comments:
  • Executive code resides in the left panel. It creates instances of the types: Input, Compute, and Output.
  • These types are defined by libraries Input::lib.rs, Compute::lib.rs, and Output::lib.rs.
  • It provides function test_ops(&str) that creates an instance of inp of Input which attempts to open a file and return its handle.
  • test_ops(&str) creates an instance cmp of Compute that counts lines in the file opened by inp.
  • It then creates an instance out of Output that displays the filename and line count.
  • Executive then defines a function run_test_ops() that calls test_ops(&str) on each file in this project, defined by static urls.
  • The total line count of StructureFactored is significantly larger thatn StructureBasic, due in large part to the FileUtilities library that has incorporated unit tests (see Program Output, above).
Input Library Cargo.toml
/////////////////////////////////////////////////////////////
// FactoredStructure::Input::lib.rs                        //
//   - Input attempts to open named file and return File   //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////

use file_utils::open_file_for_read;
use std::fs::*;

#[allow(dead_code)]
#[derive(Debug)]
pub struct Input {
  // no data members
}
impl Default for Input {
  fn default() -> Self {
    Self::new()
  }
}
impl Input {
  pub fn new() -> Input {
    Input {}
  }
  pub fn do_input(&mut self, name: &str) -> Option<File> {
    let rslt = open_file_for_read(name);
    if let Ok(file) = rslt {
      Some(file)
    } else {
      print!("\n  can't open file {:?}", name);
      None
    }
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  use file_utils::{read_file_to_string, write_string_to_file};

  // fn test1() has been refactored by Perplexity AI from my
  // original test code.  Now more consise.

  #[test]
  fn test1() {
    // Test setup: create test file
    write_string_to_file("test1", "test1.txt").expect("Failed to write test file");

    // Check: file is readable
    let _file = open_file_for_read("test1.txt").expect("File should be readable");

    // Test Input::do_input opens file successfully
    let mut inp = Input::new();
    let file_option = inp.do_input("test1.txt");
    let mut file = file_option.expect("Input::do_input should return Some(file)");

    // The file content is as expected
    let content = read_file_to_string(&mut file).expect("Should read file content");
    assert_eq!(content, "test1");
  }
}
[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" }
Build and Test
> cargo build --lib
   Compiling file_utils v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\Fileutils)
   Compiling input v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\Input)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\Input
> cargo test --lib
   Compiling input v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\Input)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.24s
     Running unittests src\lib.rs (target\debug\deps\input-afa3cf23b115c222.exe)

running 1 test
test tests::test1 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Comments:
  • Input library declares the Input type that has no data members and provides public method fn do_input(&mut self, name: &str)
  • do_input(&mut self, name: &str) attempts to open named file and return a handle to that file.
  • Input library provides unit tests configured with the standard Rust library test hook. Test output is shown above.
  • This library depends only on the file_utils library and standard libraries, as shown by the Cargo.toml file at the top of this view.
  • You will find more typical unit testing in the Fileutils library
Compute Library Cargo.toml
/////////////////////////////////////////////////////////////
// FactoredStructure::Compute::lib.rs                      //
//   - Input attempts to read File to string & count lines //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////

use file_utils::read_file_to_string;
use std::fs::*;

#[derive(Debug)]
pub struct Compute {
  lines: usize,
}
impl Default for Compute {
  fn default() -> Self {
    Self::new()
  }
}
impl Compute {
  pub fn new() -> Compute {
    Compute { lines: 0 }
  }
  /*-- read file, count lines and save count --*/
  pub fn do_compute(&mut self, name: &str, mut file: File) {
    let rslt = read_file_to_string(&mut file);
    if let Ok(contents) = rslt {
      if contents.len() == 0 {
        self.lines = 0;
      } else {
        self.lines = 1;
      }
      for ch in contents.chars() {
        if ch == '\n' {
          self.lines += 1;
        }
      }
    } else {
      print!("\n  couldn't open {:?}", name);
    }
  }
  /*-- return saved line count --*/
  pub fn lines(&self) -> usize {
    self.lines
  }
}
#[cfg(test)]
mod tests {
  use super::*;
  use file_utils::{open_file_for_read, write_string_to_file};

  #[test]
  fn read_and_compute() {
    write_string_to_file("test1", "test1.txt").expect("Failed to write test file");
    let file = open_file_for_read("test1.txt").expect("Failed to open test file for read");

    let mut comp = Compute::new();
    comp.do_compute("test1.txt", file);
    assert_eq!(comp.lines(), 1);
  }
}
[package]
name = "compute"
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" }
            
Build and Test
> cargo build --lib
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\Compute
> cargo test --lib
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running unittests src\lib.rs (target\debug\deps\compute-af586bcaada24d0a.exe)

running 1 test
test tests::read_and_compute ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
            
Comments:
  • Compute library declares the Compute type that has a lines: usize data member and provides public method fn do_compute(&mut self, name: &str, mut file: File)
  • do_compute(&mut self, name: &str) attempts to read file into a Result<String, Error>
  • If that succeeds it counts lines in the String instance and saves in its member lines: usize.
  • Compute also provides the public function fn lines(&self) -> usize, used by the Output library to display the result.
  • Compute library provides unit tests configured with the standard Rust library test hook. Test output is shown above.
  • This library depends only on the file_utils library and standard libraries, as shown by the Cargo.toml file at the top of this view.
  • You will find more typical unit testing in the Fileutils library
Output Library Cargo.toml
/////////////////////////////////////////////////////////////
// FactoredStructure::Output::lib.rs                       //
//   - Output displays line count                          //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021  //
/////////////////////////////////////////////////////////////

#[derive(Debug)]
pub struct Output {}
impl Default for Output {
  fn default() -> Self {
    Self::new()
  }
}
impl Output {
  pub fn new() -> Output {
    Output {}
  }
  pub fn do_output(&self, name: &str, lines: usize) {
    print!("\n  {:4} lines in file {:?}", lines, name);
  }
}
#[cfg(test)]
mod tests {
  use super::*;

  /// new() + Debug should produce the struct name
  #[test]
  fn new_and_debug() {
    let out = Output::new();
    // Debug derive on `struct Output {}` prints just "Output"
    assert_eq!(format!("{:?}", out), "Output");
  }

  /// do_output() should return () and never panic
  #[test]
  fn do_output_does_not_panic() {
    let out = Output::new();
    // We're not capturing stdout here (that requires an external crate
    // or reworking the API), but at least we ensure it runs cleanly.
    let unit = out.do_output("foo.rs", 7);
    let () = unit; // type-check that we got the unit value back
  }
}
[package]
name = "output"
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]
            
Build and Test
> cargo build --lib
   Compiling output v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\Output)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.20s
C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\Output
> cargo test --lib
   Compiling output v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\Output)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.24s
     Running unittests src\lib.rs (target\debug\deps\output-1eee8dc0a1134569.exe)

running 2 tests
test tests::do_output_does_not_panic ... ok
test tests::new_and_debug ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Comments:
  • The Output library defines one type, Output, with no data members and one method, fn do_output(&self, name: &str, lines: usize).
  • It has no dependencies other than the Rust standard libraries, as shown in the Cargo.toml file at the top of this view.
  • The build and output view above shows successful build and execution of the lilbrary's unit tests.
Fileutils Library Cargo.toml
/////////////////////////////////////////////////////////////
// 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 `write_string_to_file` + `open_file_for_read` + `read_file_to_string` path.
  #[test]
  fn write_and_read_via_filename() {
    // create a temp file path but don't write to it yet
    let tmp = NamedTempFile::new().expect("create temp file");
    let path = tmp.path().to_str().unwrap();

    let test_string = "hello filename!";
    // write via filename API
    write_string_to_file(test_string, path).expect("write_string_to_file failed");

    // now read it 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 `open_file_for_write` + `write_string_to_file_handle` + read path.
  #[test]
  fn write_and_read_via_handle() {
    let tmp = NamedTempFile::new().expect("create temp file");
    let path = tmp.path().to_str().unwrap();

    let test_string = "hello handle!";
    // open for write (creates & truncates), 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 it 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);
  }
}
[package]
name = "file_utils"
version = "0.1.0"
edition = "2024"

[dependencies]

[dev-dependencies]
tempfile = "3"
Build and Test
> cargo build --lib
   Compiling file_utils v0.1.0 (C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\FileUtils)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s
C:\github\JimFawcett\NewSite\Code\DesignStructure\FactoredStructure\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\FactoredStructure\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_filename ... ok
test tests::write_and_read_via_handle ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Comments:
  • Fileutils library has no dependencies other than the Rust standard libraries, as shown in the Cargo.toml file at the top of this view.
  • 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 fourh 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?