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, source_from_path};
8use oak_core::{Builder, Language, errors::OakError};
9
10#[cfg(feature = "serde")]
11use crate::json_from_path;
12#[cfg(feature = "serde")]
13use serde::Serialize;
14
15#[cfg(feature = "serde")]
16use serde_json::Value as JsonValueNode;
17
18use std::{
19    fmt::Debug,
20    path::{Path, PathBuf},
21    time::Duration,
22};
23use walkdir::WalkDir;
24
25/// A concurrent builder testing utility that can run tests against multiple files with timeout support.
26///
27/// The `BuilderTester` provides functionality to test builders against a directory
28/// of files with specific extensions, comparing actual output against expected
29/// results stored in JSON files, with configurable timeout protection.
30pub struct BuilderTester {
31    root: PathBuf,
32    extensions: Vec<String>,
33    timeout: Duration,
34}
35
36/// Expected builder test results for comparison.
37///
38/// This struct represents the expected output of a builder test, including
39/// success status, typed root structure, and any expected errors.
40#[derive(Debug, Clone, PartialEq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct BuilderTestExpected {
43    /// Whether the build was expected to succeed.
44    pub success: bool,
45    /// The expected typed root data, if any.
46    pub typed_root: Option<TypedRootData>,
47    /// Any expected error messages.
48    pub errors: Vec<String>,
49}
50
51/// Typed root data structure for builder testing.
52///
53/// Represents the typed root structure with its type name and serialized content
54/// used for testing builder output. Since TypedRoot can be any type, we serialize
55/// it as a generic structure for comparison.
56#[derive(Debug, Clone, PartialEq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub struct TypedRootData {
59    /// The name of the type.
60    pub type_name: String,
61    /// The serialized content of the type.
62    #[cfg(feature = "serde")]
63    pub content: JsonValueNode,
64    #[cfg(not(feature = "serde"))]
65    pub content: (),
66}
67
68impl BuilderTester {
69    /// Creates a new builder tester with the specified root directory and default 10-second timeout.
70    pub fn new<P: AsRef<Path>>(root: P) -> Self {
71        Self { root: root.as_ref().to_path_buf(), extensions: vec![], timeout: Duration::from_secs(10) }
72    }
73
74    /// Adds a file extension to test against.
75    pub fn with_extension(mut self, extension: impl ToString) -> Self {
76        self.extensions.push(extension.to_string());
77        self
78    }
79
80    /// Sets the timeout for building operations.
81    pub fn with_timeout(mut self, timeout: Duration) -> Self {
82        self.timeout = timeout;
83        self
84    }
85
86    /// Run tests for the given builder against all files in the root directory with the specified extensions.
87    #[cfg(feature = "serde")]
88    pub fn run_tests<L, B>(self, builder: &B) -> Result<(), OakError>
89    where
90        B: Builder<L> + Send + Sync,
91        L: Language + Send + Sync,
92        L::TypedRoot: serde::Serialize + Debug + Sync + Send,
93    {
94        let test_files = self.find_test_files()?;
95        let force_regenerated = std::env::var("REGENERATE_TESTS").unwrap_or("0".to_string()) == "1";
96        let mut regenerated_any = false;
97
98        for file_path in test_files {
99            println!("Testing file: {}", file_path.display());
100            regenerated_any |= self.test_single_file::<L, B>(&file_path, builder, force_regenerated)?
101        }
102
103        if regenerated_any && force_regenerated {
104            println!("Tests regenerated for: {}", self.root.display());
105            Ok(())
106        }
107        else {
108            Ok(())
109        }
110    }
111
112    /// Run tests for the given builder against all files in the root directory with the specified extensions.
113    #[cfg(not(feature = "serde"))]
114    pub fn run_tests<L, B>(self, _builder: &B) -> Result<(), OakError>
115    where
116        B: Builder<L> + Send + Sync,
117        L: Language + Send + Sync,
118        L::TypedRoot: Debug + Sync + Send,
119    {
120        Ok(())
121    }
122
123    fn find_test_files(&self) -> Result<Vec<PathBuf>, OakError> {
124        let mut files = Vec::new();
125
126        for entry in WalkDir::new(&self.root) {
127            let entry = entry.unwrap();
128            let path = entry.path();
129
130            if path.is_file() {
131                if let Some(ext) = path.extension() {
132                    let ext_str = ext.to_str().unwrap_or("");
133                    if self.extensions.iter().any(|e| e == ext_str) {
134                        // Ignore output files generated by the Tester itself to prevent recursive inclusion
135                        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
136                        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");
137
138                        if !is_output_file {
139                            files.push(path.to_path_buf());
140                        }
141                    }
142                }
143            }
144        }
145
146        Ok(files)
147    }
148
149    #[cfg(feature = "serde")]
150    fn test_single_file<L, B>(&self, file_path: &Path, builder: &B, force_regenerated: bool) -> Result<bool, OakError>
151    where
152        B: Builder<L> + Send + Sync,
153        L: Language + Send + Sync,
154        L::TypedRoot: serde::Serialize + Debug + Sync + Send,
155    {
156        let source = source_from_path(file_path)?;
157
158        // Perform build in a thread and construct test results, with main thread handling timeout control
159        use std::sync::{Arc, Mutex};
160        let result: Arc<Mutex<Option<Result<BuilderTestExpected, OakError>>>> = Arc::new(Mutex::new(None));
161        let result_clone = Arc::clone(&result);
162        let timeout = self.timeout;
163        let file_path_string = file_path.display().to_string();
164
165        std::thread::scope(|s| {
166            let handle = s.spawn(move || {
167                let mut cache = oak_core::parser::ParseSession::<L>::new(1024);
168                let build_out = builder.build(&source, &[], &mut cache);
169
170                // Build typed root structure if build succeeded
171                let (success, typed_root) = match &build_out.result {
172                    Ok(root) => {
173                        // Serialize the typed root to JSON for comparison
174                        match serde_json::to_value(root) {
175                            Ok(content) => {
176                                let typed_root_data = TypedRootData { type_name: std::any::type_name::<L::TypedRoot>().to_string(), content };
177                                (true, Some(typed_root_data))
178                            }
179                            Err(_) => {
180                                // If serialization fails, still mark as success but with no typed root data
181                                (true, None)
182                            }
183                        }
184                    }
185                    Err(_) => (false, None),
186                };
187
188                // Collect error messages
189                let mut error_messages: Vec<String> = build_out.diagnostics.iter().map(|e| e.to_string()).collect();
190                if let Err(e) = &build_out.result {
191                    error_messages.push(e.to_string());
192                }
193
194                let test_result = BuilderTestExpected { success, typed_root, errors: error_messages };
195
196                let mut result = result_clone.lock().unwrap();
197                *result = Some(Ok(test_result));
198            });
199
200            // Wait for thread completion or timeout
201            let start_time = std::time::Instant::now();
202            let timeout_occurred = loop {
203                // Check if thread has finished
204                if handle.is_finished() {
205                    break false;
206                }
207
208                // Check for timeout
209                if start_time.elapsed() > timeout {
210                    break true;
211                }
212
213                // Sleep briefly to avoid busy waiting
214                std::thread::sleep(std::time::Duration::from_millis(10));
215            };
216
217            // Return error if timed out
218            if timeout_occurred {
219                return Err(OakError::custom_error(format!("Builder test timed out after {:?} for file: {}", timeout, file_path_string)));
220            }
221
222            // Get build result
223            let test_result = {
224                let result_guard = result.lock().unwrap();
225                match result_guard.as_ref() {
226                    Some(Ok(test_result)) => test_result.clone(),
227                    Some(Err(e)) => return Err(e.clone()),
228                    None => return Err(OakError::custom_error("Builder thread disconnected unexpectedly")),
229                }
230            };
231
232            let mut regenerated = false;
233            let expected_file = file_path.with_extension(format!("{}.built.json", file_path.extension().unwrap_or_default().to_str().unwrap_or("")));
234
235            // Migration: If the new naming convention file doesn't exist, but the old one does, rename it
236            if !expected_file.exists() {
237                let legacy_file = file_path.with_extension("expected.json");
238                if legacy_file.exists() {
239                    let _ = std::fs::rename(&legacy_file, &expected_file);
240                }
241            }
242
243            if expected_file.exists() && !force_regenerated {
244                let expected_json = json_from_path(&expected_file)?;
245                let expected: BuilderTestExpected = serde_json::from_value(expected_json).map_err(|e| OakError::custom_error(e.to_string()))?;
246                if test_result != expected {
247                    return Err(OakError::test_failure(file_path.to_path_buf(), format!("{:#?}", expected), format!("{:#?}", test_result)));
248                }
249            }
250            else {
251                use std::io::Write;
252                let mut file = create_file(&expected_file)?;
253                let mut buf = Vec::new();
254                let formatter = serde_json::ser::PrettyFormatter::with_indent(b"    "); // 4 spaces indentation
255                let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
256                test_result.serialize(&mut ser).map_err(|e| OakError::custom_error(e.to_string()))?;
257                file.write_all(&buf).map_err(|e| OakError::custom_error(e.to_string()))?;
258                regenerated = true;
259            }
260
261            Ok(regenerated)
262        })
263    }
264}