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