allure_core/
writer.rs

1//! File writer for Allure test results, containers, and attachments.
2
3use std::fs::{self, File};
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6
7use crate::enums::ContentType;
8use crate::model::{Attachment, Category, TestResult, TestResultContainer};
9
10/// Default directory for Allure results.
11pub const DEFAULT_RESULTS_DIR: &str = "allure-results";
12
13/// Writer for Allure test result files.
14#[derive(Debug, Clone)]
15pub struct AllureWriter {
16    results_dir: PathBuf,
17}
18
19impl AllureWriter {
20    /// Creates a new writer with the default results directory.
21    pub fn new() -> Self {
22        Self::with_results_dir(DEFAULT_RESULTS_DIR)
23    }
24
25    /// Creates a new writer with a custom results directory.
26    pub fn with_results_dir(path: impl AsRef<Path>) -> Self {
27        Self {
28            results_dir: path.as_ref().to_path_buf(),
29        }
30    }
31
32    /// Returns the results directory path.
33    pub fn results_dir(&self) -> &Path {
34        &self.results_dir
35    }
36
37    /// Initializes the results directory, optionally cleaning it first.
38    pub fn init(&self, clean: bool) -> io::Result<()> {
39        if clean && self.results_dir.exists() {
40            fs::remove_dir_all(&self.results_dir)?;
41        }
42        fs::create_dir_all(&self.results_dir)?;
43        Ok(())
44    }
45
46    /// Ensures the results directory exists.
47    fn ensure_dir(&self) -> io::Result<()> {
48        if !self.results_dir.exists() {
49            fs::create_dir_all(&self.results_dir)?;
50        }
51        Ok(())
52    }
53
54    /// Writes a test result to a JSON file.
55    pub fn write_test_result(&self, result: &TestResult) -> io::Result<PathBuf> {
56        self.ensure_dir()?;
57        let filename = format!("{}-result.json", result.uuid);
58        let path = self.results_dir.join(&filename);
59        let json = serde_json::to_string_pretty(result)
60            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
61        fs::write(&path, json)?;
62        Ok(path)
63    }
64
65    /// Writes a container to a JSON file.
66    pub fn write_container(&self, container: &TestResultContainer) -> io::Result<PathBuf> {
67        self.ensure_dir()?;
68        let filename = format!("{}-container.json", container.uuid);
69        let path = self.results_dir.join(&filename);
70        let json = serde_json::to_string_pretty(container)
71            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
72        fs::write(&path, json)?;
73        Ok(path)
74    }
75
76    /// Writes a text attachment and returns the Attachment reference.
77    pub fn write_text_attachment(
78        &self,
79        name: impl Into<String>,
80        content: impl AsRef<str>,
81    ) -> io::Result<Attachment> {
82        self.ensure_dir()?;
83        let uuid = uuid::Uuid::new_v4().to_string();
84        let filename = format!("{}-attachment.txt", uuid);
85        let path = self.results_dir.join(&filename);
86        fs::write(&path, content.as_ref())?;
87        Ok(Attachment::new(
88            name,
89            filename,
90            Some(ContentType::Text.as_mime().to_string()),
91        ))
92    }
93
94    /// Writes a JSON attachment and returns the Attachment reference.
95    pub fn write_json_attachment<T: serde::Serialize>(
96        &self,
97        name: impl Into<String>,
98        value: &T,
99    ) -> io::Result<Attachment> {
100        self.ensure_dir()?;
101        let uuid = uuid::Uuid::new_v4().to_string();
102        let filename = format!("{}-attachment.json", uuid);
103        let path = self.results_dir.join(&filename);
104        let json = serde_json::to_string_pretty(value)
105            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
106        fs::write(&path, json)?;
107        Ok(Attachment::new(
108            name,
109            filename,
110            Some(ContentType::Json.as_mime().to_string()),
111        ))
112    }
113
114    /// Writes a binary attachment and returns the Attachment reference.
115    pub fn write_binary_attachment(
116        &self,
117        name: impl Into<String>,
118        content: &[u8],
119        content_type: ContentType,
120    ) -> io::Result<Attachment> {
121        self.ensure_dir()?;
122        let uuid = uuid::Uuid::new_v4().to_string();
123        let filename = format!("{}-attachment.{}", uuid, content_type.extension());
124        let path = self.results_dir.join(&filename);
125        fs::write(&path, content)?;
126        Ok(Attachment::new(
127            name,
128            filename,
129            Some(content_type.as_mime().to_string()),
130        ))
131    }
132
133    /// Writes a binary attachment with a custom MIME type.
134    pub fn write_binary_attachment_with_mime(
135        &self,
136        name: impl Into<String>,
137        content: &[u8],
138        mime_type: impl Into<String>,
139        extension: impl AsRef<str>,
140    ) -> io::Result<Attachment> {
141        self.ensure_dir()?;
142        let uuid = uuid::Uuid::new_v4().to_string();
143        let filename = format!("{}-attachment.{}", uuid, extension.as_ref());
144        let path = self.results_dir.join(&filename);
145        fs::write(&path, content)?;
146        Ok(Attachment::new(name, filename, Some(mime_type.into())))
147    }
148
149    /// Copies a file as an attachment and returns the Attachment reference.
150    pub fn copy_file_attachment(
151        &self,
152        name: impl Into<String>,
153        source_path: impl AsRef<Path>,
154        content_type: Option<ContentType>,
155    ) -> io::Result<Attachment> {
156        self.ensure_dir()?;
157        let source = source_path.as_ref();
158        let extension = source
159            .extension()
160            .and_then(|ext| ext.to_str())
161            .unwrap_or("bin");
162
163        let uuid = uuid::Uuid::new_v4().to_string();
164        let filename = format!("{}-attachment.{}", uuid, extension);
165        let dest_path = self.results_dir.join(&filename);
166        fs::copy(source, &dest_path)?;
167
168        let mime = content_type
169            .map(|ct| ct.as_mime().to_string())
170            .or_else(|| guess_mime_type(extension));
171
172        Ok(Attachment::new(name, filename, mime))
173    }
174
175    /// Writes the environment.properties file.
176    ///
177    /// Keys and values are escaped according to the Java Properties file format.
178    pub fn write_environment(&self, properties: &[(String, String)]) -> io::Result<PathBuf> {
179        self.ensure_dir()?;
180        let path = self.results_dir.join("environment.properties");
181        let mut file = File::create(&path)?;
182        for (key, value) in properties {
183            let escaped_key = escape_property_value(key);
184            let escaped_value = escape_property_value(value);
185            writeln!(file, "{}={}", escaped_key, escaped_value)?;
186        }
187        Ok(path)
188    }
189
190    /// Writes the categories.json file.
191    pub fn write_categories(&self, categories: &[Category]) -> io::Result<PathBuf> {
192        self.ensure_dir()?;
193        let path = self.results_dir.join("categories.json");
194        let json = serde_json::to_string_pretty(categories)
195            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
196        fs::write(&path, json)?;
197        Ok(path)
198    }
199}
200
201impl Default for AllureWriter {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207/// Escapes a string for use in a Java Properties file.
208///
209/// Order of operations matters:
210/// 1. Escape backslashes first (\ -> \\)
211/// 2. Then escape newlines (\n -> \\n)
212/// 3. Then escape carriage returns (\r -> \\r)
213/// 4. Then escape equals signs (= -> \=)
214fn escape_property_value(s: &str) -> String {
215    s.replace('\\', "\\\\")
216        .replace('\n', "\\n")
217        .replace('\r', "\\r")
218        .replace('=', "\\=")
219}
220
221/// Guesses the MIME type from a file extension.
222fn guess_mime_type(extension: &str) -> Option<String> {
223    match extension.to_lowercase().as_str() {
224        "txt" => Some("text/plain".to_string()),
225        "json" => Some("application/json".to_string()),
226        "xml" => Some("application/xml".to_string()),
227        "html" | "htm" => Some("text/html".to_string()),
228        "css" => Some("text/css".to_string()),
229        "csv" => Some("text/csv".to_string()),
230        "png" => Some("image/png".to_string()),
231        "jpg" | "jpeg" => Some("image/jpeg".to_string()),
232        "gif" => Some("image/gif".to_string()),
233        "svg" => Some("image/svg+xml".to_string()),
234        "webp" => Some("image/webp".to_string()),
235        "mp4" => Some("video/mp4".to_string()),
236        "webm" => Some("video/webm".to_string()),
237        "pdf" => Some("application/pdf".to_string()),
238        "zip" => Some("application/zip".to_string()),
239        "log" => Some("text/plain".to_string()),
240        _ => None,
241    }
242}
243
244/// Generates a new UUID v4 string.
245pub fn generate_uuid() -> String {
246    uuid::Uuid::new_v4().to_string()
247}
248
249/// Computes the history ID for a test based on its full name and parameters.
250pub fn compute_history_id(full_name: &str, parameters: &[crate::model::Parameter]) -> String {
251    use md5::{Digest, Md5};
252
253    let mut hasher = Md5::new();
254    hasher.update(full_name.as_bytes());
255
256    for param in parameters {
257        // Skip excluded parameters
258        if param.excluded.unwrap_or(false) {
259            continue;
260        }
261        hasher.update(param.name.as_bytes());
262        hasher.update(param.value.as_bytes());
263    }
264
265    format!("{:x}", hasher.finalize())
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::enums::Status;
272    use crate::model::Parameter;
273    use std::env;
274
275    fn temp_dir() -> PathBuf {
276        let mut path = env::temp_dir();
277        path.push(format!("allure-test-{}", uuid::Uuid::new_v4()));
278        path
279    }
280
281    #[test]
282    fn test_writer_init() {
283        let dir = temp_dir();
284        let writer = AllureWriter::with_results_dir(&dir);
285        writer.init(true).unwrap();
286        assert!(dir.exists());
287        fs::remove_dir_all(&dir).ok();
288    }
289
290    #[test]
291    fn test_write_test_result() {
292        let dir = temp_dir();
293        let writer = AllureWriter::with_results_dir(&dir);
294        writer.init(true).unwrap();
295
296        let mut result = TestResult::new("test-123".to_string(), "My Test".to_string());
297        result.pass();
298
299        let path = writer.write_test_result(&result).unwrap();
300        assert!(path.exists());
301        assert!(path.to_string_lossy().contains("test-123-result.json"));
302
303        let content = fs::read_to_string(&path).unwrap();
304        assert!(content.contains("\"uuid\": \"test-123\""));
305        assert!(content.contains("\"status\": \"passed\""));
306
307        fs::remove_dir_all(&dir).ok();
308    }
309
310    #[test]
311    fn test_write_text_attachment() {
312        let dir = temp_dir();
313        let writer = AllureWriter::with_results_dir(&dir);
314        writer.init(true).unwrap();
315
316        let attachment = writer
317            .write_text_attachment("Log", "Test log content")
318            .unwrap();
319        assert_eq!(attachment.name, "Log");
320        assert!(attachment.source.ends_with(".txt"));
321        assert_eq!(attachment.r#type, Some("text/plain".to_string()));
322
323        let path = dir.join(&attachment.source);
324        assert!(path.exists());
325        assert_eq!(fs::read_to_string(&path).unwrap(), "Test log content");
326
327        fs::remove_dir_all(&dir).ok();
328    }
329
330    #[test]
331    fn test_write_json_attachment() {
332        let dir = temp_dir();
333        let writer = AllureWriter::with_results_dir(&dir);
334        writer.init(true).unwrap();
335
336        #[derive(serde::Serialize)]
337        struct Data {
338            foo: String,
339            bar: i32,
340        }
341
342        let data = Data {
343            foo: "hello".to_string(),
344            bar: 42,
345        };
346
347        let attachment = writer.write_json_attachment("Response", &data).unwrap();
348        assert_eq!(attachment.r#type, Some("application/json".to_string()));
349
350        let path = dir.join(&attachment.source);
351        let content = fs::read_to_string(&path).unwrap();
352        assert!(content.contains("\"foo\": \"hello\""));
353        assert!(content.contains("\"bar\": 42"));
354
355        fs::remove_dir_all(&dir).ok();
356    }
357
358    #[test]
359    fn test_write_binary_attachment() {
360        let dir = temp_dir();
361        let writer = AllureWriter::with_results_dir(&dir);
362        writer.init(true).unwrap();
363
364        let png_data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG magic bytes
365        let attachment = writer
366            .write_binary_attachment("Screenshot", &png_data, ContentType::Png)
367            .unwrap();
368        assert!(attachment.source.ends_with(".png"));
369        assert_eq!(attachment.r#type, Some("image/png".to_string()));
370
371        fs::remove_dir_all(&dir).ok();
372    }
373
374    #[test]
375    fn test_write_environment() {
376        let dir = temp_dir();
377        let writer = AllureWriter::with_results_dir(&dir);
378        writer.init(true).unwrap();
379
380        let env = vec![
381            ("os".to_string(), "linux".to_string()),
382            ("rust_version".to_string(), "1.75.0".to_string()),
383        ];
384
385        let path = writer.write_environment(&env).unwrap();
386        assert!(path.exists());
387
388        let content = fs::read_to_string(&path).unwrap();
389        assert!(content.contains("os=linux"));
390        assert!(content.contains("rust_version=1.75.0"));
391
392        fs::remove_dir_all(&dir).ok();
393    }
394
395    #[test]
396    fn test_write_categories() {
397        let dir = temp_dir();
398        let writer = AllureWriter::with_results_dir(&dir);
399        writer.init(true).unwrap();
400
401        let categories = vec![
402            Category::new("Infrastructure Issues")
403                .with_status(Status::Broken)
404                .with_message_regex(".*timeout.*"),
405            Category::new("Product Defects").with_status(Status::Failed),
406        ];
407
408        let path = writer.write_categories(&categories).unwrap();
409        assert!(path.exists());
410
411        let content = fs::read_to_string(&path).unwrap();
412        assert!(content.contains("Infrastructure Issues"));
413        assert!(content.contains("Product Defects"));
414        assert!(content.contains("timeout"));
415
416        fs::remove_dir_all(&dir).ok();
417    }
418
419    #[test]
420    fn test_compute_history_id() {
421        let params = vec![Parameter::new("a", "1"), Parameter::new("b", "2")];
422
423        let id1 = compute_history_id("test::my_test", &params);
424        let id2 = compute_history_id("test::my_test", &params);
425        assert_eq!(id1, id2);
426
427        // Different name should produce different ID
428        let id3 = compute_history_id("test::other_test", &params);
429        assert_ne!(id1, id3);
430
431        // Excluded parameters should not affect the ID
432        let params_with_excluded = vec![
433            Parameter::new("a", "1"),
434            Parameter::new("b", "2"),
435            Parameter::excluded("timestamp", "12345"),
436        ];
437        let id4 = compute_history_id("test::my_test", &params_with_excluded);
438        assert_eq!(id1, id4);
439    }
440
441    #[test]
442    fn test_generate_uuid() {
443        let uuid1 = generate_uuid();
444        let uuid2 = generate_uuid();
445        assert_ne!(uuid1, uuid2);
446        assert_eq!(uuid1.len(), 36); // UUID v4 format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
447    }
448
449    #[test]
450    fn test_writer_new_and_results_dir() {
451        let writer = AllureWriter::new();
452        assert_eq!(writer.results_dir(), Path::new(DEFAULT_RESULTS_DIR));
453
454        let custom = AllureWriter::with_results_dir("custom-dir");
455        assert_eq!(custom.results_dir(), Path::new("custom-dir"));
456    }
457
458    #[test]
459    fn test_write_binary_attachment_with_custom_mime() {
460        let dir = temp_dir();
461        let writer = AllureWriter::with_results_dir(&dir);
462        writer.init(true).unwrap();
463
464        let attachment = writer
465            .write_binary_attachment_with_mime("bin", b"123", "application/x-test", "bin")
466            .unwrap();
467        assert_eq!(attachment.r#type, Some("application/x-test".to_string()));
468        assert!(attachment.source.ends_with(".bin"));
469        fs::remove_dir_all(&dir).ok();
470    }
471
472    #[test]
473    fn test_escape_and_guess_mime_helpers() {
474        assert_eq!(
475            escape_property_value("a\\b=c\n"),
476            "a\\\\b\\=c\\n".to_string()
477        );
478        assert_eq!(guess_mime_type("json").as_deref(), Some("application/json"));
479        assert_eq!(guess_mime_type("unknown"), None);
480    }
481}