Skip to main content

executable_stories/
lib.rs

1#![warn(clippy::all, clippy::pedantic)]
2
3mod collector;
4mod doc_entry;
5mod json_writer;
6mod story;
7mod types;
8
9pub use collector::write_results;
10
11/// Declare what a file's scenarios are for, ahead of the examples.
12///
13/// Scenarios say what the system does. A declaration says why the feature
14/// exists and who it serves, so a reader meets the intent before the examples.
15///
16/// Reach for [`declare_feature!`] rather than calling this directly: Rust has
17/// no portable way to run code before a test binary's tests, so the macro
18/// wraps the declaration in a `#[test]` of its own.
19///
20/// ```
21/// use executable_stories::Feature;
22///
23/// Feature::new("Anyone can do arithmetic without a calculator app")
24///     .ability()
25///     .narrative("Switching apps for a quick sum loses your place.")
26///     .term("operand", "One of the two numbers an operation is applied to.")
27///     .declare(file!());
28/// ```
29pub struct Feature {
30    title: String,
31    kind: Option<String>,
32    narrative: Option<String>,
33    tags: Option<Vec<String>>,
34    glossary: Vec<GlossaryTerm>,
35}
36
37pub use types::RawGlossaryTerm as GlossaryTerm;
38
39impl Feature {
40    /// Start a declaration with the heading readers will see.
41    #[must_use]
42    pub fn new(title: &str) -> Self {
43        Feature {
44            title: title.to_string(),
45            kind: None,
46            narrative: None,
47            tags: None,
48            glossary: Vec::new(),
49        }
50    }
51
52    /// Frame the feature as something a person can now do.
53    #[must_use]
54    pub fn ability(mut self) -> Self {
55        self.kind = Some("ability".to_string());
56        self
57    }
58
59    /// Frame the feature as a cross-cutting concern nobody asks for by name,
60    /// such as security or performance.
61    #[must_use]
62    pub fn business_need(mut self) -> Self {
63        self.kind = Some("business-need".to_string());
64        self
65    }
66
67    /// Explain, in markdown, why the feature exists and who it serves.
68    #[must_use]
69    pub fn narrative(mut self, narrative: &str) -> Self {
70        self.narrative = Some(narrative.to_string());
71        self
72    }
73
74    /// Apply tags to every scenario in the file.
75    #[must_use]
76    pub fn tags(mut self, tags: &[&str]) -> Self {
77        self.tags = Some(tags.iter().map(|t| (*t).to_string()).collect());
78        self
79    }
80
81    /// Define a term the scenarios use.
82    #[must_use]
83    pub fn term(mut self, term: &str, definition: &str) -> Self {
84        self.glossary
85            .push(GlossaryTerm { term: term.to_string(), definition: definition.to_string() });
86        self
87    }
88
89    /// Record the declaration against a source file. Pass `file!()`.
90    pub fn declare(self, source_file: &str) {
91        collector::record_feature(types::RawFeature {
92            source_file: Some(source_file.to_string()),
93            title: self.title,
94            kind: self.kind,
95            narrative: self.narrative,
96            tags: self.tags,
97            glossary: if self.glossary.is_empty() { None } else { Some(self.glossary) },
98        });
99    }
100}
101
102pub use doc_entry::{DocEntry, HtmlOptions};
103pub use json_writer::write_raw_run as write_raw_run_to_path;
104pub use story::{StepDoc, Story};
105pub use types::*;
106
107/// Declare a feature for the current test file.
108///
109/// Rust runs nothing before a test binary's tests, so the declaration goes in a
110/// `#[test]` of its own. Put this at module scope, once per file:
111///
112/// ```
113/// use executable_stories::{declare_feature, Feature};
114///
115/// declare_feature!(
116///     Feature::new("Anyone can do arithmetic without a calculator app")
117///         .ability()
118///         .narrative("Switching apps for a quick sum loses your place.")
119/// );
120/// ```
121///
122/// Filtering tests (`cargo test add`) skips the generated test along with
123/// everything else it excludes, so that run's report carries no narrative.
124#[macro_export]
125macro_rules! declare_feature {
126    ($feature:expr $(,)?) => {
127        #[test]
128        fn declares_the_feature_for_this_file() {
129            $feature.declare(file!());
130        }
131    };
132}
133
134#[cfg(test)]
135mod source_file_tests {
136    use crate::Story;
137
138    /// A scenario must record the test file it was written in — the same path
139    /// `declare_feature!` writes through `file!()`. The report groups by that
140    /// path, so without it every scenario lands under "unknown" and its
141    /// file's feature declaration reaches nothing. This test lives outside
142    /// story.rs on purpose: if `#[track_caller]` were dropped, the recorded
143    /// file would be story.rs and this assertion would catch it.
144    #[test]
145    fn story_records_the_calling_file() {
146        let story = Story::new("a scenario declared in lib.rs");
147        let file = story.source_file.clone().expect("source file recorded");
148        assert!(file.ends_with("lib.rs"), "recorded {file}, want this file");
149    }
150}