executable_stories/
collector.rs1use std::path::{Path, PathBuf};
2use std::sync::{Mutex, Once};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use crate::json_writer;
6use crate::types::{RawCIInfo, RawRun, RawTestCase};
7
8static COLLECTED: Mutex<Vec<RawTestCase>> = Mutex::new(Vec::new());
9static FEATURES: Mutex<Vec<crate::types::RawFeature>> = Mutex::new(Vec::new());
10static ORDER_SEQ: Mutex<u32> = Mutex::new(0);
11static STARTED_AT_MS: Mutex<Option<f64>> = Mutex::new(None);
14
15static EXIT_HOOK: Once = Once::new();
16
17const GIT_TIMEOUT: Duration = Duration::from_secs(5);
19
20pub(crate) fn epoch_ms() -> f64 {
21 SystemTime::now().duration_since(UNIX_EPOCH).map_or(0.0, |d| d.as_secs_f64() * 1000.0)
22}
23
24extern "C" fn write_results_at_exit() {
25 let _ = std::panic::catch_unwind(write_results);
28}
29
30pub(crate) fn ensure_exit_hook() {
36 EXIT_HOOK.call_once(|| {
37 *STARTED_AT_MS.lock().unwrap() = Some(epoch_ms());
38 unsafe {
42 libc::atexit(write_results_at_exit);
43 }
44 });
45}
46
47pub fn record(tc: RawTestCase) {
49 COLLECTED.lock().unwrap().push(tc);
50}
51
52pub fn next_order() -> u32 {
54 let mut seq = ORDER_SEQ.lock().unwrap();
55 let val = *seq;
56 *seq += 1;
57 val
58}
59
60pub fn get_all() -> Vec<RawTestCase> {
62 COLLECTED.lock().unwrap().clone()
63}
64
65pub(crate) fn record_feature(feature: crate::types::RawFeature) {
68 let mut features = FEATURES.lock().unwrap();
69 match features.iter_mut().find(|f| f.source_file == feature.source_file) {
70 Some(existing) => *existing = feature,
71 None => features.push(feature),
72 }
73}
74
75pub(crate) fn get_features() -> Vec<crate::types::RawFeature> {
77 FEATURES.lock().unwrap().clone()
78}
79
80fn detect_ci() -> Option<RawCIInfo> {
81 if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") {
82 let url = match (
83 std::env::var("GITHUB_SERVER_URL"),
84 std::env::var("GITHUB_REPOSITORY"),
85 std::env::var("GITHUB_RUN_ID"),
86 ) {
87 (Ok(s), Ok(r), Ok(id)) => Some(format!("{s}/{r}/actions/runs/{id}")),
88 _ => None,
89 };
90 return Some(RawCIInfo {
91 name: "github".to_string(),
92 build_number: std::env::var("GITHUB_RUN_NUMBER").ok(),
93 url,
94 });
95 }
96 if std::env::var("CIRCLECI").as_deref() == Ok("true") {
97 return Some(RawCIInfo {
98 name: "circleci".to_string(),
99 build_number: std::env::var("CIRCLE_BUILD_NUM").ok(),
100 url: std::env::var("CIRCLE_BUILD_URL").ok(),
101 });
102 }
103 if std::env::var("JENKINS_URL").is_ok() {
104 return Some(RawCIInfo {
105 name: "jenkins".to_string(),
106 build_number: std::env::var("BUILD_NUMBER").ok(),
107 url: std::env::var("BUILD_URL").ok(),
108 });
109 }
110 if std::env::var("TRAVIS").as_deref() == Ok("true") {
111 return Some(RawCIInfo {
112 name: "travis".to_string(),
113 build_number: std::env::var("TRAVIS_BUILD_NUMBER").ok(),
114 url: std::env::var("TRAVIS_BUILD_WEB_URL").ok(),
115 });
116 }
117 if std::env::var("GITLAB_CI").as_deref() == Ok("true") {
118 return Some(RawCIInfo {
119 name: "gitlab".to_string(),
120 build_number: std::env::var("CI_PIPELINE_IID").ok(),
121 url: std::env::var("CI_PIPELINE_URL").ok(),
122 });
123 }
124 if std::env::var("CI").as_deref() == Ok("true") {
125 return Some(RawCIInfo { name: "ci".to_string(), build_number: None, url: None });
126 }
127 None
128}
129
130const VALUE_TAKING_FLAGS: &[&str] =
141 &["--test-threads", "--logfile", "--format", "--color", "--skip", "-Z", "--shuffle-seed"];
142
143fn is_name_filtered(args: &[String]) -> bool {
150 let mut rest = args.iter().skip(1); while let Some(arg) = rest.next() {
152 if arg == "--skip" || arg == "--ignored" {
155 return true;
156 }
157 if VALUE_TAKING_FLAGS.contains(&arg.as_str()) {
158 rest.next(); continue;
160 }
161 if arg == "--" || arg.starts_with('-') {
162 continue;
163 }
164 return true;
165 }
166 false
167}
168
169fn resolve_output_path(project_root: &str) -> PathBuf {
175 let declared = std::env::var("EXECUTABLE_STORIES_OUTPUT")
176 .unwrap_or_else(|_| ".executable-stories/raw-run.json".to_string());
177 Path::new(project_root).join(declared)
178}
179
180fn git_sha(project_root: &str) -> Option<String> {
185 for key in ["GITHUB_SHA", "GIT_COMMIT", "CI_COMMIT_SHA"] {
186 if let Ok(value) = std::env::var(key) {
187 if !value.is_empty() {
188 return Some(value);
189 }
190 }
191 }
192
193 let mut child = std::process::Command::new("git")
194 .args(["rev-parse", "HEAD"])
195 .current_dir(project_root)
196 .stdout(std::process::Stdio::piped())
197 .stderr(std::process::Stdio::null())
200 .stdin(std::process::Stdio::null())
201 .spawn()
202 .ok()?;
203
204 let deadline = std::time::Instant::now() + GIT_TIMEOUT;
205 loop {
206 match child.try_wait() {
207 Ok(Some(status)) => {
208 if !status.success() {
209 return None;
210 }
211 break;
212 }
213 Ok(None) => {
214 if std::time::Instant::now() >= deadline {
215 let _ = child.kill();
216 let _ = child.wait();
217 return None;
218 }
219 std::thread::sleep(Duration::from_millis(10));
220 }
221 Err(_) => return None,
222 }
223 }
224
225 let mut output = String::new();
226 std::io::Read::read_to_string(child.stdout.as_mut()?, &mut output).ok()?;
227 let sha = output.trim().to_string();
228 if sha.is_empty() { None } else { Some(sha) }
229}
230
231#[allow(clippy::missing_panics_doc)]
235pub fn write_results() {
236 let cases = get_all();
237 if cases.is_empty() {
238 return;
239 }
240
241 let cwd = std::env::current_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
242 let output = resolve_output_path(&cwd);
243
244 let run = RawRun {
245 schema_version: 1,
246 test_cases: cases,
247 features: get_features(),
248 project_root: cwd.clone(),
249 started_at_ms: *STARTED_AT_MS.lock().unwrap(),
250 finished_at_ms: Some(epoch_ms()),
251 ci: detect_ci(),
252 git_sha: git_sha(&cwd),
253 package_version: Some(env!("CARGO_PKG_VERSION").to_string()),
254 run_scope: Some(if is_name_filtered(&std::env::args().collect::<Vec<_>>()) {
255 "filtered".to_string()
256 } else {
257 "full".to_string()
258 }),
259 };
260
261 let output = output.to_string_lossy().to_string();
262 json_writer::write_raw_run(&run, &output).expect("Failed to write raw run JSON");
263 print_next_step(&output);
264}
265
266fn print_next_step(output_path: &str) {
273 if std::env::var_os("EXECUTABLE_STORIES_QUIET").is_some() {
274 return;
275 }
276 eprintln!("\nexecutable-stories: wrote {output_path}");
277 eprintln!(" next: executable-stories format {output_path} --format html");
278}
279
280#[allow(dead_code)]
282pub fn reset() {
283 COLLECTED.lock().unwrap().clear();
284 FEATURES.lock().unwrap().clear();
285 *ORDER_SEQ.lock().unwrap() = 0;
286}
287
288#[cfg(test)]
289mod output_path_tests {
290 use super::resolve_output_path;
291
292 #[test]
293 fn a_relative_override_lands_under_the_project_root() {
294 temp_env("reports/raw-run.json", || {
295 assert_eq!(
296 resolve_output_path("/projects/app"),
297 std::path::Path::new("/projects/app/reports/raw-run.json")
298 );
299 });
300 }
301
302 #[test]
303 fn an_absolute_override_is_used_as_given() {
304 temp_env("/elsewhere/raw-run.json", || {
305 assert_eq!(
306 resolve_output_path("/projects/app"),
307 std::path::Path::new("/elsewhere/raw-run.json")
308 );
309 });
310 }
311
312 fn temp_env(value: &str, body: impl FnOnce()) {
314 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
315 let _guard = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
316 unsafe { std::env::set_var("EXECUTABLE_STORIES_OUTPUT", value) };
318 body();
319 unsafe { std::env::remove_var("EXECUTABLE_STORIES_OUTPUT") };
320 }
321}
322
323#[cfg(test)]
324mod name_filter_tests {
325 use super::is_name_filtered;
326
327 fn args(list: &[&str]) -> Vec<String> {
328 list.iter().map(|s| (*s).to_string()).collect()
329 }
330
331 #[test]
332 fn plain_run_is_not_filtered() {
333 assert!(!is_name_filtered(&args(&["target/debug/deps/story-abc123"])));
334 }
335
336 #[test]
337 fn positional_filter_marks_the_run_filtered() {
338 assert!(is_name_filtered(&args(&["bin", "refuses"])));
340 }
341
342 #[test]
343 fn skip_flag_marks_the_run_filtered() {
344 assert!(is_name_filtered(&args(&["bin", "--skip", "slow"])));
345 }
346
347 #[test]
348 fn libtest_flags_alone_are_not_a_filter() {
349 assert!(!is_name_filtered(&args(&["bin", "--nocapture", "--exact"])));
350 }
351
352 #[test]
353 fn ignored_only_marks_the_run_filtered() {
354 assert!(is_name_filtered(&args(&["bin", "--ignored"])));
355 }
356
357 #[test]
358 fn including_ignored_tests_is_not_a_filter() {
359 assert!(!is_name_filtered(&args(&["bin", "--include-ignored"])));
360 }
361
362 #[test]
363 fn a_flags_value_is_not_mistaken_for_a_filter() {
364 assert!(!is_name_filtered(&args(&["bin", "--test-threads", "1"])));
366 assert!(!is_name_filtered(&args(&["bin", "--format", "terse"])));
367 }
368}