1use 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
10pub const DEFAULT_RESULTS_DIR: &str = "allure-results";
12
13#[derive(Debug, Clone)]
15pub struct AllureWriter {
16 results_dir: PathBuf,
17}
18
19impl AllureWriter {
20 pub fn new() -> Self {
22 Self::with_results_dir(DEFAULT_RESULTS_DIR)
23 }
24
25 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 pub fn results_dir(&self) -> &Path {
34 &self.results_dir
35 }
36
37 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 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 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 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 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 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 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 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 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 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 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
207fn escape_property_value(s: &str) -> String {
215 s.replace('\\', "\\\\")
216 .replace('\n', "\\n")
217 .replace('\r', "\\r")
218 .replace('=', "\\=")
219}
220
221fn 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
244pub fn generate_uuid() -> String {
246 uuid::Uuid::new_v4().to_string()
247}
248
249pub 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 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]; 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", ¶ms);
424 let id2 = compute_history_id("test::my_test", ¶ms);
425 assert_eq!(id1, id2);
426
427 let id3 = compute_history_id("test::other_test", ¶ms);
429 assert_ne!(id1, id3);
430
431 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", ¶ms_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); }
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}