hindsight_tests/
nextest.rs

1// Copyright (c) 2026 - present Nicholas D. Crosbie
2// SPDX-License-Identifier: MIT
3
4//! Nextest output parsing
5//!
6//! This module provides functionality to parse cargo-nextest output in various formats:
7//! - `nextest list --message-format json` for test discovery
8//! - `nextest run --message-format libtest-json` for test execution results
9//!
10//! # Example
11//!
12//! ```no_run
13//! use hindsight_tests::nextest::{parse_list_output, parse_run_output};
14//!
15//! // Parse test list
16//! let list_json = r#"{"test-count": 5, "rust-suites": {}}"#;
17//! let list = parse_list_output(list_json).unwrap();
18//!
19//! // Parse run output (line-delimited JSON)
20//! let run_output = r#"{"type":"suite","event":"started","test_count":1}"#;
21//! let run = parse_run_output(run_output).unwrap();
22//! ```
23
24use crate::error::TestsError;
25use crate::result::{TestOutcome, TestResult};
26use chrono::Utc;
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30// ============================================================================
31// Test List Types (from `cargo nextest list --message-format json`)
32// ============================================================================
33
34/// Output from `cargo nextest list --message-format json`
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct TestList {
37    /// Total number of tests
38    #[serde(rename = "test-count")]
39    pub test_count: usize,
40    /// Test suites by binary ID
41    #[serde(rename = "rust-suites")]
42    pub rust_suites: HashMap<String, TestSuite>,
43}
44
45/// A test suite (binary containing tests)
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct TestSuite {
48    /// Package name
49    #[serde(rename = "package-name")]
50    pub package_name: String,
51    /// Binary ID
52    #[serde(rename = "binary-id")]
53    pub binary_id: String,
54    /// Binary name
55    #[serde(rename = "binary-name")]
56    pub binary_name: String,
57    /// Kind of binary (lib, bin, test, etc.)
58    pub kind: String,
59    /// Test cases in this suite
60    pub testcases: HashMap<String, TestCase>,
61}
62
63/// A single test case in a suite
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct TestCase {
66    /// Kind of test (usually "test")
67    pub kind: String,
68    /// Whether the test is ignored
69    pub ignored: bool,
70}
71
72impl TestList {
73    /// Get all test names across all suites
74    #[must_use]
75    pub fn all_test_names(&self) -> Vec<String> {
76        let mut names = Vec::new();
77        for (suite_id, suite) in &self.rust_suites {
78            for test_name in suite.testcases.keys() {
79                names.push(format!("{}::{}", suite_id, test_name));
80            }
81        }
82        names
83    }
84
85    /// Get all test names in a specific suite
86    #[must_use]
87    pub fn tests_in_suite(&self, suite_id: &str) -> Vec<&str> {
88        self.rust_suites
89            .get(suite_id)
90            .map(|s| s.testcases.keys().map(String::as_str).collect())
91            .unwrap_or_default()
92    }
93
94    /// Count ignored tests
95    #[must_use]
96    pub fn ignored_count(&self) -> usize {
97        self.rust_suites
98            .values()
99            .flat_map(|s| s.testcases.values())
100            .filter(|tc| tc.ignored)
101            .count()
102    }
103}
104
105// ============================================================================
106// Test Run Types (from `cargo nextest run --message-format libtest-json`)
107// ============================================================================
108
109/// A single event from libtest JSON output
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(tag = "type", rename_all = "lowercase")]
112pub enum LibtestEvent {
113    /// Suite started event
114    Suite(SuiteEvent),
115    /// Test event (started, ok, failed, ignored)
116    Test(TestEvent),
117}
118
119/// Suite-level event
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct SuiteEvent {
122    /// Event type: "started" or "ok"/"failed"
123    pub event: String,
124    /// Number of tests (only in "started" event)
125    pub test_count: Option<usize>,
126    /// Number of passed tests (in final event)
127    pub passed: Option<usize>,
128    /// Number of failed tests (in final event)
129    pub failed: Option<usize>,
130    /// Number of ignored tests (in final event)
131    pub ignored: Option<usize>,
132    /// Execution time (in final event)
133    pub exec_time: Option<f64>,
134}
135
136/// Test-level event
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct TestEvent {
139    /// Event type: "started", "ok", "failed", "ignored"
140    pub event: String,
141    /// Full test name including binary
142    pub name: String,
143    /// Execution time in seconds (only in finished events)
144    pub exec_time: Option<f64>,
145    /// Stdout output (only in failed events)
146    pub stdout: Option<String>,
147}
148
149/// Aggregated results from a test run
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct TestRunSummary {
152    /// Total tests run
153    pub total: usize,
154    /// Tests passed
155    pub passed: usize,
156    /// Tests failed
157    pub failed: usize,
158    /// Tests ignored
159    pub ignored: usize,
160    /// Total execution time in seconds
161    pub exec_time_secs: f64,
162    /// Individual test results
163    pub results: Vec<TestResult>,
164}
165
166impl TestRunSummary {
167    /// Create an empty summary
168    #[must_use]
169    pub fn empty() -> Self {
170        Self {
171            total: 0,
172            passed: 0,
173            failed: 0,
174            ignored: 0,
175            exec_time_secs: 0.0,
176            results: Vec::new(),
177        }
178    }
179
180    /// Check if all tests passed
181    #[must_use]
182    pub fn all_passed(&self) -> bool {
183        self.failed == 0
184    }
185
186    /// Get failing tests
187    #[must_use]
188    pub fn failing_tests(&self) -> Vec<&TestResult> {
189        self.results.iter().filter(|r| r.failed()).collect()
190    }
191}
192
193// ============================================================================
194// Parsing Functions
195// ============================================================================
196
197/// Parse `cargo nextest list --message-format json` output
198///
199/// # Errors
200///
201/// Returns `TestsError::JsonParse` if the JSON is invalid.
202pub fn parse_list_output(json: &str) -> Result<TestList, TestsError> {
203    serde_json::from_str(json).map_err(TestsError::from)
204}
205
206/// Parse `cargo nextest run --message-format libtest-json` output
207///
208/// The output is newline-delimited JSON.
209///
210/// # Errors
211///
212/// Returns `TestsError::JsonParse` if any line is invalid JSON.
213pub fn parse_run_output(output: &str) -> Result<TestRunSummary, TestsError> {
214    let mut summary = TestRunSummary::empty();
215    let mut pending_tests: HashMap<String, chrono::DateTime<Utc>> = HashMap::new();
216    let now = Utc::now();
217
218    for line in output.lines() {
219        let line = line.trim();
220        if line.is_empty() {
221            continue;
222        }
223
224        let event: LibtestEvent = serde_json::from_str(line)?;
225
226        match event {
227            LibtestEvent::Suite(suite) => {
228                if suite.event == "started" {
229                    if let Some(count) = suite.test_count {
230                        summary.total = count;
231                    }
232                } else {
233                    // Final summary event
234                    if let Some(passed) = suite.passed {
235                        summary.passed = passed;
236                    }
237                    if let Some(failed) = suite.failed {
238                        summary.failed = failed;
239                    }
240                    if let Some(ignored) = suite.ignored {
241                        summary.ignored = ignored;
242                    }
243                    if let Some(exec_time) = suite.exec_time {
244                        summary.exec_time_secs = exec_time;
245                    }
246                }
247            }
248            LibtestEvent::Test(test) => {
249                if test.event == "started" {
250                    pending_tests.insert(test.name.clone(), now);
251                } else {
252                    // Test finished
253                    let outcome = match test.event.as_str() {
254                        "ok" => TestOutcome::Passed,
255                        "failed" => TestOutcome::Failed,
256                        "ignored" => TestOutcome::Ignored,
257                        _ => TestOutcome::Failed,
258                    };
259
260                    let duration_ms = test.exec_time.map(|t| (t * 1000.0) as u64).unwrap_or(0);
261
262                    // Parse the name - nextest format: "binary-id::binary_name$test::path"
263                    let test_name = normalize_test_name(&test.name);
264
265                    let result = TestResult {
266                        name: test_name,
267                        outcome,
268                        duration_ms,
269                        timestamp: now,
270                        output: test.stdout,
271                    };
272
273                    summary.results.push(result);
274                    pending_tests.remove(&test.name);
275                }
276            }
277        }
278    }
279
280    Ok(summary)
281}
282
283/// Normalize a nextest test name to a clean format
284///
285/// Input: "hindsight-tests::hindsight_tests$result::tests::test_name"
286/// Output: "result::tests::test_name"
287fn normalize_test_name(name: &str) -> String {
288    // Find the $ separator that nextest uses
289    if let Some(idx) = name.find('$') {
290        name[idx + 1..].to_string()
291    } else if let Some(idx) = name.find("::") {
292        // Fallback: skip binary prefix
293        name[idx + 2..].to_string()
294    } else {
295        name.to_string()
296    }
297}
298
299/// Parse a single libtest JSON event
300///
301/// # Errors
302///
303/// Returns `TestsError::JsonParse` if the JSON is invalid.
304pub fn parse_event(json: &str) -> Result<LibtestEvent, TestsError> {
305    serde_json::from_str(json).map_err(TestsError::from)
306}
307
308// ============================================================================
309// Streaming Parser for incremental parsing
310// ============================================================================
311
312/// A streaming parser for libtest JSON output
313pub struct StreamingParser {
314    pending_tests: HashMap<String, chrono::DateTime<Utc>>,
315    results: Vec<TestResult>,
316    total: usize,
317}
318
319impl StreamingParser {
320    /// Create a new streaming parser
321    #[must_use]
322    pub fn new() -> Self {
323        Self {
324            pending_tests: HashMap::new(),
325            results: Vec::new(),
326            total: 0,
327        }
328    }
329
330    /// Process a single line of output
331    ///
332    /// # Errors
333    ///
334    /// Returns `TestsError::JsonParse` if the line is invalid JSON.
335    pub fn process_line(&mut self, line: &str) -> Result<Option<TestResult>, TestsError> {
336        let line = line.trim();
337        if line.is_empty() {
338            return Ok(None);
339        }
340
341        let event: LibtestEvent = serde_json::from_str(line)?;
342        let now = Utc::now();
343
344        match event {
345            LibtestEvent::Suite(suite) => {
346                if suite.event == "started"
347                    && let Some(count) = suite.test_count
348                {
349                    self.total = count;
350                }
351                Ok(None)
352            }
353            LibtestEvent::Test(test) => {
354                if test.event == "started" {
355                    self.pending_tests.insert(test.name.clone(), now);
356                    Ok(None)
357                } else {
358                    let outcome = match test.event.as_str() {
359                        "ok" => TestOutcome::Passed,
360                        "failed" => TestOutcome::Failed,
361                        "ignored" => TestOutcome::Ignored,
362                        _ => TestOutcome::Failed,
363                    };
364
365                    let duration_ms = test.exec_time.map(|t| (t * 1000.0) as u64).unwrap_or(0);
366
367                    let test_name = normalize_test_name(&test.name);
368
369                    let result = TestResult {
370                        name: test_name,
371                        outcome,
372                        duration_ms,
373                        timestamp: now,
374                        output: test.stdout,
375                    };
376
377                    self.results.push(result.clone());
378                    self.pending_tests.remove(&test.name);
379                    Ok(Some(result))
380                }
381            }
382        }
383    }
384
385    /// Get all accumulated results
386    #[must_use]
387    pub fn results(&self) -> &[TestResult] {
388        &self.results
389    }
390
391    /// Finalize and return summary
392    #[must_use]
393    pub fn into_summary(self) -> TestRunSummary {
394        let passed = self.results.iter().filter(|r| r.passed()).count();
395        let failed = self.results.iter().filter(|r| r.failed()).count();
396        let ignored = self
397            .results
398            .iter()
399            .filter(|r| r.outcome == TestOutcome::Ignored)
400            .count();
401        let exec_time_secs =
402            self.results.iter().map(|r| r.duration_ms).sum::<u64>() as f64 / 1000.0;
403
404        TestRunSummary {
405            total: self.total,
406            passed,
407            failed,
408            ignored,
409            exec_time_secs,
410            results: self.results,
411        }
412    }
413}
414
415impl Default for StreamingParser {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use similar_asserts::assert_eq;
425
426    #[test]
427    fn test_parse_list_output() {
428        let json = r#"{
429            "test-count": 2,
430            "rust-suites": {
431                "my-crate": {
432                    "package-name": "my-crate",
433                    "binary-id": "my-crate",
434                    "binary-name": "my_crate",
435                    "kind": "lib",
436                    "testcases": {
437                        "tests::test_one": {"kind": "test", "ignored": false},
438                        "tests::test_two": {"kind": "test", "ignored": true}
439                    }
440                }
441            }
442        }"#;
443
444        let list = parse_list_output(json).expect("Should parse");
445        assert_eq!(list.test_count, 2);
446        assert_eq!(list.rust_suites.len(), 1);
447        assert_eq!(list.ignored_count(), 1);
448    }
449
450    #[test]
451    fn test_parse_run_output_single_test() {
452        let output = r#"{"type":"suite","event":"started","test_count":1}
453{"type":"test","event":"started","name":"my-crate::my_crate$tests::test_one"}
454{"type":"test","event":"ok","name":"my-crate::my_crate$tests::test_one","exec_time":0.015}
455{"type":"suite","event":"ok","passed":1,"failed":0,"ignored":0,"exec_time":0.015}"#;
456
457        let summary = parse_run_output(output).expect("Should parse");
458        assert_eq!(summary.passed, 1);
459        assert_eq!(summary.failed, 0);
460        assert_eq!(summary.results.len(), 1);
461        assert_eq!(summary.results[0].name, "tests::test_one");
462        assert!(summary.results[0].passed());
463    }
464
465    #[test]
466    fn test_parse_run_output_failed_test() {
467        let output = r#"{"type":"suite","event":"started","test_count":1}
468{"type":"test","event":"started","name":"crate::bin$mod::test_fail"}
469{"type":"test","event":"failed","name":"crate::bin$mod::test_fail","exec_time":0.005,"stdout":"assertion failed"}
470{"type":"suite","event":"failed","passed":0,"failed":1,"ignored":0,"exec_time":0.005}"#;
471
472        let summary = parse_run_output(output).expect("Should parse");
473        assert_eq!(summary.failed, 1);
474        assert!(summary.results[0].failed());
475        assert_eq!(
476            summary.results[0].output,
477            Some("assertion failed".to_string())
478        );
479    }
480
481    #[test]
482    fn test_parse_run_output_multiple_tests() {
483        let output = r#"{"type":"suite","event":"started","test_count":3}
484{"type":"test","event":"started","name":"c::b$test_a"}
485{"type":"test","event":"ok","name":"c::b$test_a","exec_time":0.001}
486{"type":"test","event":"started","name":"c::b$test_b"}
487{"type":"test","event":"ignored","name":"c::b$test_b","exec_time":0.0}
488{"type":"test","event":"started","name":"c::b$test_c"}
489{"type":"test","event":"ok","name":"c::b$test_c","exec_time":0.002}
490{"type":"suite","event":"ok","passed":2,"failed":0,"ignored":1,"exec_time":0.003}"#;
491
492        let summary = parse_run_output(output).expect("Should parse");
493        assert_eq!(summary.total, 3);
494        assert_eq!(summary.passed, 2);
495        assert_eq!(summary.ignored, 1);
496        assert_eq!(summary.results.len(), 3);
497    }
498
499    #[test]
500    fn test_normalize_test_name() {
501        assert_eq!(
502            normalize_test_name("hindsight-tests::hindsight_tests$result::tests::test_passed"),
503            "result::tests::test_passed"
504        );
505        assert_eq!(
506            normalize_test_name("crate::binary$module::test"),
507            "module::test"
508        );
509        assert_eq!(normalize_test_name("simple_test"), "simple_test");
510    }
511
512    #[test]
513    fn test_streaming_parser() {
514        let mut parser = StreamingParser::new();
515
516        let result = parser
517            .process_line(r#"{"type":"suite","event":"started","test_count":1}"#)
518            .expect("Should parse");
519        assert!(result.is_none());
520
521        let result = parser
522            .process_line(r#"{"type":"test","event":"started","name":"c::b$t"}"#)
523            .expect("Should parse");
524        assert!(result.is_none());
525
526        let result = parser
527            .process_line(r#"{"type":"test","event":"ok","name":"c::b$t","exec_time":0.01}"#)
528            .expect("Should parse");
529        assert!(result.is_some());
530        assert_eq!(result.unwrap().name, "t");
531
532        let summary = parser.into_summary();
533        assert_eq!(summary.passed, 1);
534    }
535
536    #[test]
537    fn test_test_run_summary_helpers() {
538        let summary = TestRunSummary {
539            total: 3,
540            passed: 2,
541            failed: 1,
542            ignored: 0,
543            exec_time_secs: 0.1,
544            results: vec![
545                TestResult {
546                    name: "test_pass".to_string(),
547                    outcome: TestOutcome::Passed,
548                    duration_ms: 50,
549                    timestamp: Utc::now(),
550                    output: None,
551                },
552                TestResult {
553                    name: "test_fail".to_string(),
554                    outcome: TestOutcome::Failed,
555                    duration_ms: 50,
556                    timestamp: Utc::now(),
557                    output: Some("error".to_string()),
558                },
559            ],
560        };
561
562        assert!(!summary.all_passed());
563        assert_eq!(summary.failing_tests().len(), 1);
564        assert_eq!(summary.failing_tests()[0].name, "test_fail");
565    }
566
567    #[test]
568    fn test_test_list_helpers() {
569        let json = r#"{
570            "test-count": 3,
571            "rust-suites": {
572                "suite-a": {
573                    "package-name": "a",
574                    "binary-id": "suite-a",
575                    "binary-name": "a",
576                    "kind": "lib",
577                    "testcases": {
578                        "test_1": {"kind": "test", "ignored": false},
579                        "test_2": {"kind": "test", "ignored": false}
580                    }
581                },
582                "suite-b": {
583                    "package-name": "b",
584                    "binary-id": "suite-b",
585                    "binary-name": "b",
586                    "kind": "lib",
587                    "testcases": {
588                        "test_3": {"kind": "test", "ignored": true}
589                    }
590                }
591            }
592        }"#;
593
594        let list = parse_list_output(json).expect("Should parse");
595        let all_names = list.all_test_names();
596        assert_eq!(all_names.len(), 3);
597
598        let suite_a_tests = list.tests_in_suite("suite-a");
599        assert_eq!(suite_a_tests.len(), 2);
600
601        assert_eq!(list.ignored_count(), 1);
602    }
603
604    #[test]
605    fn test_parse_empty_output() {
606        let summary = parse_run_output("").expect("Should parse empty");
607        assert_eq!(summary.total, 0);
608        assert_eq!(summary.results.len(), 0);
609    }
610
611    #[test]
612    fn test_parse_invalid_json() {
613        let result = parse_run_output("not json");
614        assert!(result.is_err());
615    }
616}