Week 46 of 2025

Development log of Tad Better Behavior

36 items
  1. Setup the project using devenv with flake
  2. Setup Rust development environment
  3. Setup CLI parsing with Clap
  4. Set the name of main binary to tbb
  5. Describe the input option
  6. Write a basic sample specification
  7. Distinguish between file and directory inputs
  8. Read the spec input and print it back
  9. Parse input to mdast
  10. Define structs (Spec, Scenario, Step)
  11. Fix a typo
  12. Use h1 for spec title
  13. Implement scenarios extraction
  14. Setup proper logging using log and env_logger
  15. Separate Spec, Scenario and Step to the spec module
  16. Separate parsing markdown to Spec module
  17. Refactor step extraction
  18. Stub the code for spec evaluation
  19. Clarify some naming
  20. Stub tbb <-> interpreter pipes
  21. Enable proper logging in the interpreter
  22. Implement two steps from the arithmetic scenario
  23. Make a decorator for steps in basic interpreter
  24. Separate reading spec from its evaluation
  25. Move spec evaluation from main to Spec::evaluate
  26. Dump some thoughts in a README
  27. Implement pretty printing of a Spec
  28. Implement EvaluationReport struct and some logic
  29. Always print report, even if some scenarios failed
  30. Update the roadmap
  31. Enumerate control and interpreter messages
  32. Fix basic interpreter crashing on assertion errors
  33. Make the program skip steps after failure
  34. Implement one step from the Text scenario
  35. Update the roadmap
  36. Style the report

Setup the project using devenv with flake

On by Tad Lispy

new file mode 100644
index 0000000..c5d670d
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then
+  source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM="
+fi
+
+export DEVENV_IN_DIRENV_SHELL=true
+
+watch_file flake.nix
+watch_file flake.lock
+if ! use flake . --no-pure-eval; then
+  echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2
+fi
new file mode 100644
index 0000000..f7f246e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.devenv
+.direnv/
\ No newline at end of file
new file mode 100644
index 0000000..790b7aa
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,227 @@
+{
+  "nodes": {
+    "cachix": {
+      "inputs": {
+        "devenv": [
+          "devenv"
+        ],
+        "flake-compat": [
+          "devenv",
+          "flake-compat"
+        ],
+        "git-hooks": [
+          "devenv",
+          "git-hooks"
+        ],
+        "nixpkgs": [
+          "devenv",
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1760971495,
+        "narHash": "sha256-IwnNtbNVrlZIHh7h4Wz6VP0Furxg9Hh0ycighvL5cZc=",
+        "owner": "cachix",
+        "repo": "cachix",
+        "rev": "c5bfd933d1033672f51a863c47303fc0e093c2d2",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "ref": "latest",
+        "repo": "cachix",
+        "type": "github"
+      }
+    },
+    "devenv": {
+      "inputs": {
+        "cachix": "cachix",
+        "flake-compat": "flake-compat",
+        "flake-parts": "flake-parts",
+        "git-hooks": "git-hooks",
+        "nix": "nix",
+        "nixpkgs": [
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1762889687,
+        "narHash": "sha256-oKvHfeYDZ0LfuHSaFLA0w/dfZ9R6C5W8pCGUjUWawGI=",
+        "owner": "cachix",
+        "repo": "devenv",
+        "rev": "3b4fb549962342c928aae1bbea3a13f0eeed2703",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "repo": "devenv",
+        "type": "github"
+      }
+    },
+    "flake-compat": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1761588595,
+        "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
+        "type": "github"
+      },
+      "original": {
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "type": "github"
+      }
+    },
+    "flake-parts": {
+      "inputs": {
+        "nixpkgs-lib": [
+          "devenv",
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1760948891,
+        "narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
+        "owner": "hercules-ci",
+        "repo": "flake-parts",
+        "rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
+        "type": "github"
+      },
+      "original": {
+        "owner": "hercules-ci",
+        "repo": "flake-parts",
+        "type": "github"
+      }
+    },
+    "git-hooks": {
+      "inputs": {
+        "flake-compat": [
+          "devenv",
+          "flake-compat"
+        ],
+        "gitignore": "gitignore",
+        "nixpkgs": [
+          "devenv",
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1760663237,
+        "narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
+        "owner": "cachix",
+        "repo": "git-hooks.nix",
+        "rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "repo": "git-hooks.nix",
+        "type": "github"
+      }
+    },
+    "gitignore": {
+      "inputs": {
+        "nixpkgs": [
+          "devenv",
+          "git-hooks",
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1709087332,
+        "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
+        "owner": "hercules-ci",
+        "repo": "gitignore.nix",
+        "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
+        "type": "github"
+      },
+      "original": {
+        "owner": "hercules-ci",
+        "repo": "gitignore.nix",
+        "type": "github"
+      }
+    },
+    "nix": {
+      "inputs": {
+        "flake-compat": [
+          "devenv",
+          "flake-compat"
+        ],
+        "flake-parts": [
+          "devenv",
+          "flake-parts"
+        ],
+        "git-hooks-nix": [
+          "devenv",
+          "git-hooks"
+        ],
+        "nixpkgs": [
+          "devenv",
+          "nixpkgs"
+        ],
+        "nixpkgs-23-11": [
+          "devenv"
+        ],
+        "nixpkgs-regression": [
+          "devenv"
+        ]
+      },
+      "locked": {
+        "lastModified": 1761648602,
+        "narHash": "sha256-H97KSB/luq/aGobKRuHahOvT1r7C03BgB6D5HBZsbN8=",
+        "owner": "cachix",
+        "repo": "nix",
+        "rev": "3e5644da6830ef65f0a2f7ec22830c46285bfff6",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "ref": "devenv-2.30.6",
+        "repo": "nix",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1761313199,
+        "narHash": "sha256-wCIACXbNtXAlwvQUo1Ed++loFALPjYUA3dpcUJiXO44=",
+        "owner": "cachix",
+        "repo": "devenv-nixpkgs",
+        "rev": "d1c30452ebecfc55185ae6d1c983c09da0c274ff",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "ref": "rolling",
+        "repo": "devenv-nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "devenv": "devenv",
+        "nixpkgs": "nixpkgs",
+        "systems": "systems"
+      }
+    },
+    "systems": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
new file mode 100644
index 0000000..f90aba3
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,42 @@
+{
+  inputs = {
+    nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
+    systems.url = "github:nix-systems/default";
+    devenv.url = "github:cachix/devenv";
+    devenv.inputs.nixpkgs.follows = "nixpkgs";
+  };
+
+  nixConfig = {
+    extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
+    extra-substituters = "https://devenv.cachix.org";
+  };
+
+  outputs = { self, nixpkgs, devenv, systems, ... } @ inputs:
+    let
+      forEachSystem = nixpkgs.lib.genAttrs (import systems);
+    in
+    {
+      devShells = forEachSystem
+        (system:
+          let
+            pkgs = nixpkgs.legacyPackages.${system};
+          in
+          {
+            default = devenv.lib.mkShell {
+              inherit inputs pkgs;
+              modules = [
+                {
+                  # https://devenv.sh/reference/options/
+                  packages = [ pkgs.hello ];
+
+                  enterShell = ''
+                    hello
+                  '';
+
+                  processes.hello.exec = "hello";
+                }
+              ];
+            };
+          });
+    };
+}

Setup Rust development environment

On by Tad Lispy

index f7f246e..98507b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,6 @@
=.devenv
-.direnv/
\ No newline at end of file
+.direnv/
+
+# Added by cargo
+
+/target
new file mode 100644
index 0000000..ee2c2d5
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,7 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "tad-better-behavior"
+version = "0.1.0"
new file mode 100644
index 0000000..048938e
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,6 @@
+[package]
+name = "tad-better-behavior"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
index f90aba3..0560c1e 100644
--- a/flake.nix
+++ b/flake.nix
@@ -27,13 +27,9 @@
=              modules = [
=                {
=                  # https://devenv.sh/reference/options/
-                  packages = [ pkgs.hello ];
=
-                  enterShell = ''
-                    hello
-                  '';
-
-                  processes.hello.exec = "hello";
+                  languages.rust.enable = true;
+                  packages = [];
=                }
=              ];
=            };
new file mode 100644
index 0000000..e7a11a9
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,3 @@
+fn main() {
+    println!("Hello, world!");
+}

Setup CLI parsing with Clap

On by Tad Lispy

index ee2c2d5..488461d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,185 @@
=# It is not intended for manual editing.
=version = 4
=
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
=[[package]]
=name = "tad-better-behavior"
=version = "0.1.0"
+dependencies = [
+ "clap",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
index 048938e..ead47b9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,3 +4,4 @@ version = "0.1.0"
=edition = "2024"
=
=[dependencies]
+clap = { version = "4.5.51", features = ["derive"] }
index e7a11a9..414bc9c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,15 @@
+use clap::Parser;
+use std::path::PathBuf;
+
+#[derive(Parser)]
+#[command(version, about, long_about=None)]
+struct Cli {
+    #[arg(value_name = "SPEC PATH", default_value = "./spec/")]
+    input: PathBuf,
+}
+
=fn main() {
-    println!("Hello, world!");
+    let cli = Cli::parse();
+
+    println!("Reading specifications from {}", cli.input.display());
=}

Set the name of main binary to tbb

On by Tad Lispy

Short for Tad Better Behavior!

index ead47b9..3386de1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,3 +5,7 @@ edition = "2024"
=
=[dependencies]
=clap = { version = "4.5.51", features = ["derive"] }
+
+[[bin]]
+name = "tbb"
+path = "src/main.rs"

Describe the input option

On by Tad Lispy

index 414bc9c..a606887 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@ use std::path::PathBuf;
=#[derive(Parser)]
=#[command(version, about, long_about=None)]
=struct Cli {
+    /// A directory or a markdown file with specs to evaluate
=    #[arg(value_name = "SPEC PATH", default_value = "./spec/")]
=    input: PathBuf,
=}

Write a basic sample specification

On by Tad Lispy

new file mode 100644
index 0000000..163ee32
--- /dev/null
+++ b/samples/basic.md
@@ -0,0 +1,29 @@
+---
+interpretter: "python samples/basic.py"
+---
+
+# Basic BDD suite
+
+This suite contains several simple **scenarios**. Use it as a reference to get started with Tad Better Behavior.
+
+Scenarios are delimited by 2nd level heading (`## Heading` in markdown). Each bullet point inside a scenario defines a **step**. 
+
+
+For each scenario the **interpreter** program (see the front-matter above) will be started. For every step this program will be passed a JSON object via `stdin`. The object will contain the description of the step. An interpretter is expected to print a JSON object with the result of evaluation on it's `stdout`.
+
+
+## Arithmetic
+
+Content outside of bullet points (like this) won't have any effect on the program. You can user it as a humane description of the scenario, or for any other purpose.
+
+  * Add `7` and `5` to get `12`.
+  * Divide `8` by `4` to get `2`
+  * Subtract `7` from `5` to get `-2`
+
+
+## Text
+
+  * The word `blocks` has `6` characters
+  * There are `3` `r`s in the word `strawberry`
+  * The reverse of `abc` is `cba`
+  * There are `2` `o`s in the word `boost`

Distinguish between file and directory inputs

On by Tad Lispy

Handle errors.

index a606887..c6a8eae 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,8 +9,19 @@ struct Cli {
=    input: PathBuf,
=}
=
-fn main() {
+fn main() -> Result<(), Box<dyn std::error::Error>> {
=    let cli = Cli::parse();
=
=    println!("Reading specifications from {}", cli.input.display());
+
+    let input = cli.input.canonicalize()?;
+    if input.is_dir() {
+        println!("Input is a directory. Looking for markdown files...");
+        Ok(())
+    } else if input.is_file() {
+        println!("Input is a file. Reading...");
+        Ok(())
+    } else {
+        Err(format!("The {} is neither a file nor directory", input.display()).into())
+    }
=}

Read the spec input and print it back

On by Tad Lispy

index 488461d..ed46f99 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -98,6 +98,12 @@ version = "1.0.4"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
=
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
=[[package]]
=name = "heck"
=version = "0.5.0"
@@ -156,6 +162,7 @@ name = "tad-better-behavior"
=version = "0.1.0"
=dependencies = [
= "clap",
+ "glob",
=]
=
=[[package]]
index 3386de1..3e2476d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2024"
=
=[dependencies]
=clap = { version = "4.5.51", features = ["derive"] }
+glob = "0.3.3"
=
=[[bin]]
=name = "tbb"
index c6a8eae..66b20da 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,6 @@
=use clap::Parser;
+use glob::glob;
+use std::error::Error;
=use std::path::PathBuf;
=
=#[derive(Parser)]
@@ -9,19 +11,33 @@ struct Cli {
=    input: PathBuf,
=}
=
-fn main() -> Result<(), Box<dyn std::error::Error>> {
+fn main() -> Result<(), Box<dyn Error>> {
=    let cli = Cli::parse();
=
=    println!("Reading specifications from {}", cli.input.display());
=
=    let input = cli.input.canonicalize()?;
=    if input.is_dir() {
-        println!("Input is a directory. Looking for markdown files...");
+        println!(
+            "The {} is a directory. Looking for markdown files...",
+            input.display()
+        );
+        let pattern = format!("{}/**/*.md", input.display());
+        for path in glob(&pattern)? {
+            process_spec(path?)?;
+        }
=        Ok(())
=    } else if input.is_file() {
-        println!("Input is a file. Reading...");
-        Ok(())
+        println!("The {} is a file. Reading...", input.display());
+        process_spec(input)
=    } else {
=        Err(format!("The {} is neither a file nor directory", input.display()).into())
=    }
=}
+
+fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
+    println!("Reading {}", input.display());
+    let markdown = std::fs::read_to_string(input)?;
+    println!("Content:\n\n{}", markdown);
+    Ok(())
+}

Parse input to mdast

On by Tad Lispy

index ed46f99..d270297 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -116,6 +116,15 @@ version = "1.70.2"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
=
+[[package]]
+name = "markdown"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb"
+dependencies = [
+ "unicode-id",
+]
+
=[[package]]
=name = "once_cell_polyfill"
=version = "1.70.2"
@@ -163,8 +172,15 @@ version = "0.1.0"
=dependencies = [
= "clap",
= "glob",
+ "markdown",
=]
=
+[[package]]
+name = "unicode-id"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580"
+
=[[package]]
=name = "unicode-ident"
=version = "1.0.22"
index 3e2476d..0ee1226 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ edition = "2024"
=[dependencies]
=clap = { version = "4.5.51", features = ["derive"] }
=glob = "0.3.3"
+markdown = "1.0.0"
=
=[[bin]]
=name = "tbb"
index 163ee32..134df0d 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -8,7 +8,6 @@ This suite contains several simple **scenarios**. Use it as a reference to get s
=
=Scenarios are delimited by 2nd level heading (`## Heading` in markdown). Each bullet point inside a scenario defines a **step**. 
=
-
=For each scenario the **interpreter** program (see the front-matter above) will be started. For every step this program will be passed a JSON object via `stdin`. The object will contain the description of the step. An interpretter is expected to print a JSON object with the result of evaluation on it's `stdout`.
=
=
index 66b20da..b884fb4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
=use clap::Parser;
=use glob::glob;
+use markdown;
=use std::error::Error;
=use std::path::PathBuf;
=
@@ -37,7 +38,18 @@ fn main() -> Result<(), Box<dyn Error>> {
=
=fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
=    println!("Reading {}", input.display());
-    let markdown = std::fs::read_to_string(input)?;
-    println!("Content:\n\n{}", markdown);
+    let md = std::fs::read_to_string(input)?;
+    let mdast = markdown::to_mdast(
+        &md,
+        &markdown::ParseOptions {
+            constructs: markdown::Constructs {
+                frontmatter: true,
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+    )
+    .map_err(|message| message.to_string())?;
+    println!("Content:\n\n{:#?}", mdast);
=    Ok(())
=}

Define structs (Spec, Scenario, Step)

On by Tad Lispy

Also extract interpretter from a front-matter using serde_yaml.

index d270297..efecadd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -98,24 +98,52 @@ version = "1.0.4"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
=
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
=[[package]]
=name = "glob"
=version = "0.3.3"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
=
+[[package]]
+name = "hashbrown"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
+
=[[package]]
=name = "heck"
=version = "0.5.0"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
=
+[[package]]
+name = "indexmap"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
=[[package]]
=name = "is_terminal_polyfill"
=version = "1.70.2"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
=
+[[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
=[[package]]
=name = "markdown"
=version = "1.0.0"
@@ -149,6 +177,55 @@ dependencies = [
= "proc-macro2",
=]
=
+[[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
=[[package]]
=name = "strsim"
=version = "0.11.1"
@@ -173,6 +250,8 @@ dependencies = [
= "clap",
= "glob",
= "markdown",
+ "serde",
+ "serde_yaml",
=]
=
=[[package]]
@@ -187,6 +266,12 @@ version = "1.0.22"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
=
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
=[[package]]
=name = "utf8parse"
=version = "0.2.2"
index 0ee1226..0511019 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,6 +7,8 @@ edition = "2024"
=clap = { version = "4.5.51", features = ["derive"] }
=glob = "0.3.3"
=markdown = "1.0.0"
+serde = { version = "1.0.228", features = ["serde_derive"] }
+serde_yaml = "0.9.34"
=
=[[bin]]
=name = "tbb"
index b884fb4..0e98912 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
=use clap::Parser;
=use glob::glob;
=use markdown;
+use serde::Deserialize;
=use std::error::Error;
=use std::path::PathBuf;
=
@@ -36,6 +37,58 @@ fn main() -> Result<(), Box<dyn Error>> {
=    }
=}
=
+#[derive(Debug)]
+struct Spec {
+    title: String,
+    interpretter: String,
+    scenarios: Vec<Scenario>,
+}
+
+#[derive(Debug)]
+struct Scenario {
+    title: String,
+    steps: Vec<Step>,
+}
+
+#[derive(Debug)]
+struct Step {
+    variant: String,
+    arguments: Vec<String>,
+}
+
+#[derive(Deserialize)]
+struct FrontMatter {
+    interpretter: String,
+}
+
+impl TryFrom<markdown::mdast::Node> for Spec {
+    type Error = Box<dyn Error>;
+
+    fn try_from(mdast: markdown::mdast::Node) -> Result<Self, Self::Error> {
+        // Find the YAML frontmatter and extract interpretter field
+        let children = mdast.children().ok_or("The given node has no children.")?;
+        let first = children
+            .first()
+            .ok_or("There is no first child in the given node.")?;
+
+        let markdown::mdast::Node::Yaml(markdown::mdast::Yaml { value: yaml, .. }) = first else {
+            return Err("First child is not a YAML front matter.".into());
+        };
+        let frontmatter: FrontMatter = serde_yaml::from_str(yaml)?;
+
+        // TODO: Find h1 and use it as title (make sure there's only one)
+
+        // TODO: Split into sections, each starting at h2
+        // TODO: Convert each section into a scenario (section::try_into())
+
+        Ok(Self {
+            title: "Spec title".into(),
+            interpretter: frontmatter.interpretter,
+            scenarios: [].into(),
+        })
+    }
+}
+
=fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
=    println!("Reading {}", input.display());
=    let md = std::fs::read_to_string(input)?;
@@ -51,5 +104,8 @@ fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
=    )
=    .map_err(|message| message.to_string())?;
=    println!("Content:\n\n{:#?}", mdast);
+
+    let spec = Spec::try_from(mdast)?;
+    println!("Spec:\n\n{:#?}", spec);
=    Ok(())
=}

Fix a typo

On by Tad Lispy

Spelling hard

index 134df0d..95a7704 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -1,5 +1,5 @@
=---
-interpretter: "python samples/basic.py"
+interpreter: "python samples/basic.py"
=---
=
=# Basic BDD suite
@@ -8,7 +8,7 @@ This suite contains several simple **scenarios**. Use it as a reference to get s
=
=Scenarios are delimited by 2nd level heading (`## Heading` in markdown). Each bullet point inside a scenario defines a **step**. 
=
-For each scenario the **interpreter** program (see the front-matter above) will be started. For every step this program will be passed a JSON object via `stdin`. The object will contain the description of the step. An interpretter is expected to print a JSON object with the result of evaluation on it's `stdout`.
+For each scenario the **interpreter** program (see the front-matter above) will be started. For every step this program will be passed a JSON object via `stdin`. The object will contain the description of the step. An interpreter is expected to print a JSON object with the result of evaluation on it's `stdout`.
=
=
=## Arithmetic
index 0e98912..151721e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -40,7 +40,7 @@ fn main() -> Result<(), Box<dyn Error>> {
=#[derive(Debug)]
=struct Spec {
=    title: String,
-    interpretter: String,
+    interpreter: String,
=    scenarios: Vec<Scenario>,
=}
=
@@ -58,14 +58,14 @@ struct Step {
=
=#[derive(Deserialize)]
=struct FrontMatter {
-    interpretter: String,
+    interpreter: String,
=}
=
=impl TryFrom<markdown::mdast::Node> for Spec {
=    type Error = Box<dyn Error>;
=
=    fn try_from(mdast: markdown::mdast::Node) -> Result<Self, Self::Error> {
-        // Find the YAML frontmatter and extract interpretter field
+        // Find the YAML front-matter and extract the interpreter field
=        let children = mdast.children().ok_or("The given node has no children.")?;
=        let first = children
=            .first()
@@ -83,7 +83,7 @@ impl TryFrom<markdown::mdast::Node> for Spec {
=
=        Ok(Self {
=            title: "Spec title".into(),
-            interpretter: frontmatter.interpretter,
+            interpreter: frontmatter.interpreter,
=            scenarios: [].into(),
=        })
=    }

Use h1 for spec title

On by Tad Lispy

index 151721e..c3c87df 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -76,13 +76,28 @@ impl TryFrom<markdown::mdast::Node> for Spec {
=        };
=        let frontmatter: FrontMatter = serde_yaml::from_str(yaml)?;
=
-        // TODO: Find h1 and use it as title (make sure there's only one)
+        // Find h1 and use it as title (
+        // TODO: make sure there's only one and it's before any h2
+        let title = children
+            .iter()
+            .filter(|element| {
+                if let markdown::mdast::Node::Heading(heading) = element
+                    && heading.depth == 1
+                {
+                    true
+                } else {
+                    false
+                }
+            })
+            .next()
+            .ok_or("There is no level 1 heading inside the given node.")?
+            .to_string();
=
=        // TODO: Split into sections, each starting at h2
=        // TODO: Convert each section into a scenario (section::try_into())
=
=        Ok(Self {
-            title: "Spec title".into(),
+            title,
=            interpreter: frontmatter.interpreter,
=            scenarios: [].into(),
=        })

Implement scenarios extraction

On by Tad Lispy

A bit of a mess, but seems to be working. Maybe I can refactor it later.

index c3c87df..992efa1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -93,13 +93,75 @@ impl TryFrom<markdown::mdast::Node> for Spec {
=            .ok_or("There is no level 1 heading inside the given node.")?
=            .to_string();
=
-        // TODO: Split into sections, each starting at h2
-        // TODO: Convert each section into a scenario (section::try_into())
+        // Extract scenarios and steps
+        // Split into sections, each starting at h2
+        // Convert each section into a scenario
+        let mut scenarios = Vec::new();
+        for node in children.iter() {
+            match node {
+                markdown::mdast::Node::Heading(heading) => {
+                    if heading.depth == 2 {
+                        scenarios.push(Scenario {
+                            title: node.to_string(),
+                            steps: [].into(),
+                        });
+                    }
+                }
+                markdown::mdast::Node::List(list) => {
+                    if let Some(scenario) = scenarios.last_mut() {
+                        let items = list.children.iter().filter_map(|child| {
+                            if let markdown::mdast::Node::ListItem(item) = child {
+                                Some(item)
+                            } else {
+                                None
+                            }
+                        });
+
+                        // TODO: Extract into something like Step::from(item)
+                        for item in items {
+                            // First child of a list item should always be a paragraph
+                            let mut heading = item
+                                .children
+                                .first()
+                                .ok_or("Encountered a list item without any children. Impossible?")?
+                                .clone();
+
+                            let mut arguments = Vec::new();
+
+                            let mut variant_heading = heading.clone();
+
+                            for child in variant_heading.children_mut().ok_or(
+                                "Encountered a list heading with an empty first child. Impossible?",
+                            )?.iter_mut() {
+                                if let markdown::mdast::Node::InlineCode(inline_code) = child {
+                                    let  index = arguments.len();
+                                    let placeholder = format!(  "{{{index}}}");
+                                    arguments.push(inline_code.value.clone());
+                                    *child = markdown::mdast::Node::Text(markdown::mdast::Text {
+                                        value: placeholder.into(),
+                                        position: inline_code.position.clone(), // Is that correct?
+                                    })
+
+                                };
+                            }
+
+                            scenario.steps.push(Step {
+                                variant: variant_heading.to_string(),
+                                arguments,
+                            });
+                        }
+                    } else {
+                        // A list before any scenario. Ignoring.
+                    }
+                }
+                _ => continue,
+            }
+        }
=
=        Ok(Self {
=            title,
+            scenarios,
=            interpreter: frontmatter.interpreter,
-            scenarios: [].into(),
=        })
=    }
=}

Setup proper logging using log and env_logger

On by Tad Lispy

Logging level can be controlled with RUST_LOG environment variable or --verbose command line flag.

index efecadd..7c0d58f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,15 @@
=# It is not intended for manual editing.
=version = 4
=
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
=[[package]]
=name = "anstream"
=version = "0.6.21"
@@ -98,6 +107,29 @@ version = "1.0.4"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
=
+[[package]]
+name = "env_filter"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
=[[package]]
=name = "equivalent"
=version = "1.0.2"
@@ -144,6 +176,36 @@ version = "1.0.15"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
=
+[[package]]
+name = "jiff"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35"
+dependencies = [
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "log"
+version = "0.4.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
+
=[[package]]
=name = "markdown"
=version = "1.0.0"
@@ -153,12 +215,33 @@ dependencies = [
= "unicode-id",
=]
=
+[[package]]
+name = "memchr"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+
=[[package]]
=name = "once_cell_polyfill"
=version = "1.70.2"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
=
+[[package]]
+name = "portable-atomic"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
+dependencies = [
+ "portable-atomic",
+]
+
=[[package]]
=name = "proc-macro2"
=version = "1.0.103"
@@ -177,6 +260,35 @@ dependencies = [
= "proc-macro2",
=]
=
+[[package]]
+name = "regex"
+version = "1.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+
=[[package]]
=name = "ryu"
=version = "1.0.20"
@@ -248,7 +360,9 @@ name = "tad-better-behavior"
=version = "0.1.0"
=dependencies = [
= "clap",
+ "env_logger",
= "glob",
+ "log",
= "markdown",
= "serde",
= "serde_yaml",
index 0511019..953c42a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,9 @@ edition = "2024"
=
=[dependencies]
=clap = { version = "4.5.51", features = ["derive"] }
+env_logger = "0.11.8"
=glob = "0.3.3"
+log = "0.4.28"
=markdown = "1.0.0"
=serde = { version = "1.0.228", features = ["serde_derive"] }
=serde_yaml = "0.9.34"
index 992efa1..eb1939c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,7 @@
=use clap::Parser;
+use env_logger;
=use glob::glob;
+use log;
=use markdown;
=use serde::Deserialize;
=use std::error::Error;
@@ -11,16 +13,23 @@ struct Cli {
=    /// A directory or a markdown file with specs to evaluate
=    #[arg(value_name = "SPEC PATH", default_value = "./spec/")]
=    input: PathBuf,
+
+    /// Enable verbose logging
+    #[arg(short, long)]
+    verbose: bool,
=}
=
=fn main() -> Result<(), Box<dyn Error>> {
=    let cli = Cli::parse();
+    let log_env = env_logger::Env::default()
+        .filter_or("RUST_LOG", if cli.verbose { "trace" } else { "info" });
+    env_logger::init_from_env(log_env);
=
-    println!("Reading specifications from {}", cli.input.display());
+    log::debug!("Reading specifications from {}", cli.input.display());
=
=    let input = cli.input.canonicalize()?;
=    if input.is_dir() {
-        println!(
+        log::debug!(
=            "The {} is a directory. Looking for markdown files...",
=            input.display()
=        );
@@ -30,7 +39,7 @@ fn main() -> Result<(), Box<dyn Error>> {
=        }
=        Ok(())
=    } else if input.is_file() {
-        println!("The {} is a file. Reading...", input.display());
+        log::debug!("The {} is a file. Reading...", input.display());
=        process_spec(input)
=    } else {
=        Err(format!("The {} is neither a file nor directory", input.display()).into())
@@ -167,7 +176,7 @@ impl TryFrom<markdown::mdast::Node> for Spec {
=}
=
=fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
-    println!("Reading {}", input.display());
+    log::debug!("Reading {}", input.display());
=    let md = std::fs::read_to_string(input)?;
=    let mdast = markdown::to_mdast(
=        &md,
@@ -180,9 +189,9 @@ fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
=        },
=    )
=    .map_err(|message| message.to_string())?;
-    println!("Content:\n\n{:#?}", mdast);
+    log::info!("Content:\n\n{:#?}", mdast);
=
=    let spec = Spec::try_from(mdast)?;
-    println!("Spec:\n\n{:#?}", spec);
+    log::info!("Spec:\n\n{:#?}", spec);
=    Ok(())
=}

Separate Spec, Scenario and Step to the spec module

On by Tad Lispy

index eb1939c..afab452 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,9 +1,10 @@
+mod spec;
+
=use clap::Parser;
=use env_logger;
=use glob::glob;
=use log;
=use markdown;
-use serde::Deserialize;
=use std::error::Error;
=use std::path::PathBuf;
=
@@ -46,135 +47,6 @@ fn main() -> Result<(), Box<dyn Error>> {
=    }
=}
=
-#[derive(Debug)]
-struct Spec {
-    title: String,
-    interpreter: String,
-    scenarios: Vec<Scenario>,
-}
-
-#[derive(Debug)]
-struct Scenario {
-    title: String,
-    steps: Vec<Step>,
-}
-
-#[derive(Debug)]
-struct Step {
-    variant: String,
-    arguments: Vec<String>,
-}
-
-#[derive(Deserialize)]
-struct FrontMatter {
-    interpreter: String,
-}
-
-impl TryFrom<markdown::mdast::Node> for Spec {
-    type Error = Box<dyn Error>;
-
-    fn try_from(mdast: markdown::mdast::Node) -> Result<Self, Self::Error> {
-        // Find the YAML front-matter and extract the interpreter field
-        let children = mdast.children().ok_or("The given node has no children.")?;
-        let first = children
-            .first()
-            .ok_or("There is no first child in the given node.")?;
-
-        let markdown::mdast::Node::Yaml(markdown::mdast::Yaml { value: yaml, .. }) = first else {
-            return Err("First child is not a YAML front matter.".into());
-        };
-        let frontmatter: FrontMatter = serde_yaml::from_str(yaml)?;
-
-        // Find h1 and use it as title (
-        // TODO: make sure there's only one and it's before any h2
-        let title = children
-            .iter()
-            .filter(|element| {
-                if let markdown::mdast::Node::Heading(heading) = element
-                    && heading.depth == 1
-                {
-                    true
-                } else {
-                    false
-                }
-            })
-            .next()
-            .ok_or("There is no level 1 heading inside the given node.")?
-            .to_string();
-
-        // Extract scenarios and steps
-        // Split into sections, each starting at h2
-        // Convert each section into a scenario
-        let mut scenarios = Vec::new();
-        for node in children.iter() {
-            match node {
-                markdown::mdast::Node::Heading(heading) => {
-                    if heading.depth == 2 {
-                        scenarios.push(Scenario {
-                            title: node.to_string(),
-                            steps: [].into(),
-                        });
-                    }
-                }
-                markdown::mdast::Node::List(list) => {
-                    if let Some(scenario) = scenarios.last_mut() {
-                        let items = list.children.iter().filter_map(|child| {
-                            if let markdown::mdast::Node::ListItem(item) = child {
-                                Some(item)
-                            } else {
-                                None
-                            }
-                        });
-
-                        // TODO: Extract into something like Step::from(item)
-                        for item in items {
-                            // First child of a list item should always be a paragraph
-                            let mut heading = item
-                                .children
-                                .first()
-                                .ok_or("Encountered a list item without any children. Impossible?")?
-                                .clone();
-
-                            let mut arguments = Vec::new();
-
-                            let mut variant_heading = heading.clone();
-
-                            for child in variant_heading.children_mut().ok_or(
-                                "Encountered a list heading with an empty first child. Impossible?",
-                            )?.iter_mut() {
-                                if let markdown::mdast::Node::InlineCode(inline_code) = child {
-                                    let  index = arguments.len();
-                                    let placeholder = format!(  "{{{index}}}");
-                                    arguments.push(inline_code.value.clone());
-                                    *child = markdown::mdast::Node::Text(markdown::mdast::Text {
-                                        value: placeholder.into(),
-                                        position: inline_code.position.clone(), // Is that correct?
-                                    })
-
-                                };
-                            }
-
-                            scenario.steps.push(Step {
-                                variant: variant_heading.to_string(),
-                                arguments,
-                            });
-                        }
-                    } else {
-                        // A list before any scenario. Ignoring.
-                    }
-                }
-                _ => continue,
-            }
-        }
-
-        Ok(Self {
-            title,
-            scenarios,
-            interpreter: frontmatter.interpreter,
-        })
-    }
-}
-
=fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
=    log::debug!("Reading {}", input.display());
=    let md = std::fs::read_to_string(input)?;
@@ -191,7 +63,7 @@ fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
=    .map_err(|message| message.to_string())?;
=    log::info!("Content:\n\n{:#?}", mdast);
=
-    let spec = Spec::try_from(mdast)?;
+    let spec = spec::Spec::try_from(mdast)?;
=    log::info!("Spec:\n\n{:#?}", spec);
=    Ok(())
=}
new file mode 100644
index 0000000..541f4a7
--- /dev/null
+++ b/src/spec.rs
@@ -0,0 +1,131 @@
+use serde::Deserialize;
+use std::error::Error;
+
+#[derive(Debug)]
+pub struct Spec {
+    pub title: String,
+    pub interpreter: String,
+    pub scenarios: Vec<Scenario>,
+}
+
+#[derive(Debug)]
+pub struct Scenario {
+    pub title: String,
+    pub steps: Vec<Step>,
+}
+
+#[derive(Debug)]
+pub struct Step {
+    pub variant: String,
+    pub arguments: Vec<String>,
+}
+
+#[derive(Deserialize)]
+pub struct FrontMatter {
+    pub interpreter: String,
+}
+
+impl TryFrom<markdown::mdast::Node> for Spec {
+    type Error = Box<dyn Error>;
+
+    fn try_from(mdast: markdown::mdast::Node) -> Result<Self, Self::Error> {
+        // Find the YAML front-matter and extract the interpreter field
+        let children = mdast.children().ok_or("The given node has no children.")?;
+        let first = children
+            .first()
+            .ok_or("There is no first child in the given node.")?;
+
+        let markdown::mdast::Node::Yaml(markdown::mdast::Yaml { value: yaml, .. }) = first else {
+            return Err("First child is not a YAML front matter.".into());
+        };
+        let frontmatter: FrontMatter = serde_yaml::from_str(yaml)?;
+
+        // Find h1 and use it as title (
+        // TODO: make sure there's only one and it's before any h2
+        let title = children
+            .iter()
+            .filter(|element| {
+                if let markdown::mdast::Node::Heading(heading) = element
+                    && heading.depth == 1
+                {
+                    true
+                } else {
+                    false
+                }
+            })
+            .next()
+            .ok_or("There is no level 1 heading inside the given node.")?
+            .to_string();
+
+        // Extract scenarios and steps
+        // Split into sections, each starting at h2
+        // Convert each section into a scenario
+        let mut scenarios = Vec::new();
+        for node in children.iter() {
+            match node {
+                markdown::mdast::Node::Heading(heading) => {
+                    if heading.depth == 2 {
+                        scenarios.push(Scenario {
+                            title: node.to_string(),
+                            steps: [].into(),
+                        });
+                    }
+                }
+                markdown::mdast::Node::List(list) => {
+                    if let Some(scenario) = scenarios.last_mut() {
+                        let items = list.children.iter().filter_map(|child| {
+                            if let markdown::mdast::Node::ListItem(item) = child {
+                                Some(item)
+                            } else {
+                                None
+                            }
+                        });
+
+                        // TODO: Extract into something like Step::from(item)
+                        for item in items {
+                            // First child of a list item should always be a paragraph
+                            let mut heading = item
+                                .children
+                                .first()
+                                .ok_or("Encountered a list item without any children. Impossible?")?
+                                .clone();
+
+                            let mut arguments = Vec::new();
+
+                            let mut variant_heading = heading.clone();
+
+                            for child in variant_heading.children_mut().ok_or(
+                                "Encountered a list heading with an empty first child. Impossible?",
+                            )?.iter_mut() {
+                                if let markdown::mdast::Node::InlineCode(inline_code) = child {
+                                    let  index = arguments.len();
+                                    let placeholder = format!(  "{{{index}}}");
+                                    arguments.push(inline_code.value.clone());
+                                    *child = markdown::mdast::Node::Text(markdown::mdast::Text {
+                                        value: placeholder.into(),
+                                        position: inline_code.position.clone(), // Is that correct?
+                                    })
+
+                                };
+                            }
+
+                            scenario.steps.push(Step {
+                                variant: variant_heading.to_string(),
+                                arguments,
+                            });
+                        }
+                    } else {
+                        // A list before any scenario. Ignoring.
+                    }
+                }
+                _ => continue,
+            }
+        }
+
+        Ok(Self {
+            title,
+            scenarios,
+            interpreter: frontmatter.interpreter,
+        })
+    }
+}

Separate parsing markdown to Spec module

On by Tad Lispy

index afab452..e83340d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,7 +4,6 @@ use clap::Parser;
=use env_logger;
=use glob::glob;
=use log;
-use markdown;
=use std::error::Error;
=use std::path::PathBuf;
=
@@ -50,20 +49,7 @@ fn main() -> Result<(), Box<dyn Error>> {
=fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
=    log::debug!("Reading {}", input.display());
=    let md = std::fs::read_to_string(input)?;
-    let mdast = markdown::to_mdast(
-        &md,
-        &markdown::ParseOptions {
-            constructs: markdown::Constructs {
-                frontmatter: true,
-                ..Default::default()
-            },
-            ..Default::default()
-        },
-    )
-    .map_err(|message| message.to_string())?;
-    log::info!("Content:\n\n{:#?}", mdast);
-
-    let spec = spec::Spec::try_from(mdast)?;
+    let spec = spec::Spec::from_markdown(&md);
=    log::info!("Spec:\n\n{:#?}", spec);
=    Ok(())
=}
index 541f4a7..15a58c0 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -25,6 +25,25 @@ pub struct FrontMatter {
=    pub interpreter: String,
=}
=
+impl Spec {
+    pub fn from_markdown(md: &str) -> Result<Self, Box<dyn Error>> {
+        let mdast = markdown::to_mdast(
+            md,
+            &markdown::ParseOptions {
+                constructs: markdown::Constructs {
+                    frontmatter: true,
+                    ..Default::default()
+                },
+                ..Default::default()
+            },
+        )
+        .map_err(|message| message.to_string())?;
+        log::info!("Markdown parsed:\n\n{:#?}", mdast);
+
+        Self::try_from(mdast)
+    }
+}
+
=impl TryFrom<markdown::mdast::Node> for Spec {
=    type Error = Box<dyn Error>;
=

Refactor step extraction

On by Tad Lispy

Implement Step::try_from(list_item).

index 15a58c0..ac468ce 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -100,38 +100,11 @@ impl TryFrom<markdown::mdast::Node> for Spec {
=                            }
=                        });
=
-                        // TODO: Extract into something like Step::from(item)
=                        for item in items {
=                            // First child of a list item should always be a paragraph
-                            let mut heading = item
-                                .children
-                                .first()
-                                .ok_or("Encountered a list item without any children. Impossible?")?
-                                .clone();
-
-                            let mut arguments = Vec::new();
-
-                            let mut variant_heading = heading.clone();
-
-                            for child in variant_heading.children_mut().ok_or(
-                                "Encountered a list heading with an empty first child. Impossible?",
-                            )?.iter_mut() {
-                                if let markdown::mdast::Node::InlineCode(inline_code) = child {
-                                    let  index = arguments.len();
-                                    let placeholder = format!(  "{{{index}}}");
-                                    arguments.push(inline_code.value.clone());
-                                    *child = markdown::mdast::Node::Text(markdown::mdast::Text {
-                                        value: placeholder.into(),
-                                        position: inline_code.position.clone(), // Is that correct?
-                                    })
-
-                                };
-                            }
+                            let step = Step::try_from(item)?;
=
-                            scenario.steps.push(Step {
-                                variant: variant_heading.to_string(),
-                                arguments,
-                            });
+                            scenario.steps.push(step);
=                        }
=                    } else {
=                        // A list before any scenario. Ignoring.
@@ -148,3 +121,39 @@ impl TryFrom<markdown::mdast::Node> for Spec {
=        })
=    }
=}
+
+impl TryFrom<&markdown::mdast::ListItem> for Step {
+    type Error = Box<dyn Error>;
+
+    fn try_from(item: &markdown::mdast::ListItem) -> Result<Self, Self::Error> {
+        let heading = item
+            .children
+            .first()
+            .ok_or("Encountered a list item without any children. Impossible?")?
+            .clone();
+        let mut arguments = Vec::new();
+        let mut variant_heading = heading.clone();
+        for child in variant_heading
+            .children_mut()
+            .ok_or("Encountered a list heading with an empty first child. Impossible?")?
+            .iter_mut()
+        {
+            if let markdown::mdast::Node::InlineCode(inline_code) = child {
+                let index = arguments.len();
+                let placeholder = format!("{{{index}}}");
+                arguments.push(inline_code.value.clone());
+                *child = markdown::mdast::Node::Text(markdown::mdast::Text {
+                    value: placeholder.into(),
+                    position: inline_code.position.clone(), // Is that correct?
+                })
+            };
+        }
+
+        // TODO: Extract list, tables, code blocks etc. from subsequent children of the list item
+
+        Ok(Self {
+            variant: variant_heading.to_string(),
+            arguments,
+        })
+    }
+}

Stub the code for spec evaluation

On by Tad Lispy

For now it only prints what its going to do, but doesn't really evaluate anything.

index e83340d..e69be07 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -49,7 +49,8 @@ fn main() -> Result<(), Box<dyn Error>> {
=fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
=    log::debug!("Reading {}", input.display());
=    let md = std::fs::read_to_string(input)?;
-    let spec = spec::Spec::from_markdown(&md);
+    let spec = spec::Spec::from_markdown(&md)?;
=    log::info!("Spec:\n\n{:#?}", spec);
-    Ok(())
+
+    spec.evaluate()
=}
index ac468ce..ac78cd5 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -14,9 +14,30 @@ pub struct Scenario {
=    pub steps: Vec<Step>,
=}
=
+impl Scenario {
+    fn run(&self, interpreter: &str) -> Result<(), Box<dyn Error>> {
+        println!("\n  {}", self.title);
+        // TODO: start the interpreter program and tap into its stdio
+        for step in self.steps.iter() {
+            println!("    {}", step.description);
+            // TODO: Serialize self and send it to interpreter
+            // TODO: Await output line from the interpreter
+            // TODO: If output is wrong - print the error and return it
+        }
+
+        Ok(())
+    }
+}
+
=#[derive(Debug)]
=pub struct Step {
+    /// The headline (without formatting)
+    pub description: String,
+
+    /// The headline with arguments replaced with index placeholders
=    pub variant: String,
+
+    /// Values of the arguments
=    pub arguments: Vec<String>,
=}
=
@@ -42,6 +63,15 @@ impl Spec {
=
=        Self::try_from(mdast)
=    }
+
+    pub fn evaluate(&self) -> Result<(), Box<dyn Error>> {
+        println!("\n{}", self.title);
+
+        for scenario in self.scenarios.iter() {
+            scenario.run(&self.interpreter)?;
+        }
+        Ok(())
+    }
=}
=
=impl TryFrom<markdown::mdast::Node> for Spec {
@@ -126,14 +156,14 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=    type Error = Box<dyn Error>;
=
=    fn try_from(item: &markdown::mdast::ListItem) -> Result<Self, Self::Error> {
-        let heading = item
+        let headline = item
=            .children
=            .first()
=            .ok_or("Encountered a list item without any children. Impossible?")?
=            .clone();
=        let mut arguments = Vec::new();
-        let mut variant_heading = heading.clone();
-        for child in variant_heading
+        let mut variant_headline = headline.clone();
+        for child in variant_headline
=            .children_mut()
=            .ok_or("Encountered a list heading with an empty first child. Impossible?")?
=            .iter_mut()
@@ -152,7 +182,8 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=        // TODO: Extract list, tables, code blocks etc. from subsequent children of the list item
=
=        Ok(Self {
-            variant: variant_heading.to_string(),
+            description: headline.to_string(),
+            variant: variant_headline.to_string(),
=            arguments,
=        })
=    }

Clarify some naming

On by Tad Lispy

Spec(ification), Suites, Scenarios. Rename stuff and write comments explaining the terminology.

index e69be07..5ab9676 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,7 +10,7 @@ use std::path::PathBuf;
=#[derive(Parser)]
=#[command(version, about, long_about=None)]
=struct Cli {
-    /// A directory or a markdown file with specs to evaluate
+    /// A directory or a markdown file with the spec to evaluate
=    #[arg(value_name = "SPEC PATH", default_value = "./spec/")]
=    input: PathBuf,
=
@@ -25,7 +25,7 @@ fn main() -> Result<(), Box<dyn Error>> {
=        .filter_or("RUST_LOG", if cli.verbose { "trace" } else { "info" });
=    env_logger::init_from_env(log_env);
=
-    log::debug!("Reading specifications from {}", cli.input.display());
+    log::debug!("Reading the specification from {}", cli.input.display());
=
=    let input = cli.input.canonicalize()?;
=    if input.is_dir() {
@@ -35,22 +35,22 @@ fn main() -> Result<(), Box<dyn Error>> {
=        );
=        let pattern = format!("{}/**/*.md", input.display());
=        for path in glob(&pattern)? {
-            process_spec(path?)?;
+            process_document(path?)?;
=        }
=        Ok(())
=    } else if input.is_file() {
=        log::debug!("The {} is a file. Reading...", input.display());
-        process_spec(input)
+        process_document(input)
=    } else {
=        Err(format!("The {} is neither a file nor directory", input.display()).into())
=    }
=}
=
-fn process_spec(input: PathBuf) -> Result<(), Box<dyn Error>> {
+fn process_document(input: PathBuf) -> Result<(), Box<dyn Error>> {
=    log::debug!("Reading {}", input.display());
=    let md = std::fs::read_to_string(input)?;
-    let spec = spec::Spec::from_markdown(&md)?;
-    log::info!("Spec:\n\n{:#?}", spec);
+    let suite = spec::Suite::from_markdown(&md)?;
+    log::info!("Suite:\n\n{:#?}", suite);
=
-    spec.evaluate()
+    suite.evaluate()
=}
index ac78cd5..0f1a7de 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -1,13 +1,29 @@
=use serde::Deserialize;
=use std::error::Error;
=
+/// Suite is a collection of scenarios that share a common title and interpreter
+///
+/// Other BDD systems often call it "a spec" but in my mind it doesn't make
+/// sense to talk about multiple specifications of a system (unless they are
+/// alternatives under consideration). So I use the term "spec" to refer to all
+/// the requirements of a system.
+///
+/// From a markdown perspective, a single document can contain multiple suites,
+/// demarcated by an h1 heading. In such a case all those suites use the same
+/// interpreter, since there can be only one front-matter per document.
=#[derive(Debug)]
-pub struct Spec {
+pub struct Suite {
=    pub title: String,
=    pub interpreter: String,
=    pub scenarios: Vec<Scenario>,
=}
=
+/// Scenario is set of steps.
+///
+/// When running a scenario, the interpreter defined in its suite will be
+/// spawned and fed steps.  Scenarios can be stateful, i.e. running a step can
+/// affect subsequent steps. It's up to the interpreter to implement state
+/// management.
=#[derive(Debug)]
=pub struct Scenario {
=    pub title: String,
@@ -46,7 +62,7 @@ pub struct FrontMatter {
=    pub interpreter: String,
=}
=
-impl Spec {
+impl Suite {
=    pub fn from_markdown(md: &str) -> Result<Self, Box<dyn Error>> {
=        let mdast = markdown::to_mdast(
=            md,
@@ -74,7 +90,7 @@ impl Spec {
=    }
=}
=
-impl TryFrom<markdown::mdast::Node> for Spec {
+impl TryFrom<markdown::mdast::Node> for Suite {
=    type Error = Box<dyn Error>;
=
=    fn try_from(mdast: markdown::mdast::Node) -> Result<Self, Self::Error> {

Stub tbb <-> interpreter pipes

On by Tad Lispy

The control program (tbb) will spawn a given interpreter as a child process and connect ot it's stdin and stdout. Currently the expected messages are:

interpreter > { "ready": true } interpreter < { "variant": "...", "description": "...", "arguments": [...] } interpreter > { "ok": true } interpreter < { "variant": "...", "description": "...", "arguments": [...] } interpreter > { "ok": true } interpreter < EOF

They will likely get more elaborate.

There is also a stubbed interpreter written in Python at samples/basic.py . For now it always responds with ok message. Python development tools are added to the shell.

index 7c0d58f..8f4f3fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -325,6 +325,19 @@ dependencies = [
= "syn",
=]
=
+[[package]]
+name = "serde_json"
+version = "1.0.145"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+ "serde_core",
+]
+
=[[package]]
=name = "serde_yaml"
=version = "0.9.34+deprecated"
@@ -365,6 +378,7 @@ dependencies = [
= "log",
= "markdown",
= "serde",
+ "serde_json",
= "serde_yaml",
=]
=
index 953c42a..e75e559 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ glob = "0.3.3"
=log = "0.4.28"
=markdown = "1.0.0"
=serde = { version = "1.0.228", features = ["serde_derive"] }
+serde_json = "1.0.145"
=serde_yaml = "0.9.34"
=
=[[bin]]
index 0560c1e..b93a9d3 100644
--- a/flake.nix
+++ b/flake.nix
@@ -29,7 +29,13 @@
=                  # https://devenv.sh/reference/options/
=
=                  languages.rust.enable = true;
-                  packages = [];
+                  languages.python.enable = true;
+
+                  packages = [
+                    pkgs.python3Packages.python-lsp-server
+                    pkgs.python3Packages.python-lsp-ruff
+                    pkgs.python3Packages.pylsp-rope
+                  ];
=                }
=              ];
=            };
new file mode 100644
index 0000000..2e9093f
--- /dev/null
+++ b/samples/basic.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+
+import json
+import sys
+import fileinput
+
+def send(message):
+    serialized = json.dumps(message)
+    print("Interpreter sending:", serialized, file=sys.stderr)
+    print (serialized)
+
+# Send the ready message
+send({ 'ready': True })
+
+# Loop over input
+while True:
+    print("Interpreter awaiting message:", file=sys.stderr)
+
+    try:
+        received = input()
+        print("Interpreter received:", received, file=sys.stderr)
+        payload = json.loads(received)
+        send({ 'ok': True })
+    except EOFError:
+        # The control program closed the stream.
+        # Most likely it indicates the end of scenario.
+        break
+
index 0f1a7de..a482a02 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -1,5 +1,7 @@
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
=use std::error::Error;
+use std::io::{BufRead, BufReader, LineWriter, Write};
+use std::process::{Command, Stdio};
=
=/// Suite is a collection of scenarios that share a common title and interpreter
=///
@@ -34,18 +36,96 @@ impl Scenario {
=    fn run(&self, interpreter: &str) -> Result<(), Box<dyn Error>> {
=        println!("\n  {}", self.title);
=        // TODO: start the interpreter program and tap into its stdio
+        log::debug!("Starting the interpreter process: `{interpreter}`");
+        let mut process = Command::new("sh")
+            .arg("-c")
+            .arg(interpreter)
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .spawn()?;
+
+        let input = process
+            .stdin
+            .take()
+            .ok_or("Failed to take the stdin stream of the interpreter process.")?;
+
+        let output = process
+            .stdout
+            .take()
+            .ok_or("Failed to take the stdout stream of the interpreter process.")?;
+
+        let mut reader = BufReader::new(output);
+        let mut writer = LineWriter::new(input);
+
+        loop {
+            let received = read_line(&mut reader)?;
+            let interpreter_state: InterpreterState = serde_json::from_str(&received)?;
+            log::debug!("Interpreter state: {interpreter_state:?}");
+            if interpreter_state.ready {
+                break;
+            };
+        }
+
=        for step in self.steps.iter() {
=            println!("    {}", step.description);
-            // TODO: Serialize self and send it to interpreter
-            // TODO: Await output line from the interpreter
-            // TODO: If output is wrong - print the error and return it
+            let json = serde_json::to_string(step)?;
+            write_line(&mut writer, &json)?;
+            let response = read_line(&mut reader)?;
+            let step_result: StepResult = serde_json::from_str(&response)?;
+            log::debug!("Received: {:?}", step_result);
+            if !step_result.ok {
+                return Err(format!("Step {step:?} failed").into());
+            }
=        }
=
-        Ok(())
+        drop(writer);
+
+        process
+            .wait()
+            .map_err(|io_error| Box::new(io_error).into())  // No idea why it's needed.
+            .and_then(|exit_status| {
+                log::debug!("Interpreter closed with exit status {exit_status:?}");
+                let exit_code = exit_status.code().unwrap_or(0); // Is that right?
+                if exit_code == 0 {
+                    Ok(())
+                } else {
+                    let message = format!("Interpreter process {interpreter} exited abnormally. Exit code: {exit_code}");
+                    Err(message.into())
+                }
+            })
=    }
=}
=
-#[derive(Debug)]
+#[derive(Deserialize, Debug)]
+struct StepResult {
+    ok: bool,
+}
+
+#[derive(Deserialize, Debug)]
+struct InterpreterState {
+    ready: bool,
+}
+
+fn write_line(
+    writer: &mut LineWriter<std::process::ChildStdin>,
+    line: &str,
+) -> Result<(), Box<dyn Error>> {
+    let buffer = [line, "\n"].concat();
+
+    log::debug!("Sending: {}", buffer);
+    writer.write_all(buffer.as_bytes())?;
+    Ok(())
+}
+
+fn read_line(reader: &mut BufReader<std::process::ChildStdout>) -> Result<String, Box<dyn Error>> {
+    let mut buffer = String::new();
+    if reader.read_line(&mut buffer)? == 0 {
+        return Err("The interpreter process closed its output stream too early.".into());
+    };
+    Ok(buffer)
+}
+
+#[derive(Debug, Serialize)]
=pub struct Step {
=    /// The headline (without formatting)
=    pub description: String,
@@ -86,6 +166,8 @@ impl Suite {
=        for scenario in self.scenarios.iter() {
=            scenario.run(&self.interpreter)?;
=        }
+
+        log::info!("All good!");
=        Ok(())
=    }
=}

Enable proper logging in the interpreter

On by Tad Lispy

Now the interpreter will be launched with an environment variable tbb_interpreter_log, containing a lowercase log level (trace, debug, info, warn, error, critical). Interpreters should setup their logging accordingly.

The control program (tbb) will set this variable to either "trace" (if --verbose flag was passed) or "warn" otherwise.

The sample basic interpreter is now using Python logging module to log diagnostic information. Since Python logging doesn't have the trace level, it needs to be coerced to debug.

index 2e9093f..4c67d2d 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -1,12 +1,18 @@
=#!/usr/bin/env python3
=
=import json
-import sys
-import fileinput
+import logging
+import os
+
+
+# Setup logging
+log_level = os.environ.get("tbb_interpreter_log", "warn").upper()
+logging.basicConfig(level=getattr(logging, log_level))
+log = logging.getLogger("basic_interpreter")
=
=def send(message):
=    serialized = json.dumps(message)
-    print("Interpreter sending:", serialized, file=sys.stderr)
+    log.debug("Sending: %s", serialized)
=    print (serialized)
=
=# Send the ready message
@@ -14,11 +20,11 @@ send({ 'ready': True })
=
=# Loop over input
=while True:
-    print("Interpreter awaiting message:", file=sys.stderr)
+    log.debug("Awaiting message...")
=
=    try:
=        received = input()
-        print("Interpreter received:", received, file=sys.stderr)
+        log.debug("Received: %s", received)
=        payload = json.loads(received)
=        send({ 'ok': True })
=    except EOFError:
index 5ab9676..38737c7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,7 +22,7 @@ struct Cli {
=fn main() -> Result<(), Box<dyn Error>> {
=    let cli = Cli::parse();
=    let log_env = env_logger::Env::default()
-        .filter_or("RUST_LOG", if cli.verbose { "trace" } else { "info" });
+        .filter_or("RUST_LOG", if cli.verbose { "trace" } else { "warn" });
=    env_logger::init_from_env(log_env);
=
=    log::debug!("Reading the specification from {}", cli.input.display());
@@ -35,22 +35,22 @@ fn main() -> Result<(), Box<dyn Error>> {
=        );
=        let pattern = format!("{}/**/*.md", input.display());
=        for path in glob(&pattern)? {
-            process_document(path?)?;
+            process_document(path?, cli.verbose)?;
=        }
=        Ok(())
=    } else if input.is_file() {
=        log::debug!("The {} is a file. Reading...", input.display());
-        process_document(input)
+        process_document(input, cli.verbose)
=    } else {
=        Err(format!("The {} is neither a file nor directory", input.display()).into())
=    }
=}
=
-fn process_document(input: PathBuf) -> Result<(), Box<dyn Error>> {
+fn process_document(input: PathBuf, verbose: bool) -> Result<(), Box<dyn Error>> {
=    log::debug!("Reading {}", input.display());
=    let md = std::fs::read_to_string(input)?;
=    let suite = spec::Suite::from_markdown(&md)?;
-    log::info!("Suite:\n\n{:#?}", suite);
+    log::debug!("Suite:\n\n{:#?}", suite);
=
-    suite.evaluate()
+    suite.evaluate(verbose)
=}
index a482a02..9d2aaf8 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -33,7 +33,7 @@ pub struct Scenario {
=}
=
=impl Scenario {
-    fn run(&self, interpreter: &str) -> Result<(), Box<dyn Error>> {
+    fn run(&self, interpreter: &str, verbose: bool) -> Result<(), Box<dyn Error>> {
=        println!("\n  {}", self.title);
=        // TODO: start the interpreter program and tap into its stdio
=        log::debug!("Starting the interpreter process: `{interpreter}`");
@@ -42,6 +42,10 @@ impl Scenario {
=            .arg(interpreter)
=            .stdin(Stdio::piped())
=            .stdout(Stdio::piped())
+            .env(
+                "tbb_interpreter_log",
+                if verbose { "debug" } else { "warn" },
+            )
=            .spawn()?;
=
=        let input = process
@@ -155,16 +159,16 @@ impl Suite {
=            },
=        )
=        .map_err(|message| message.to_string())?;
-        log::info!("Markdown parsed:\n\n{:#?}", mdast);
+        log::debug!("Markdown parsed:\n\n{:#?}", mdast);
=
=        Self::try_from(mdast)
=    }
=
-    pub fn evaluate(&self) -> Result<(), Box<dyn Error>> {
+    pub fn evaluate(&self, verbose: bool) -> Result<(), Box<dyn Error>> {
=        println!("\n{}", self.title);
=
=        for scenario in self.scenarios.iter() {
-            scenario.run(&self.interpreter)?;
+            scenario.run(&self.interpreter, verbose)?;
=        }
=
=        log::info!("All good!");

Implement two steps from the arithmetic scenario

On by Tad Lispy

Just to make sure it works before I refactor. Also use a fraction in the division step, to check how it will go.

index 95a7704..c36e714 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -15,8 +15,8 @@ For each scenario the **interpreter** program (see the front-matter above) will
=
=Content outside of bullet points (like this) won't have any effect on the program. You can user it as a humane description of the scenario, or for any other purpose.
=
-  * Add `7` and `5` to get `12`.
-  * Divide `8` by `4` to get `2`
+  * Add `7` and `5` to get `12`
+  * Divide `10` by `4` to get `2.5`
=  * Subtract `7` from `5` to get `-2`
=
=
index 4c67d2d..0f49171 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -3,6 +3,7 @@
=import json
=import logging
=import os
+from typing import Any, Callable
=
=
=# Setup logging
@@ -15,20 +16,64 @@ def send(message):
=    log.debug("Sending: %s", serialized)
=    print (serialized)
=
-# Send the ready message
-send({ 'ready': True })
=
-# Loop over input
-while True:
-    log.debug("Awaiting message...")
+steps_implementation = dict()
+def register_step(variant: str, implementation: Callable[..., Any]):
+    log.debug("Registering step implementation for '%s'", variant)
+    steps_implementation[variant] = implementation
=
-    try:
-        received = input()
-        log.debug("Received: %s", received)
-        payload = json.loads(received)
-        send({ 'ok': True })
-    except EOFError:
-        # The control program closed the stream.
-        # Most likely it indicates the end of scenario.
-        break
+def ready():
+    send({ 'ready': True })
=
+    # Loop over input
+    while True:
+        log.debug("Awaiting message...")
+
+        try:
+            received = input()
+            log.debug("Received: %s", received)
+            step = json.loads(received)
+            variant = step.get("variant")
+            arguments = step.get("arguments")
+            log.debug(f"Looking for implementation of '{ variant }'")
+            implementation = steps_implementation.get(variant)
+
+            if implementation:
+                log.debug(f"Found an implementation of '{ variant }'")
+                implementation(*arguments, **step)
+                send({ 'ok': True })
+            else:
+                log.warning(f"Not implemented: {variant}")
+                send({ 'ok': False })
+
+        except EOFError:
+            # The control program closed the stream.
+            # Most likely it indicates the end of scenario.
+            break
+
+
+def add_and_verify(a, b, expected, **kwargs):
+    log.info(f"{ a } + { b } = { expected }?")
+
+    # TODO: Can we do this conversion automatically based on typing information?
+    a = float(a)
+    b = float(b)
+    expected = float(expected)
+    
+    assert a + b == expected, f"{ a } + { b } = { a + b }, not { expected }!"
+
+register_step("Add {0} and {1} to get {2}", add_and_verify)
+
+
+def divide_and_verify(a, b, expected, **kwargs):
+    log.info(f"{ a } / { b } = { expected }?")
+
+    # TODO: Can we do this conversion automatically based on typing information?
+    a = float(a)
+    b = float(b)
+    expected = float(expected)
+    
+    assert a / b == expected, f"{ a } / { b } = { a / b }, not { expected }!"
+
+register_step("Divide {0} by {1} to get {2}", divide_and_verify)
+ready()

Make a decorator for steps in basic interpreter

On by Tad Lispy

Also demarcate code that should eventually go to a re-usable Python library and interprete-specific (user) code.

index 0f49171..ecd880a 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -5,6 +5,8 @@ import logging
=import os
=from typing import Any, Callable
=
+# Library code
+# ============
=
=# Setup logging
=log_level = os.environ.get("tbb_interpreter_log", "warn").upper()
@@ -16,12 +18,25 @@ def send(message):
=    log.debug("Sending: %s", serialized)
=    print (serialized)
=
-
+# This will hold all step implementations
=steps_implementation = dict()
+
+# A helper to register a step implementation
=def register_step(variant: str, implementation: Callable[..., Any]):
=    log.debug("Registering step implementation for '%s'", variant)
=    steps_implementation[variant] = implementation
=
+# A decorator to register a step implementation
+def step(variant: str) -> Callable[..., Any]:
+    def decorator(implementation: Callable[..., Any]) -> Callable[..., Any]:
+        register_step(variant, implementation)
+        
+        # We don't really do anything to the implementation itself
+        return implementation
+
+    return decorator
+
+# Call this when ready, i.e. all implementations are registered
=def ready():
=    send({ 'ready': True })
=
@@ -52,6 +67,10 @@ def ready():
=            break
=
=
+# User code
+# =========
+        
+@step("Add {0} and {1} to get {2}")
=def add_and_verify(a, b, expected, **kwargs):
=    log.info(f"{ a } + { b } = { expected }?")
=
@@ -62,9 +81,8 @@ def add_and_verify(a, b, expected, **kwargs):
=    
=    assert a + b == expected, f"{ a } + { b } = { a + b }, not { expected }!"
=
-register_step("Add {0} and {1} to get {2}", add_and_verify)
-
=
+@step("Divide {0} by {1} to get {2}")
=def divide_and_verify(a, b, expected, **kwargs):
=    log.info(f"{ a } / { b } = { expected }?")
=
@@ -75,5 +93,4 @@ def divide_and_verify(a, b, expected, **kwargs):
=    
=    assert a / b == expected, f"{ a } / { b } = { a / b }, not { expected }!"
=
-register_step("Divide {0} by {1} to get {2}", divide_and_verify)
=ready()

Separate reading spec from its evaluation

On by Tad Lispy

For this end a new Spec struct was introduced. It's a wrapper for a list of suites, with the evaluate method.

new file mode 100644
index 0000000..e7c23a9
--- /dev/null
+++ b/samples/second.md
@@ -0,0 +1,14 @@
+---
+interpreter: "invalid interpreter"
+---
+
+A second document, just to test if it will be injested correctly.
+
+#  Suite 1 from a Second Document
+
+It should be possible to define multiple suites in each document.
+
+## Scenario 1.1
+
+  * Do something `impactful`
+  * Asses the results: `A`, `B` and `C`
index 38737c7..686906f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@ use clap::Parser;
=use env_logger;
=use glob::glob;
=use log;
+use spec::{Spec, Suite};
=use std::error::Error;
=use std::path::PathBuf;
=
@@ -28,29 +29,38 @@ fn main() -> Result<(), Box<dyn Error>> {
=    log::debug!("Reading the specification from {}", cli.input.display());
=
=    let input = cli.input.canonicalize()?;
+    let mut spec = Spec::default();
+
=    if input.is_dir() {
=        log::debug!(
=            "The {} is a directory. Looking for markdown files...",
=            input.display()
=        );
=        let pattern = format!("{}/**/*.md", input.display());
+
=        for path in glob(&pattern)? {
-            process_document(path?, cli.verbose)?;
+            spec.suites.append(&mut read_document(path?)?);
=        }
-        Ok(())
=    } else if input.is_file() {
=        log::debug!("The {} is a file. Reading...", input.display());
-        process_document(input, cli.verbose)
+        spec.suites.append(&mut read_document(input)?);
=    } else {
-        Err(format!("The {} is neither a file nor directory", input.display()).into())
-    }
+        return Err(format!("The {} is neither a file nor directory", input.display()).into());
+    };
+
+    log::info!("Collected {} suites: {:#?}", spec.suites.len(), spec);
+
+    spec.evaluate(cli.verbose);
+
+    Ok(())
=}
=
-fn process_document(input: PathBuf, verbose: bool) -> Result<(), Box<dyn Error>> {
+fn read_document(input: PathBuf) -> Result<Vec<Suite>, Box<dyn Error>> {
=    log::debug!("Reading {}", input.display());
=    let md = std::fs::read_to_string(input)?;
+
+    // TODO: Return multiple suits from a single document
=    let suite = spec::Suite::from_markdown(&md)?;
-    log::debug!("Suite:\n\n{:#?}", suite);
=
-    suite.evaluate(verbose)
+    Ok(vec![suite])
=}
index 9d2aaf8..4ac72e1 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -3,6 +3,23 @@ use std::error::Error;
=use std::io::{BufRead, BufReader, LineWriter, Write};
=use std::process::{Command, Stdio};
=
+#[derive(Debug, Default)]
+pub struct Spec {
+    pub suites: Vec<Suite>,
+}
+
+impl Spec {
+    pub fn evaluate(&self, verbose: bool) -> Result<(), Box<dyn Error>> {
+        for suite in self.suites.iter() {
+            suite.evaluate(verbose)?;
+        }
+
+        // TODO: Return an EvaluationReport
+        log::info!("Done!");
+        Ok(())
+    }
+}
+
=/// Suite is a collection of scenarios that share a common title and interpreter
=///
=/// Other BDD systems often call it "a spec" but in my mind it doesn't make

Move spec evaluation from main to Spec::evaluate

On by Tad Lispy

It follows the convention of Suite::evaluate, Scenario::run etc.

Although I'm not sure if it's the final place of this code. It would probably be cleaner to separate evaluation from the spec itself, so it could be easier to port to different platforms. For example ability to run TBB in a web browser would be sweet, but of course the execution model would be very different.

index e7c23a9..8f001a4 100644
--- a/samples/second.md
+++ b/samples/second.md
@@ -4,10 +4,12 @@ interpreter: "invalid interpreter"
=
=A second document, just to test if it will be injested correctly.
=
+
=#  Suite 1 from a Second Document
=
=It should be possible to define multiple suites in each document.
=
+
=## Scenario 1.1
=
=  * Do something `impactful`
index 686906f..362aff8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,7 +4,7 @@ use clap::Parser;
=use env_logger;
=use glob::glob;
=use log;
-use spec::{Spec, Suite};
+use spec::Spec;
=use std::error::Error;
=use std::path::PathBuf;
=
@@ -39,28 +39,18 @@ fn main() -> Result<(), Box<dyn Error>> {
=        let pattern = format!("{}/**/*.md", input.display());
=
=        for path in glob(&pattern)? {
-            spec.suites.append(&mut read_document(path?)?);
+            let md = std::fs::read_to_string(path?)?;
+            spec.load_document(&md)?;
=        }
=    } else if input.is_file() {
=        log::debug!("The {} is a file. Reading...", input.display());
-        spec.suites.append(&mut read_document(input)?);
+        let md = std::fs::read_to_string(input)?;
+        spec.load_document(&md)?;
=    } else {
=        return Err(format!("The {} is neither a file nor directory", input.display()).into());
=    };
=
=    log::info!("Collected {} suites: {:#?}", spec.suites.len(), spec);
=
-    spec.evaluate(cli.verbose);
-
-    Ok(())
-}
-
-fn read_document(input: PathBuf) -> Result<Vec<Suite>, Box<dyn Error>> {
-    log::debug!("Reading {}", input.display());
-    let md = std::fs::read_to_string(input)?;
-
-    // TODO: Return multiple suits from a single document
-    let suite = spec::Suite::from_markdown(&md)?;
-
-    Ok(vec![suite])
+    spec.evaluate(cli.verbose)
=}
index 4ac72e1..ffe218b 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -3,12 +3,26 @@ use std::error::Error;
=use std::io::{BufRead, BufReader, LineWriter, Write};
=use std::process::{Command, Stdio};
=
+/// Spec is a collection of suites that together describe a system
+///
+/// Each system under test has a single specification, whereas each suite
+/// describes a different aspect of it.
=#[derive(Debug, Default)]
=pub struct Spec {
=    pub suites: Vec<Suite>,
=}
=
=impl Spec {
+    /// Load suites from a markdown document
+    pub fn load_document(&mut self, md: &str) -> Result<(), Box<dyn Error>> {
+        // TODO: Support loading multiple suits from a single document (demarcated by h1)
+        let suite = Suite::from_markdown(md)?;
+
+        self.suites.push(suite);
+        Ok(())
+    }
+
+    /// Run all the scenarios
=    pub fn evaluate(&self, verbose: bool) -> Result<(), Box<dyn Error>> {
=        for suite in self.suites.iter() {
=            suite.evaluate(verbose)?;

Dump some thoughts in a README

On by Tad Lispy

new file mode 100644
index 0000000..758bd1a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,44 @@
+# TBB: Tad Better Behavior
+
+A BDD test runner inspired by Gauge, but better.
+
+  * No magic
+  * Flexibility
+  * Cross-platform
+
+## No magic
+
+Test scenarios are evaluated by interpreters, which are normal programs that read JSON on stdin and write JSON to stdout. User can implement them in any language or framework they like, using any tools they like (editors, lanuage servers, libraries).
+
+
+## Flexibility
+
+Different test suites can use different interpreters, which may be implemented using different languages. If your computer can run it, `tbb` can use it as an interpreter.
+
+When executing a step, the interpreter will get more data from the markdown fragment which defined the step
+
+  - tables
+  - lists
+  - code blocks
+
+and even the original markdown fragment itself.
+
+
+## Cross platform
+
+Support for Linux, OS X and Web (WASM).
+
+
+# Roadmap
+
+- [x] Proof of concept
+  - [x] Interpretter in a different language (Python)
+- [ ] Nix package (from Flake)
+- [ ] Use for evaluating Jewiet's Form to Mail specification
+- [ ] Helper libraries
+    - [ ] for Python
+    - [ ] for Clojure
+- [ ] Better reporters
+    - [ ] TUI
+    - [ ] Web
+- [ ] WASM target

Implement pretty printing of a Spec

On by Tad Lispy

It implements Display to render as markdown lists. Also, lower default logging level to info, so it can be used for normal, user facing output (like this kind of reporting). It can also be useful while developing interpreters: temporarily log as info, later demote to debug.

index ecd880a..a26ae1f 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -72,7 +72,7 @@ def ready():
=        
=@step("Add {0} and {1} to get {2}")
=def add_and_verify(a, b, expected, **kwargs):
-    log.info(f"{ a } + { b } = { expected }?")
+    log.debug(f"{ a } + { b } = { expected }?")
=
=    # TODO: Can we do this conversion automatically based on typing information?
=    a = float(a)
@@ -84,7 +84,7 @@ def add_and_verify(a, b, expected, **kwargs):
=
=@step("Divide {0} by {1} to get {2}")
=def divide_and_verify(a, b, expected, **kwargs):
-    log.info(f"{ a } / { b } = { expected }?")
+    log.debug(f"{ a } / { b } = { expected }?")
=
=    # TODO: Can we do this conversion automatically based on typing information?
=    a = float(a)
index 362aff8..7402b26 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -23,7 +23,7 @@ struct Cli {
=fn main() -> Result<(), Box<dyn Error>> {
=    let cli = Cli::parse();
=    let log_env = env_logger::Env::default()
-        .filter_or("RUST_LOG", if cli.verbose { "trace" } else { "warn" });
+        .filter_or("RUST_LOG", if cli.verbose { "trace" } else { "info" });
=    env_logger::init_from_env(log_env);
=
=    log::debug!("Reading the specification from {}", cli.input.display());
@@ -50,7 +50,7 @@ fn main() -> Result<(), Box<dyn Error>> {
=        return Err(format!("The {} is neither a file nor directory", input.display()).into());
=    };
=
-    log::info!("Collected {} suites: {:#?}", spec.suites.len(), spec);
+    log::info!("Collected {} suites:\n\n{}", spec.suites.len(), spec);
=
=    spec.evaluate(cli.verbose)
=}
index ffe218b..e25b621 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -1,5 +1,6 @@
=use serde::{Deserialize, Serialize};
=use std::error::Error;
+use std::fmt::Display;
=use std::io::{BufRead, BufReader, LineWriter, Write};
=use std::process::{Command, Stdio};
=
@@ -34,6 +35,35 @@ impl Spec {
=    }
=}
=
+impl Display for Spec {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        for suite in self.suites.iter() {
+            writeln!(
+                f,
+                "\n{title} ({interpreter})",
+                title = suite.title,
+                interpreter = suite.interpreter
+            )?;
+
+            for scenario in suite.scenarios.iter() {
+                writeln!(f, "\n  * {title}\n", title = scenario.title,)?;
+
+                for (index, step) in scenario.steps.iter().enumerate() {
+                    writeln!(
+                        f,
+                        "    {index:02}. {description} {arguments:?}",
+                        description = step.description,
+                        arguments = step.arguments
+                    )?;
+                }
+            }
+            writeln!(f, "")?;
+        }
+
+        Ok(())
+    }
+}
+
=/// Suite is a collection of scenarios that share a common title and interpreter
=///
=/// Other BDD systems often call it "a spec" but in my mind it doesn't make
@@ -75,7 +105,7 @@ impl Scenario {
=            .stdout(Stdio::piped())
=            .env(
=                "tbb_interpreter_log",
-                if verbose { "debug" } else { "warn" },
+                if verbose { "debug" } else { "info" },
=            )
=            .spawn()?;
=

Implement EvaluationReport struct and some logic

On by Tad Lispy

The idea is that evaluation will be driven by the report. See the doc comment for EvaluationReport::new.

index 7402b26..0a701e8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
+mod report;
=mod spec;
=
=use clap::Parser;
@@ -52,5 +53,9 @@ fn main() -> Result<(), Box<dyn Error>> {
=
=    log::info!("Collected {} suites:\n\n{}", spec.suites.len(), spec);
=
-    spec.evaluate(cli.verbose)
+    let report = spec.evaluate(cli.verbose)?;
+
+    log::info!("Evaluation done. Here's the result:\n\n{report}");
+
+    Ok(())
=}
new file mode 100644
index 0000000..54ede7e
--- /dev/null
+++ b/src/report.rs
@@ -0,0 +1,126 @@
+use crate::spec::{Scenario, Spec, Step, Suite};
+use std::fmt::Display;
+
+pub struct EvaluationReport<'a> {
+    spec: &'a Spec,
+    suites: Vec<SuiteReport<'a>>,
+}
+
+pub struct SuiteReport<'a> {
+    suite: &'a Suite,
+    scenarios: Vec<ScenarioReport<'a>>,
+}
+
+pub struct ScenarioReport<'a> {
+    scenario: &'a Scenario,
+    status: ScenarioStatus,
+    steps: Vec<StepReport<'a>>,
+}
+
+pub struct StepReport<'a> {
+    step: &'a Step,
+    status: StepStatus,
+}
+
+pub enum StepStatus {
+    Ok,
+    Failed { reason: String },
+    NotEvaluated,
+}
+
+pub enum ScenarioStatus {
+    Done,
+    Pending,
+    InterpreterFailed { reason: String },
+}
+
+impl<'a> EvaluationReport<'a> {
+    /// Create a blank report to be evaluated
+    ///
+    /// We start with a report pending to be evaluated. It holds references to
+    /// Spec, Suites, Scenarios and Steps (all in pending state). The evaluator
+    /// should walk through the report, and "fill" it. That way, even if it
+    /// fails to finish it's job, the report will be there to display.
+    pub fn new(spec: &'a Spec) -> Self {
+        let suites = spec.suites.iter().map(SuiteReport::from).collect();
+        Self { spec, suites }
+    }
+}
+
+impl Display for EvaluationReport<'_> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        for SuiteReport { suite, scenarios } in self.suites.iter() {
+            writeln!(
+                f,
+                "\n{title} ({interpreter})",
+                title = suite.title,
+                interpreter = suite.interpreter
+            )?;
+
+            for ScenarioReport {
+                scenario,
+                status,
+                steps,
+            } in scenarios.iter()
+            {
+                let sigil = match status {
+                    ScenarioStatus::Done => "✔", // TODO: Use different icon depending on steps status
+                    ScenarioStatus::Pending => "❔",
+                    ScenarioStatus::InterpreterFailed { .. } => "☠️",
+                };
+                writeln!(f, "\n  {sigil} {title}\n", title = scenario.title)?;
+                if let ScenarioStatus::InterpreterFailed { reason } = status {
+                    writeln!(f, "{reason}")?;
+                }
+
+                for StepReport { step, status } in steps.iter() {
+                    let sigil = match status {
+                        StepStatus::Ok => "✅",
+                        StepStatus::Failed { .. } => "❌",
+                        StepStatus::NotEvaluated => "⃞",
+                    };
+                    writeln!(
+                        f,
+                        "    {sigil} {description} {arguments:?}",
+                        description = step.description,
+                        arguments = step.arguments
+                    )?;
+
+                    if let StepStatus::Failed { reason } = status {
+                        writeln!(f, "\n     {reason}\n")?;
+                    }
+                }
+            }
+            writeln!(f, "")?;
+        }
+
+        Ok(())
+    }
+}
+
+impl<'a> From<&'a Suite> for SuiteReport<'a> {
+    fn from(suite: &'a Suite) -> Self {
+        let scenarios = suite.scenarios.iter().map(ScenarioReport::from).collect();
+        Self { suite, scenarios }
+    }
+}
+
+impl<'a> From<&'a Scenario> for ScenarioReport<'a> {
+    fn from(scenario: &'a Scenario) -> Self {
+        let steps = scenario.steps.iter().map(StepReport::from).collect();
+        Self {
+            scenario,
+            status: ScenarioStatus::Pending,
+            steps,
+        }
+    }
+}
+
+impl<'a> From<&'a Step> for StepReport<'a> {
+    fn from(step: &'a Step) -> Self {
+        Self {
+            step,
+            status: StepStatus::NotEvaluated,
+        }
+    }
+}
index e25b621..205a3ad 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -1,3 +1,4 @@
+use crate::report::EvaluationReport;
=use serde::{Deserialize, Serialize};
=use std::error::Error;
=use std::fmt::Display;
@@ -24,14 +25,14 @@ impl Spec {
=    }
=
=    /// Run all the scenarios
-    pub fn evaluate(&self, verbose: bool) -> Result<(), Box<dyn Error>> {
+    pub fn evaluate(&'_ self, verbose: bool) -> Result<EvaluationReport<'_>, Box<dyn Error>> {
+        let report = EvaluationReport::new(self);
+
=        for suite in self.suites.iter() {
=            suite.evaluate(verbose)?;
=        }
=
-        // TODO: Return an EvaluationReport
-        log::info!("Done!");
-        Ok(())
+        Ok(report)
=    }
=}
=

Always print report, even if some scenarios failed

On by Tad Lispy

Move evaluation logic to report.

index 0a701e8..97c2c5e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,6 +5,7 @@ use clap::Parser;
=use env_logger;
=use glob::glob;
=use log;
+use report::EvaluationReport;
=use spec::Spec;
=use std::error::Error;
=use std::path::PathBuf;
@@ -53,7 +54,8 @@ fn main() -> Result<(), Box<dyn Error>> {
=
=    log::info!("Collected {} suites:\n\n{}", spec.suites.len(), spec);
=
-    let report = spec.evaluate(cli.verbose)?;
+    let mut report = EvaluationReport::new(&spec);
+    report.evaluate(cli.verbose);
=
=    log::info!("Evaluation done. Here's the result:\n\n{report}");
=
index 54ede7e..ea61bfa 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -1,21 +1,160 @@
+use serde::Deserialize;
+
=use crate::spec::{Scenario, Spec, Step, Suite};
+use std::error::Error;
=use std::fmt::Display;
+use std::io::{BufRead, BufReader, LineWriter, Write};
+use std::process::{Command, Stdio};
=
=pub struct EvaluationReport<'a> {
-    spec: &'a Spec,
=    suites: Vec<SuiteReport<'a>>,
=}
=
+impl<'a> EvaluationReport<'a> {
+    /// Create a blank report to be evaluated
+    ///
+    /// We start with a report pending to be evaluated. It holds references to
+    /// Spec, Suites, Scenarios and Steps (all in pending state). The evaluator
+    /// should walk through the report, and "fill" it. That way, even if it
+    /// fails to finish it's job, the report will be there to display.
+    pub fn new(spec: &'a Spec) -> Self {
+        Self {
+            suites: spec.suites.iter().map(SuiteReport::from).collect(),
+        }
+    }
+
+    /// Run all the scenarios
+    pub fn evaluate(&mut self, verbose: bool) {
+        for suite in self.suites.iter_mut() {
+            suite.evaluate(verbose);
+        }
+    }
+}
+
=pub struct SuiteReport<'a> {
=    suite: &'a Suite,
=    scenarios: Vec<ScenarioReport<'a>>,
=}
=
+impl<'a> SuiteReport<'a> {
+    fn evaluate(&mut self, verbose: bool) {
+        log::debug!("Evaluating suite {}", self.suite.title);
+
+        for scenario in self.scenarios.iter_mut() {
+            let result = scenario.run(&self.suite.interpreter, verbose);
+            if let Err(error) = result {
+                scenario.status = ScenarioStatus::FailedToRun { error }
+            } else {
+                scenario.status = ScenarioStatus::Done
+            }
+        }
+    }
+}
+
=pub struct ScenarioReport<'a> {
=    scenario: &'a Scenario,
=    status: ScenarioStatus,
=    steps: Vec<StepReport<'a>>,
=}
+impl<'a> ScenarioReport<'a> {
+    // TODO: The ScenarioReport::run method is very effectful. I think it should live in it's own module.
+    fn run(&mut self, interpreter: &str, verbose: bool) -> Result<(), Box<dyn Error>> {
+        log::debug!(
+            "Running scenario '{}' using '{interpreter}' as an interpreter",
+            self.scenario.title
+        );
+
+        let mut process = Command::new("sh")
+            .arg("-c")
+            .arg(interpreter)
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .env(
+                "tbb_interpreter_log",
+                if verbose { "debug" } else { "info" },
+            )
+            .spawn()?;
+
+        let Some(input) = process.stdin.take() else {
+            // TODO: Avoid string errors!
+            return Err("Failed to take the stdin stream of the interpreter process.".into());
+        };
+
+        let Some(output) = process.stdout.take() else {
+            return Err("Failed to take the stdout stream of the interpreter process.".into());
+        };
+
+        let mut reader = BufReader::new(output);
+        let mut writer = LineWriter::new(input);
+
+        loop {
+            let received: String = Self::read_line(&mut reader)?;
+            let interpreter_state: InterpreterState = serde_json::from_str(&received)?;
+            log::debug!("Interpreter state: {interpreter_state:?}");
+            if interpreter_state.ready {
+                break;
+            };
+        }
+
+        for &mut StepReport {
+            step,
+            ref mut status,
+        } in self.steps.iter_mut()
+        {
+            log::debug!("Taking step '{}'", step.description);
+            let json = serde_json::to_string(step)?;
+            Self::write_line(&mut writer, &json)?;
+            let response = Self::read_line(&mut reader)?;
+            let step_result: StepResult = serde_json::from_str(&response)?;
+            log::debug!("Received: {:?}", step_result);
+            if step_result.ok {
+                *status = StepStatus::Ok;
+            } else {
+                *status = StepStatus::Failed {
+                    reason: "Unknown".to_string(),
+                };
+                log::debug!("Step {step:?} failed");
+            }
+        }
+
+        drop(writer);
+
+        process
+            .wait()
+            .map_err(|io_error| Box::new(io_error).into())  // No idea why it's needed.
+            .and_then(|exit_status| {
+                log::debug!("Interpreter closed with exit status {exit_status:?}");
+                let exit_code = exit_status.code().unwrap_or(0); // Is that right?
+                if exit_code == 0 {
+                    Ok(())
+                } else {
+                    let message = format!("Interpreter process {interpreter} exited abnormally. Exit code: {exit_code}");
+                    Err(message.into())
+                }
+            })
+    }
+
+    fn write_line(
+        writer: &mut LineWriter<std::process::ChildStdin>,
+        line: &str,
+    ) -> Result<(), Box<dyn Error>> {
+        let buffer = [line, "\n"].concat();
+
+        log::debug!("Sending: {}", buffer);
+        writer.write_all(buffer.as_bytes())?;
+        Ok(())
+    }
+
+    fn read_line(
+        reader: &mut BufReader<std::process::ChildStdout>,
+    ) -> Result<String, Box<dyn Error>> {
+        let mut buffer = String::new();
+        if reader.read_line(&mut buffer)? == 0 {
+            return Err("Can't read from the interpreter process.".into());
+        };
+        Ok(buffer)
+    }
+}
=
=pub struct StepReport<'a> {
=    step: &'a Step,
@@ -31,20 +170,7 @@ pub enum StepStatus {
=pub enum ScenarioStatus {
=    Done,
=    Pending,
-    InterpreterFailed { reason: String },
-}
-
-impl<'a> EvaluationReport<'a> {
-    /// Create a blank report to be evaluated
-    ///
-    /// We start with a report pending to be evaluated. It holds references to
-    /// Spec, Suites, Scenarios and Steps (all in pending state). The evaluator
-    /// should walk through the report, and "fill" it. That way, even if it
-    /// fails to finish it's job, the report will be there to display.
-    pub fn new(spec: &'a Spec) -> Self {
-        let suites = spec.suites.iter().map(SuiteReport::from).collect();
-        Self { spec, suites }
-    }
+    FailedToRun { error: Box<dyn Error> },
=}
=
=impl Display for EvaluationReport<'_> {
@@ -66,11 +192,11 @@ impl Display for EvaluationReport<'_> {
=                let sigil = match status {
=                    ScenarioStatus::Done => "✔", // TODO: Use different icon depending on steps status
=                    ScenarioStatus::Pending => "❔",
-                    ScenarioStatus::InterpreterFailed { .. } => "☠️",
+                    ScenarioStatus::FailedToRun { .. } => "☠️",
=                };
=                writeln!(f, "\n  {sigil} {title}\n", title = scenario.title)?;
-                if let ScenarioStatus::InterpreterFailed { reason } = status {
-                    writeln!(f, "{reason}")?;
+                if let ScenarioStatus::FailedToRun { error } = status {
+                    writeln!(f, "     {error}\n")?;
=                }
=
=                for StepReport { step, status } in steps.iter() {
@@ -87,7 +213,7 @@ impl Display for EvaluationReport<'_> {
=                    )?;
=
=                    if let StepStatus::Failed { reason } = status {
-                        writeln!(f, "\n     {reason}\n")?;
+                        writeln!(f, "\n        {reason}\n")?;
=                    }
=                }
=            }
@@ -124,3 +250,15 @@ impl<'a> From<&'a Step> for StepReport<'a> {
=        }
=    }
=}
+
+// Messages from interpreter
+
+#[derive(Deserialize, Debug)]
+struct InterpreterState {
+    ready: bool,
+}
+
+#[derive(Deserialize, Debug)]
+struct StepResult {
+    ok: bool,
+}
index 205a3ad..2b51f6d 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -1,9 +1,6 @@
-use crate::report::EvaluationReport;
=use serde::{Deserialize, Serialize};
=use std::error::Error;
=use std::fmt::Display;
-use std::io::{BufRead, BufReader, LineWriter, Write};
-use std::process::{Command, Stdio};
=
=/// Spec is a collection of suites that together describe a system
=///
@@ -23,17 +20,6 @@ impl Spec {
=        self.suites.push(suite);
=        Ok(())
=    }
-
-    /// Run all the scenarios
-    pub fn evaluate(&'_ self, verbose: bool) -> Result<EvaluationReport<'_>, Box<dyn Error>> {
-        let report = EvaluationReport::new(self);
-
-        for suite in self.suites.iter() {
-            suite.evaluate(verbose)?;
-        }
-
-        Ok(report)
-    }
=}
=
=impl Display for Spec {
@@ -94,103 +80,6 @@ pub struct Scenario {
=    pub steps: Vec<Step>,
=}
=
-impl Scenario {
-    fn run(&self, interpreter: &str, verbose: bool) -> Result<(), Box<dyn Error>> {
-        println!("\n  {}", self.title);
-        // TODO: start the interpreter program and tap into its stdio
-        log::debug!("Starting the interpreter process: `{interpreter}`");
-        let mut process = Command::new("sh")
-            .arg("-c")
-            .arg(interpreter)
-            .stdin(Stdio::piped())
-            .stdout(Stdio::piped())
-            .env(
-                "tbb_interpreter_log",
-                if verbose { "debug" } else { "info" },
-            )
-            .spawn()?;
-
-        let input = process
-            .stdin
-            .take()
-            .ok_or("Failed to take the stdin stream of the interpreter process.")?;
-
-        let output = process
-            .stdout
-            .take()
-            .ok_or("Failed to take the stdout stream of the interpreter process.")?;
-
-        let mut reader = BufReader::new(output);
-        let mut writer = LineWriter::new(input);
-
-        loop {
-            let received = read_line(&mut reader)?;
-            let interpreter_state: InterpreterState = serde_json::from_str(&received)?;
-            log::debug!("Interpreter state: {interpreter_state:?}");
-            if interpreter_state.ready {
-                break;
-            };
-        }
-
-        for step in self.steps.iter() {
-            println!("    {}", step.description);
-            let json = serde_json::to_string(step)?;
-            write_line(&mut writer, &json)?;
-            let response = read_line(&mut reader)?;
-            let step_result: StepResult = serde_json::from_str(&response)?;
-            log::debug!("Received: {:?}", step_result);
-            if !step_result.ok {
-                return Err(format!("Step {step:?} failed").into());
-            }
-        }
-
-        drop(writer);
-
-        process
-            .wait()
-            .map_err(|io_error| Box::new(io_error).into())  // No idea why it's needed.
-            .and_then(|exit_status| {
-                log::debug!("Interpreter closed with exit status {exit_status:?}");
-                let exit_code = exit_status.code().unwrap_or(0); // Is that right?
-                if exit_code == 0 {
-                    Ok(())
-                } else {
-                    let message = format!("Interpreter process {interpreter} exited abnormally. Exit code: {exit_code}");
-                    Err(message.into())
-                }
-            })
-    }
-}
-
-#[derive(Deserialize, Debug)]
-struct StepResult {
-    ok: bool,
-}
-
-#[derive(Deserialize, Debug)]
-struct InterpreterState {
-    ready: bool,
-}
-
-fn write_line(
-    writer: &mut LineWriter<std::process::ChildStdin>,
-    line: &str,
-) -> Result<(), Box<dyn Error>> {
-    let buffer = [line, "\n"].concat();
-
-    log::debug!("Sending: {}", buffer);
-    writer.write_all(buffer.as_bytes())?;
-    Ok(())
-}
-
-fn read_line(reader: &mut BufReader<std::process::ChildStdout>) -> Result<String, Box<dyn Error>> {
-    let mut buffer = String::new();
-    if reader.read_line(&mut buffer)? == 0 {
-        return Err("The interpreter process closed its output stream too early.".into());
-    };
-    Ok(buffer)
-}
-
=#[derive(Debug, Serialize)]
=pub struct Step {
=    /// The headline (without formatting)
@@ -225,17 +114,6 @@ impl Suite {
=
=        Self::try_from(mdast)
=    }
-
-    pub fn evaluate(&self, verbose: bool) -> Result<(), Box<dyn Error>> {
-        println!("\n{}", self.title);
-
-        for scenario in self.scenarios.iter() {
-            scenario.run(&self.interpreter, verbose)?;
-        }
-
-        log::info!("All good!");
-        Ok(())
-    }
=}
=
=impl TryFrom<markdown::mdast::Node> for Suite {

Update the roadmap

On by Tad Lispy

index 758bd1a..27d6dd2 100644
--- a/README.md
+++ b/README.md
@@ -33,11 +33,22 @@ Support for Linux, OS X and Web (WASM).
=
=- [x] Proof of concept
=  - [x] Interpretter in a different language (Python)
+  - [ ] Report why steps fail
+- [ ] Pass more step data to interpreters
+    - [ ] Code blocks
+    - [ ] Lists
+    - [ ] Tables
+    - [ ] Definition lists
+    - [ ] Original markdown fragment
=- [ ] Nix package (from Flake)
=- [ ] Use for evaluating Jewiet's Form to Mail specification
=- [ ] Helper libraries
=    - [ ] for Python
=    - [ ] for Clojure
+- [ ] Capture more data in reports
+  - [ ] Attachments (screenshots, videos, datasets, etc.)
+  - [ ] Performance data (interpreters' startup times, steps' durations)
+  - [ ] Annotations from interpreters
=- [ ] Better reporters
=    - [ ] TUI
=    - [ ] Web

Enumerate control and interpreter messages

On by Tad Lispy

For a bit of type safety around message passing. The JSON format of the messages changed. Now each of them need to carry a "type" field indicating which enum variant it implements.

index a26ae1f..e34aae4 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -38,7 +38,10 @@ def step(variant: str) -> Callable[..., Any]:
=
=# Call this when ready, i.e. all implementations are registered
=def ready():
-    send({ 'ready': True })
+    send({
+        "type": "InterpreterState",
+        "ready": True
+    })
=
=    # Loop over input
=    while True:
@@ -47,7 +50,12 @@ def ready():
=        try:
=            received = input()
=            log.debug("Received: %s", received)
-            step = json.loads(received)
+            message = json.loads(received)
+            if not message["type"] == "Execute":
+                log.warning(f"Unexpected message received from the control program. Expected Execute type message, got {received}")
+                continue
+
+            step = message["step"] # The only message variant we expect
=            variant = step.get("variant")
=            arguments = step.get("arguments")
=            log.debug(f"Looking for implementation of '{ variant }'")
@@ -56,10 +64,13 @@ def ready():
=            if implementation:
=                log.debug(f"Found an implementation of '{ variant }'")
=                implementation(*arguments, **step)
-                send({ 'ok': True })
+                send({ "type": 'Success' })
=            else:
=                log.warning(f"Not implemented: {variant}")
-                send({ 'ok': False })
+                send({
+                    "type": 'Failure',
+                    'reason': 'Step not implemented'
+                })
=
=        except EOFError:
=            # The control program closed the stream.
index ea61bfa..862df77 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -1,4 +1,4 @@
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
=
=use crate::spec::{Scenario, Spec, Step, Suite};
=use std::error::Error;
@@ -88,10 +88,16 @@ impl<'a> ScenarioReport<'a> {
=        let mut writer = LineWriter::new(input);
=
=        loop {
-            let received: String = Self::read_line(&mut reader)?;
-            let interpreter_state: InterpreterState = serde_json::from_str(&received)?;
-            log::debug!("Interpreter state: {interpreter_state:?}");
-            if interpreter_state.ready {
+            let ready = match Self::receive(&mut reader)? {
+                InterpreterMessage::InterpreterState { ready } => ready,
+                message => {
+                    let complaint =
+                        format!("Unexpected message received from the interpreter: {message:#?}");
+                    return Err(complaint.into());
+                }
+            };
+            if ready {
+                log::debug!("Interpreter ready");
=                break;
=            };
=        }
@@ -102,19 +108,29 @@ impl<'a> ScenarioReport<'a> {
=        } in self.steps.iter_mut()
=        {
=            log::debug!("Taking step '{}'", step.description);
-            let json = serde_json::to_string(step)?;
-            Self::write_line(&mut writer, &json)?;
-            let response = Self::read_line(&mut reader)?;
-            let step_result: StepResult = serde_json::from_str(&response)?;
-            log::debug!("Received: {:?}", step_result);
-            if step_result.ok {
-                *status = StepStatus::Ok;
-            } else {
-                *status = StepStatus::Failed {
-                    reason: "Unknown".to_string(),
-                };
-                log::debug!("Step {step:?} failed");
-            }
+            Self::send(
+                &mut writer,
+                &ControlMessage::Execute {
+                    step: step.to_owned(),
+                },
+            )?;
+
+            match Self::receive(&mut reader)? {
+                InterpreterMessage::Success => {
+                    log::debug!("Step executed successfully: {step:#?}");
+                    *status = StepStatus::Ok;
+                }
+                InterpreterMessage::Failure { reason } => {
+                    log::debug!("Step failed:\n\n {step:#?} \n\n {reason:#?}");
+                    *status = StepStatus::Failed { reason };
+                }
+                unexpected => {
+                    let message = format!(
+                        "Unexpected message received from the interpreter: {unexpected:#?}"
+                    );
+                    return Err(message.into());
+                }
+            };
=        }
=
=        drop(writer);
@@ -130,10 +146,28 @@ impl<'a> ScenarioReport<'a> {
=                } else {
=                    let message = format!("Interpreter process {interpreter} exited abnormally. Exit code: {exit_code}");
=                    Err(message.into())
-                }
+                 }
=            })
=    }
=
+    /// Send a message to interpreter
+    fn send(
+        writer: &mut LineWriter<std::process::ChildStdin>,
+        message: &ControlMessage,
+    ) -> Result<(), Box<dyn Error>> {
+        let json = serde_json::to_string(&message)?;
+        Self::write_line(writer, &json)
+    }
+
+    fn receive(
+        reader: &mut BufReader<std::process::ChildStdout>,
+    ) -> Result<InterpreterMessage, Box<dyn Error>> {
+        let buffer = Self::read_line(reader)?;
+        let message = serde_json::from_str(&buffer)?;
+
+        Ok(message)
+    }
+
=    fn write_line(
=        writer: &mut LineWriter<std::process::ChildStdin>,
=        line: &str,
@@ -251,14 +285,18 @@ impl<'a> From<&'a Step> for StepReport<'a> {
=    }
=}
=
-// Messages from interpreter
-
-#[derive(Deserialize, Debug)]
-struct InterpreterState {
-    ready: bool,
+/// Messages from an interpreter to this (control) program
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(tag = "type")]
+pub enum InterpreterMessage {
+    InterpreterState { ready: bool },
+    Success,
+    Failure { reason: String },
=}
=
-#[derive(Deserialize, Debug)]
-struct StepResult {
-    ok: bool,
+/// Messages from this (control) program to an interpreter
+#[derive(Serialize, Deserialize, Debug)]
+#[serde(tag = "type")]
+pub enum ControlMessage {
+    Execute { step: Step },
=}
index 2b51f6d..4249efd 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -80,7 +80,7 @@ pub struct Scenario {
=    pub steps: Vec<Step>,
=}
=
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize, Clone)]
=pub struct Step {
=    /// The headline (without formatting)
=    pub description: String,

Fix basic interpreter crashing on assertion errors

On by Tad Lispy

and other exceptions when executing a step.

index e34aae4..d2664cb 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -63,8 +63,12 @@ def ready():
=
=            if implementation:
=                log.debug(f"Found an implementation of '{ variant }'")
-                implementation(*arguments, **step)
-                send({ "type": 'Success' })
+                try:
+                    implementation(*arguments, **step)
+                    send({ "type": 'Success' })
+                except Exception as error:
+                    send({ "type": "Failure", "reason": str(error) })
+
=            else:
=                log.warning(f"Not implemented: {variant}")
=                send({

Make the program skip steps after failure

On by Tad Lispy

See the comment for rationale.

index 862df77..b0d5c41 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -123,6 +123,15 @@ impl<'a> ScenarioReport<'a> {
=                InterpreterMessage::Failure { reason } => {
=                    log::debug!("Step failed:\n\n {step:#?} \n\n {reason:#?}");
=                    *status = StepStatus::Failed { reason };
+
+                    // Do not run subsequent steps.
+                    //
+                    // A scenario is a unit of testing. Later steps are expected
+                    // to depend on previous ones. If a step fails, continuing
+                    // may lead to a mess.
+                    //
+                    // Maybe this can be configured via front-matter?
+                    break;
=                }
=                unexpected => {
=                    let message = format!(

Implement one step from the Text scenario

On by Tad Lispy

Mainly to make sure that it will be executed, even if the Arithmetic scenario fails.

index d2664cb..f5bbff3 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -108,4 +108,15 @@ def divide_and_verify(a, b, expected, **kwargs):
=    
=    assert a / b == expected, f"{ a } / { b } = { a / b }, not { expected }!"
=
+
+@step("The word {0} has {1} characters")
+def verify_characters_count(word: str, expected_length, **kwargs):
+    log.info(word)
+    expected_length = int(expected_length)
+    actual_length = len(word)
+
+    assert expected_length == actual_length, f"The word '{word}' is {actual_length} long, not {expected_length}"
+
+    
+  
=ready()

Update the roadmap

On by Tad Lispy

index 27d6dd2..cb1c00d 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,11 @@ Support for Linux, OS X and Web (WASM).
=
=- [x] Proof of concept
=  - [x] Interpretter in a different language (Python)
-  - [ ] Report why steps fail
+  - [x] Report why steps fail
+- [ ] More readable report
+  - [ ] The emojis are misaligned and lack color (at least in my terminal)
+  - [ ] A summary at the bottom (esp. list of errors)
+  - [ ] Use colors
=- [ ] Pass more step data to interpreters
=    - [ ] Code blocks
=    - [ ] Lists

Style the report

On by Tad Lispy

Colors and better sigils! Also, demote logging the spec before evaluation to debug. The output is much more readable now.

index 8f4f3fb..9c1b9d0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -47,7 +47,7 @@ version = "1.1.5"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
=dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
=]
=
=[[package]]
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
=dependencies = [
= "anstyle",
= "once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
=]
=
=[[package]]
@@ -107,6 +107,15 @@ version = "1.0.4"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
=
+[[package]]
+name = "colored"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
=[[package]]
=name = "env_filter"
=version = "0.1.4"
@@ -373,6 +382,7 @@ name = "tad-better-behavior"
=version = "0.1.0"
=dependencies = [
= "clap",
+ "colored",
= "env_logger",
= "glob",
= "log",
@@ -412,6 +422,15 @@ version = "0.2.1"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
=
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
=[[package]]
=name = "windows-sys"
=version = "0.61.2"
@@ -420,3 +439,67 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
=dependencies = [
= "windows-link",
=]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
index e75e559..3ffa046 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2024"
=
=[dependencies]
=clap = { version = "4.5.51", features = ["derive"] }
+colored = "3.0.0"
=env_logger = "0.11.8"
=glob = "0.3.3"
=log = "0.4.28"
index 97c2c5e..b5da4fa 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -52,7 +52,8 @@ fn main() -> Result<(), Box<dyn Error>> {
=        return Err(format!("The {} is neither a file nor directory", input.display()).into());
=    };
=
-    log::info!("Collected {} suites:\n\n{}", spec.suites.len(), spec);
+    log::info!("Collected {} suites.", spec.suites.len());
+    log::debug!("Evaluating:\n\n{spec}");
=
=    let mut report = EvaluationReport::new(&spec);
=    report.evaluate(cli.verbose);
index b0d5c41..787b9bc 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -1,6 +1,6 @@
-use serde::{Deserialize, Serialize};
-
=use crate::spec::{Scenario, Spec, Step, Suite};
+use colored::Colorize;
+use serde::{Deserialize, Serialize};
=use std::error::Error;
=use std::fmt::Display;
=use std::io::{BufRead, BufReader, LineWriter, Write};
@@ -222,8 +222,8 @@ impl Display for EvaluationReport<'_> {
=            writeln!(
=                f,
=                "\n{title} ({interpreter})",
-                title = suite.title,
-                interpreter = suite.interpreter
+                title = suite.title.bold().underline(),
+                interpreter = suite.interpreter.dimmed()
=            )?;
=
=            for ScenarioReport {
@@ -233,20 +233,20 @@ impl Display for EvaluationReport<'_> {
=            } in scenarios.iter()
=            {
=                let sigil = match status {
-                    ScenarioStatus::Done => "✔", // TODO: Use different icon depending on steps status
-                    ScenarioStatus::Pending => "❔",
-                    ScenarioStatus::FailedToRun { .. } => "☠️",
+                    ScenarioStatus::Done => "✓".to_string().bold(), // TODO: Use different icon depending on steps status
+                    ScenarioStatus::Pending => "?".to_string().bold().dimmed(),
+                    ScenarioStatus::FailedToRun { .. } => "x".to_string().bold().red(),
=                };
=                writeln!(f, "\n  {sigil} {title}\n", title = scenario.title)?;
=                if let ScenarioStatus::FailedToRun { error } = status {
-                    writeln!(f, "     {error}\n")?;
+                    writeln!(f, "    {}\n", error.to_string().red())?;
=                }
=
=                for StepReport { step, status } in steps.iter() {
=                    let sigil = match status {
-                        StepStatus::Ok => "✅",
-                        StepStatus::Failed { .. } => "❌",
-                        StepStatus::NotEvaluated => "⃞",
+                        StepStatus::Ok => "⊞".to_string().bold().green(),
+                        StepStatus::Failed { .. } => "⊠".to_string().bold().red(),
+                        StepStatus::NotEvaluated => "□".to_string().bold(),
=                    };
=                    writeln!(
=                        f,
@@ -256,7 +256,7 @@ impl Display for EvaluationReport<'_> {
=                    )?;
=
=                    if let StepStatus::Failed { reason } = status {
-                        writeln!(f, "\n        {reason}\n")?;
+                        writeln!(f, "\n      {}\n", reason.red())?;
=                    }
=                }
=            }