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}