Commits: 10

Implement the "not containing" step

index 5ce84a8..e4f5fe8 100644
--- a/spec/self-check.py
+++ b/spec/self-check.py
@@ -114,4 +114,24 @@ def step_implementation_04(label: str, **kwargs):
=    ```
=    """)
=
+@step("The output will not contain {0}")
+def step_implementation_05(pattern: str, **kwargs):
+    global completed
+
+    output = str(completed.stdout, "utf-8")
+
+    assert not re.search(pattern, output), dedent(f"""
+    forbidden pattern found
+    
+    ``` regular-expression
+    {pattern} 
+    ```
+
+    --- found in ---
+
+    ``` text
+    {indent_tail(output, "    ")}
+    ```
+    """)
+
=tbb.ready()

Correct a mistake in the filtering spec

Scenario titles were not matching prose.

index 70433c0..8a10f06 100644
--- a/spec/filtering.md
+++ b/spec/filtering.md
@@ -25,12 +25,12 @@ With the `--only suite:<tag>` only suites that have the given tag should be incl
=
=## Only some scenarios included
=
-With the `--exclude suite:<tag>` any suites that have the given tag should be excluded from the report. What's not in the report, should not be evaluated (laziness).
+With the `--only scenario:<tag>` only scenarios that have the given tag should be included in the report. What's not in the report, should not be evaluated (laziness).
=
=
=## Some suites excluded
=
-With the `--only scenario:<tag>` only scenarios that have the given tag should be included in the report. What's not in the report, should not be evaluated (laziness).
+With the `--exclude suite:<tag>` any suites that have the given tag should be excluded from the report. What's not in the report, should not be evaluated (laziness).
=
=
=## Some scenarios excluded

Self-check: Upon unexpected exit status show the command

index e4f5fe8..75665c7 100644
--- a/spec/self-check.py
+++ b/spec/self-check.py
@@ -34,12 +34,7 @@ def step_implementation_00(args: str, **kwargs):
=def step_implementation_01(expected_code: int, **kwargs):
=    global completed
=
-    tester.assertEqual(expected_code, completed.returncode)  
-    # for line in completed.stdout.splitlines():
-    #     log.info(line)
-    # log.info(str(completed.stdout))
-    # completed = run()
-    # assert "cat" == "dog", "Obviously you need to replace this!"
+    tester.assertEqual(expected_code, completed.returncode, f"{shlex.join(completed.args)}")
=
=@step("The output will contain {0}")
=def step_implementation_02(pattern: str, **kwargs):

Implement the suite filtering with --only

index 5a5fb59..f50d8ba 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,7 +7,7 @@ use clap::{Parser, Subcommand};
=use env_logger;
=use glob::glob;
=use log;
-use report::{EvaluationReport, EvaluationSummary};
+use report::{EvaluationReport, EvaluationSummary, ReportOptions};
=use spec::Spec;
=use std::{path::PathBuf, process};
=
@@ -36,6 +36,10 @@ enum Command {
=        /// A directory or a markdown file with the spec to evaluate
=        #[arg(value_name = "SPEC PATH", default_value = "./spec/")]
=        input: PathBuf,
+
+        /// Filter by tag
+        #[arg(short, long, value_delimiter = ',', value_name = "TAG")]
+        only: Vec<String>,
=    },
=}
=fn main() -> Result<(), Error> {
@@ -46,7 +50,7 @@ fn main() -> Result<(), Error> {
=
=    match cli.command {
=        Command::List { input } => list(input),
-        Command::Evaluate { input } => evaluate(input, cli.verbose),
+        Command::Evaluate { input, only } => evaluate(input, only, cli.verbose),
=    }
=}
=
@@ -85,8 +89,9 @@ fn list(input: PathBuf) -> Result<(), Error> {
=    Ok(())
=}
=
-fn evaluate(input: PathBuf, verbose: bool) -> Result<(), Error> {
+fn evaluate(input: PathBuf, only: Vec<String>, verbose: bool) -> Result<(), Error> {
=    log::debug!("Reading the specification from {}", input.display());
+    log::info!("Evaluating only {:?}", only);
=
=    let input = input.canonicalize()?;
=    let mut spec = Spec::default();
@@ -118,7 +123,23 @@ fn evaluate(input: PathBuf, verbose: bool) -> Result<(), Error> {
=    log::info!("Collected {} suites.", spec.suites.len());
=    log::debug!("Evaluating:\n\n{spec}");
=
-    let mut report = EvaluationReport::new(&spec);
+    let mut options = ReportOptions::default();
+    for tag in only {
+        match tag.split_once(":") {
+            Some(("suite", rest)) => {
+                options.only_suites.insert(rest.to_string());
+            }
+            Some(("scenario", rest)) => {
+                options.only_scenarios.insert(rest.to_string());
+            }
+            _ => {
+                options.only_suites.insert(tag.to_string());
+                options.only_scenarios.insert(tag.to_string());
+            }
+        };
+    }
+
+    let mut report = EvaluationReport::new(&spec, &options);
=    report.evaluate(verbose);
=
=    // Print the report on STDOUT
index e62f399..4f7a077 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -10,6 +10,21 @@ use std::io::{BufRead, BufReader, LineWriter, Write};
=use std::ops::Not;
=use std::process::{Command, Stdio};
=
+#[derive(Debug, Default)]
+pub struct ReportOptions {
+    /// Only include suites with those tags
+    pub only_suites: BTreeSet<String>,
+
+    /// Only include scenarios with those tags
+    pub only_scenarios: BTreeSet<String>,
+
+    /// Exclude any suite with those tags
+    pub exclude_suites: BTreeSet<String>,
+
+    /// Exclude any scenario with those tags
+    pub exclude_scenarios: BTreeSet<String>,
+}
+
=pub struct EvaluationReport<'a> {
=    suites: Vec<SuiteReport<'a>>,
=}
@@ -21,9 +36,16 @@ impl<'a> EvaluationReport<'a> {
=    /// 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 {
+    pub fn new(spec: &'a Spec, options: &ReportOptions) -> Self {
+        log::info!("{options:#?}");
+
=        Self {
-            suites: spec.suites.iter().map(SuiteReport::from).collect(),
+            suites: spec
+                .suites
+                .iter()
+                .filter(|suite| suite.tags.is_superset(&options.only_suites))
+                .map(|suite| SuiteReport::from_suite(suite, options))
+                .collect(),
=        }
=    }
=
@@ -41,6 +63,11 @@ pub struct SuiteReport<'a> {
=}
=
=impl<'a> SuiteReport<'a> {
+    fn from_suite(suite: &'a Suite, options: &ReportOptions) -> Self {
+        let scenarios = suite.scenarios.iter().map(ScenarioReport::from).collect();
+        Self { suite, scenarios }
+    }
+
=    fn evaluate(&mut self, verbose: bool) {
=        log::debug!("Evaluating suite {}", self.suite.title);
=
@@ -323,13 +350,6 @@ impl Display for EvaluationReport<'_> {
=    }
=}
=
-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();

Specify and implement --only scenario: filtering

index 45ce718..5eb13d9 100644
--- a/samples/passing.md
+++ b/samples/passing.md
@@ -10,3 +10,12 @@ This suite should always pass.
=## Easy scenario
=
=  * Add `2` and `2` to get `4`
+
+
+## Interesting scenario
+
+``` yaml tbb
+tags: [ interesting ]
+```
+
+  * Divide `42` by `7` to get `6`
index 8a10f06..fa5d7c1 100644
--- a/spec/filtering.md
+++ b/spec/filtering.md
@@ -28,6 +28,33 @@ With the `--only suite:<tag>` only suites that have the given tag should be incl
=With the `--only scenario:<tag>` only scenarios that have the given tag should be included in the report. What's not in the report, should not be evaluated (laziness).
=
=
+
+
+  * Run the program with `evaluate --only scenario:interesting samples/` command line arguments
+  * The exit code should be `0`
+  * The output will contain `the expected suite header` block
+
+    ```text
+    A little suite that could (python -m samples.basic)
+    tagged: basic passing
+    ```
+
+  * The output will contain `the expected scenario header` block
+
+    ```text
+    ✓ Interesting scenario
+      tagged: interesting
+    ```
+
+  * The output will contain `Suite 1 from the invalid document`
+
+    Suites without any matching scenarios should still be mentioned!
+
+  * The output will not contain `Easy scenario`
+
+     Scenarios missing the required tag are not included in the report.
+
+
=## Some suites excluded
=
=With the `--exclude suite:<tag>` any suites that have the given tag should be excluded from the report. What's not in the report, should not be evaluated (laziness).
index 4f7a077..10a51d3 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -64,7 +64,12 @@ pub struct SuiteReport<'a> {
=
=impl<'a> SuiteReport<'a> {
=    fn from_suite(suite: &'a Suite, options: &ReportOptions) -> Self {
-        let scenarios = suite.scenarios.iter().map(ScenarioReport::from).collect();
+        let scenarios = suite
+            .scenarios
+            .iter()
+            .filter(|scenario| scenario.tags.is_superset(&options.only_scenarios))
+            .map(ScenarioReport::from)
+            .collect();
=        Self { suite, scenarios }
=    }
=

Specify the --exclude suite: behavior

index fa5d7c1..b2d0ece 100644
--- a/spec/filtering.md
+++ b/spec/filtering.md
@@ -60,6 +60,18 @@ With the `--only scenario:<tag>` only scenarios that have the given tag should b
=With the `--exclude suite:<tag>` any suites that have the given tag should be excluded from the report. What's not in the report, should not be evaluated (laziness).
=
=
+  * Run the program with `evaluate --exclude suite:not-implemented samples/` command line arguments
+  * The exit code should be `0`
+  * The output will contain `the passing suite header` block
+
+    ```text
+    A little suite that could (python -m samples.basic)
+    tagged: basic passing
+    ```
+
+  * The output will not contain `Suite 1 from the invalid document`
+
+
=## Some scenarios excluded
=
=With the `--exclude scenario:<tag>` any scenarios that have the given tag should be excluded from the report. What's not in the report, should not be evaluated (laziness).

Specify the CLI for filtering by multiple tags

Prose only for now.

index b2d0ece..e6e6ce7 100644
--- a/spec/filtering.md
+++ b/spec/filtering.md
@@ -105,3 +105,20 @@ If as a result of filtering there are no scenarios to evaluate, the program shou
=## No suites to evaluate at all
=
=Same as above.
+
+
+## Passing multiple comma delimited tags
+
+The CLI also supports this form:
+
+```shell 
+tbb --only tag-1,tag-2,tag-3 --exclude tag-4,tag-5
+```
+
+## Passing multiple tags by repeating the options
+
+The CLI also supports this form:
+
+```shell 
+tbb --only tag-1 --only tag-2 --only tag-3 --exclude tag-4 --exclude tag-5
+```

Implement suites filtering out with --exclude

index 60889a3..7c1be74 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -3,6 +3,7 @@ interpreter: "python -m samples.basic"
=tags: 
=  - basic
=  - sample
+  - not-implemented
=---
=
=# Basic BDD suite
index 5f5380b..ef349f1 100644
--- a/spec/basic-usage.md
+++ b/spec/basic-usage.md
@@ -68,7 +68,7 @@ Whe a directory is given as the last argument, load all documents inside (recurs
=  
=    ``` text 
=    Basic BDD suite (python -m samples.basic)
-    tagged: basic sample tutorial
+    tagged: basic not-implemented sample tutorial
=    ```
=
=  * The output will contain `the Aritmetic scenario` block
@@ -141,7 +141,7 @@ Notice it's similar to the output of `tbb list`, but now contains unicode symbol
=  
=    ```text
=    Basic BDD suite (python -m samples.basic)
-    tagged: basic sample tutorial
+    tagged: basic not-implemented sample tutorial
=    ```
=  
=  * The output will contain `arithmetic scenario` block
index f50d8ba..dbc9ffd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -40,6 +40,10 @@ enum Command {
=        /// Filter by tag
=        #[arg(short, long, value_delimiter = ',', value_name = "TAG")]
=        only: Vec<String>,
+
+        /// Exclude by tag
+        #[arg(short, long, value_delimiter = ',', value_name = "TAG")]
+        exclude: Vec<String>,
=    },
=}
=fn main() -> Result<(), Error> {
@@ -50,7 +54,11 @@ fn main() -> Result<(), Error> {
=
=    match cli.command {
=        Command::List { input } => list(input),
-        Command::Evaluate { input, only } => evaluate(input, only, cli.verbose),
+        Command::Evaluate {
+            input,
+            only,
+            exclude,
+        } => evaluate(input, only, exclude, cli.verbose),
=    }
=}
=
@@ -89,7 +97,12 @@ fn list(input: PathBuf) -> Result<(), Error> {
=    Ok(())
=}
=
-fn evaluate(input: PathBuf, only: Vec<String>, verbose: bool) -> Result<(), Error> {
+fn evaluate(
+    input: PathBuf,
+    only: Vec<String>,
+    exclude: Vec<String>,
+    verbose: bool,
+) -> Result<(), Error> {
=    log::debug!("Reading the specification from {}", input.display());
=    log::info!("Evaluating only {:?}", only);
=
@@ -138,6 +151,21 @@ fn evaluate(input: PathBuf, only: Vec<String>, verbose: bool) -> Result<(), Erro
=            }
=        };
=    }
+    // TODO: DRY?
+    for tag in exclude {
+        match tag.split_once(":") {
+            Some(("suite", rest)) => {
+                options.exclude_suites.insert(rest.to_string());
+            }
+            Some(("scenario", rest)) => {
+                options.exclude_scenarios.insert(rest.to_string());
+            }
+            _ => {
+                options.exclude_suites.insert(tag.to_string());
+                options.exclude_scenarios.insert(tag.to_string());
+            }
+        };
+    }
=
=    let mut report = EvaluationReport::new(&spec, &options);
=    report.evaluate(verbose);
index 10a51d3..8081e5b 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -43,7 +43,10 @@ impl<'a> EvaluationReport<'a> {
=            suites: spec
=                .suites
=                .iter()
-                .filter(|suite| suite.tags.is_superset(&options.only_suites))
+                .filter(|suite| {
+                    suite.tags.is_superset(&options.only_suites)
+                        && suite.tags.is_disjoint(&options.exclude_suites)
+                })
=                .map(|suite| SuiteReport::from_suite(suite, options))
=                .collect(),
=        }

Implement --exclude scenario: CLI option

index e6e6ce7..5446aa1 100644
--- a/spec/filtering.md
+++ b/spec/filtering.md
@@ -27,9 +27,6 @@ With the `--only suite:<tag>` only suites that have the given tag should be incl
=
=With the `--only scenario:<tag>` only scenarios that have the given tag should be included in the report. What's not in the report, should not be evaluated (laziness).
=
-
-
-
=  * Run the program with `evaluate --only scenario:interesting samples/` command line arguments
=  * The exit code should be `0`
=  * The output will contain `the expected suite header` block
@@ -76,6 +73,24 @@ With the `--exclude suite:<tag>` any suites that have the given tag should be ex
=
=With the `--exclude scenario:<tag>` any scenarios that have the given tag should be excluded from the report. What's not in the report, should not be evaluated (laziness).
=
+  * Run the program with `evaluate --exclude scenario:interesting samples/passing.md` command line arguments
+  * The exit code should be `0`
+  * The output will contain `the expected suite header` block
+
+    ```text
+    A little suite that could (python -m samples.basic)
+    tagged: basic passing
+    ```
+
+  * The output will contain `the expected scenario header` block
+
+    ```text
+    ✓ Easy scenario
+    ```
+
+  * The output will not contain `Interesting scenario`
+
+     Scenarios carrying the required tag are not included in the report.
=
=## Only some suites included and then some scenarios excluded
=
index 8081e5b..f811216 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -70,7 +70,10 @@ impl<'a> SuiteReport<'a> {
=        let scenarios = suite
=            .scenarios
=            .iter()
-            .filter(|scenario| scenario.tags.is_superset(&options.only_scenarios))
+            .filter(|scenario| {
+                scenario.tags.is_superset(&options.only_scenarios)
+                    && scenario.tags.is_disjoint(&options.exclude_scenarios)
+            })
=            .map(ScenarioReport::from)
=            .collect();
=        Self { suite, scenarios }

Bump the minor version to 0.5.0 + roadmap update

This version renames run sub-command to evaluate and introduces filtering with --only and --exclude.

index d58a07f..16c0e0f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ dependencies = [
=
=[[package]]
=name = "tad-better-behavior"
-version = "0.4.0"
+version = "0.5.0"
=dependencies = [
= "anyhow",
= "clap",
index d7c38de..8d76829 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
=[package]
=name = "tad-better-behavior"
-version = "0.4.0"
+version = "0.5.0"
=edition = "2024"
=
=[dependencies]
index cab0847..a92873c 100644
--- a/README.md
+++ b/README.md
@@ -60,13 +60,13 @@ In summary:
=- [ ] Self-check
=  - [x] Proof of concept
=  - [ ] Comprehensive
-- [ ] Tags to filtering suites and scenarios
-  - [ ] Per document (in front-matter)
-  - [ ] Per suite (in `tags` codeblock under `h1`)
-  - [ ] Per scenario (in `tags` codeblock under `h2`)
-  - [ ] `--exclude` CLI option (logical *or*)
-  - [ ] `--only` CLI option (logical *and*)
-  - [ ] prefixes (like `suite:foo`, `scenario:ready`)
+- [x] Tags to filtering suites and scenarios
+  - [x] Per document (in front-matter)
+  - [x] Per suite (in a `yaml tbb` codeblock under `h1`)
+  - [x] Per scenario (in a `yaml tbb` codeblock under `h2`)
+  - [x] `--exclude` CLI option (logical *or*)
+  - [x] `--only` CLI option (logical *and*)
+  - [x] prefixes (like `suite:foo`, `scenario:ready`)
=- [ ] More readable report
=  - [x] The emojis are misaligned and lack color (at least in my terminal)
=  - [x] A summary at the bottom (esp. list of errors)
@@ -84,7 +84,7 @@ In summary:
=    - [ ] Original markdown fragment
=    - [ ] Block quotes
=- [x] Nix package (from Flake)
-- [ ] Use for evaluating Jewiet's Form to Mail specification
+- [x] Use for evaluating Jewiet's Form to Mail specification
=- [ ] Helper libraries
=    - [x] for Python
=      - [x] Proof-of-concept