Commits: 9

Use h1 for spec title

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

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

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

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

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

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

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

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

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(())
=    }
=}