Skip to main content

oak_testing/
building.rs

1//! Builder testing utilities for the Oak ecosystem.
2//!
3//! This module provides comprehensive testing infrastructure for builders,
4//! including file-based testing, expected output comparison, timeout handling,
5//! and test result serialization for typed root structures.
6
7use crate::{create_file, json_from_path, source_from_path};
8use oak_core::{Builder, Language, errors::OakError};
9use serde::{Deserialize, Serialize};
10use serde_json::Value as JsonValue;
11
12use std::{
13    fmt::Debug,
14    path::{Path, PathBuf},
15    time::Duration,
16};
17use walkdir::WalkDir;
18
19/// A concurrent builder testing utility that can run tests against multiple files with timeout support.
20///
21/// The `BuilderTester` provides functionality to test builders against a directory
22/// of files with specific extensions, comparing actual output against expected
23/// results stored in JSON files, with configurable timeout protection.
24pub struct BuilderTester {
25    root: PathBuf,
26    extensions: Vec<String>,
27    timeout: Duration,
28}
29
30/// Expected builder test results for comparison.
31///
32/// This struct represents the expected output of a builder test, including
33/// success status, typed root structure, and any expected errors.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct BuilderTestExpected {
36    /// Whether the build was expected to succeed.
37    pub success: bool,
38    /// The expected typed root data, if any.
39    pub typed_root: Option<TypedRootData>,
40    /// Any expected error messages.
41    pub errors: Vec<String>,
42}
43
44/// Typed root data structure for builder testing.
45///
46/// Represents the typed root structure with its type name and serialized content
47/// used for testing builder output. Since TypedRoot can be any type, we serialize
48/// it as a generic structure for comparison.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct TypedRootData {
51    /// The name of the type.
52    pub type_name: String,
53    /// The serialized content of the type.
54    pub content: JsonValue,
55}
56
57impl BuilderTester {
58    /// Creates a new builder tester with the specified root directory and default 10-second timeout.
59    pub fn new<P: AsRef<Path>>(root: P) -> Self {
60        Self { root: root.as_ref().to_path_buf(), extensions: vec![], timeout: Duration::from_secs(10) }
61    }
62
63    /// Adds a file extension to test against.
64    pub fn with_extension(mut self, extension: impl ToString) -> Self {
65        self.extensions.push(extension.to_string());
66        self
67    }
68
69    /// Sets the timeout for building operations.
70    pub fn with_timeout(mut self, timeout: Duration) -> Self {
71        self.timeout = timeout;
72        self
73    }
74
75    /// Run tests for the given builder against all files in the root directory with the specified extensions.
76    pub fn run_tests<L, B>(self, builder: &B) -> Result<(), OakError>
77    where
78        B: Builder<L> + Send + Sync,
79        L: Language + Send + Sync,
80        L::TypedRoot: Serialize + Debug + Sync + Send,
81    {
82        let test_files = self.find_test_files()?;
83        let force_regenerated = std::env::var("REGENERATE_TESTS").unwrap_or("0".to_string()) == "1";
84        let mut regenerated_any = false;
85
86        for file_path in test_files {
87            println!("Testing file: {}", file_path.display());
88            regenerated_any |= self.test_single_file::<L, B>(&file_path, builder, force_regenerated)?
89        }
90
91        if regenerated_any && force_regenerated {
92            println!("Tests regenerated for: {}", self.root.display());
93            Ok(())
94        }
95        else {
96            Ok(())
97        }
98    }
99
100    fn find_test_files(&self) -> Result<Vec<PathBuf>, OakError> {
101        let mut files = Vec::new();
102
103        for entry in WalkDir::new(&self.root) {
104            let entry = entry.unwrap();
105            let path = entry.path();
106
107            if path.is_file() {
108                if let Some(ext) = path.extension() {
109                    let ext_str = ext.to_str().unwrap_or("");
110                    if self.extensions.iter().any(|e| e == ext_str) {
111                        // Ignore output files generated by the Tester itself to prevent recursive inclusion
112                        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
113                        let is_output_file = file_name.ends_with(".parsed.json") || file_name.ends_with(".lexed.json") || file_name.ends_with(".built.json") || file_name.ends_with(".expected.json");
114
115                        if !is_output_file {
116                            files.push(path.to_path_buf());
117                        }
118                    }
119                }
120            }
121        }
122
123        Ok(files)
124    }
125
126    fn test_single_file<L, B>(&self, file_path: &Path, builder: &B, force_regenerated: bool) -> Result<bool, OakError>
127    where
128        B: Builder<L> + Send + Sync,
129        L: Language + Send + Sync,
130        L::TypedRoot: Serialize + Debug + Sync + Send,
131    {
132        let source = source_from_path(file_path)?;
133
134        // Perform build in a thread and construct test results, with main thread handling timeout control
135        use std::sync::mpsc;
136        let (tx, rx) = mpsc::channel();
137        let timeout = self.timeout;
138        let file_path_string = file_path.display().to_string();
139
140        std::thread::scope(|s| {
141            s.spawn(move || {
142                let mut cache = oak_core::parser::ParseSession::<L>::new(1024);
143                let build_out = builder.build(&source, &[], &mut cache);
144
145                // Build typed root structure if build succeeded
146                let (success, typed_root) = match &build_out.result {
147                    Ok(root) => {
148                        // Serialize the typed root to JSON for comparison
149                        match serde_json::to_value(root) {
150                            Ok(content) => {
151                                let typed_root_data = TypedRootData { type_name: std::any::type_name::<L::TypedRoot>().to_string(), content };
152                                (true, Some(typed_root_data))
153                            }
154                            Err(_) => {
155                                // If serialization fails, still mark as success but with no typed root data
156                                (true, None)
157                            }
158                        }
159                    }
160                    Err(_) => (false, None),
161                };
162
163                // Collect error messages
164                let mut error_messages: Vec<String> = build_out.diagnostics.iter().map(|e| e.to_string()).collect();
165                if let Err(e) = &build_out.result {
166                    error_messages.push(e.to_string());
167                }
168
169                let test_result = BuilderTestExpected { success, typed_root, errors: error_messages };
170
171                let _ = tx.send(Ok::<BuilderTestExpected, OakError>(test_result));
172            });
173
174            let mut regenerated = false;
175            match rx.recv_timeout(timeout) {
176                Ok(Ok(test_result)) => {
177                    let expected_file = file_path.with_extension(format!("{}.built.json", file_path.extension().unwrap_or_default().to_str().unwrap_or("")));
178
179                    // Migration: If the new naming convention file doesn't exist, but the old one does, rename it
180                    if !expected_file.exists() {
181                        let legacy_file = file_path.with_extension("expected.json");
182                        if legacy_file.exists() {
183                            let _ = std::fs::rename(&legacy_file, &expected_file);
184                        }
185                    }
186
187                    if expected_file.exists() && !force_regenerated {
188                        let expected_json = json_from_path(&expected_file)?;
189                        let expected: BuilderTestExpected = serde_json::from_value(expected_json).map_err(|e| OakError::custom_error(e.to_string()))?;
190                        if test_result != expected {
191                            return Err(OakError::test_failure(file_path.to_path_buf(), format!("{:#?}", expected), format!("{:#?}", test_result)));
192                        }
193                    }
194                    else {
195                        use std::io::Write;
196                        let mut file = create_file(&expected_file)?;
197                        let json_val = serde_json::to_string_pretty(&test_result).map_err(|e| OakError::custom_error(e.to_string()))?;
198                        file.write_all(json_val.as_bytes()).map_err(|e| OakError::custom_error(e.to_string()))?;
199                        regenerated = true;
200                    }
201                }
202                Ok(Err(e)) => return Err(e),
203                Err(mpsc::RecvTimeoutError::Timeout) => return Err(OakError::custom_error(format!("Builder test timed out after {:?} for file: {}", timeout, file_path_string))),
204                Err(mpsc::RecvTimeoutError::Disconnected) => return Err(OakError::custom_error("Builder thread disconnected unexpectedly")),
205            }
206            Ok(regenerated)
207        })
208    }
209}