libmagic_rs/output/mod.rs
1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Output formatting module for magic rule evaluation results
5//!
6//! This module provides data structures and functionality for storing and formatting
7//! the results of magic rule evaluation, supporting both text and JSON output formats.
8//!
9//! The module follows a structured approach where evaluation results contain metadata
10//! about the evaluation process and a list of matches found during rule processing.
11
12pub mod json;
13pub mod text;
14
15use serde::{Deserialize, Serialize};
16use std::path::PathBuf;
17
18use std::sync::LazyLock;
19
20use crate::parser::ast::Value;
21
22/// Shared `TagExtractor` instance, initialized once on first use.
23/// Avoids allocating the 16-keyword `HashSet` on every call to
24/// `from_evaluator_match` or `from_library_result`.
25static DEFAULT_TAG_EXTRACTOR: LazyLock<crate::tags::TagExtractor> =
26 LazyLock::new(crate::tags::TagExtractor::new);
27
28/// Result of a single magic rule match
29///
30/// Contains all information about a successful rule match, including the matched
31/// value, its location in the file, and metadata about the rule that matched.
32///
33/// # Examples
34///
35/// ```
36/// use libmagic_rs::output::MatchResult;
37/// use libmagic_rs::parser::ast::Value;
38///
39/// let result = MatchResult {
40/// message: "ELF 64-bit LSB executable".to_string(),
41/// offset: 0,
42/// length: 4,
43/// value: Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
44/// rule_path: vec!["elf".to_string(), "elf64".to_string()],
45/// confidence: 90,
46/// mime_type: Some("application/x-executable".to_string()),
47/// };
48///
49/// assert_eq!(result.message, "ELF 64-bit LSB executable");
50/// assert_eq!(result.offset, 0);
51/// ```
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct MatchResult {
54 /// Human-readable description of the file type or pattern match
55 pub message: String,
56
57 /// Byte offset in the file where the match occurred
58 pub offset: usize,
59
60 /// Number of bytes that were examined for this match
61 pub length: usize,
62
63 /// The actual value that was matched at the specified offset
64 pub value: Value,
65
66 /// Hierarchical path of rule names that led to this match
67 ///
68 /// For nested rules, this contains the sequence of rule identifiers
69 /// from the root rule down to the specific rule that matched.
70 pub rule_path: Vec<String>,
71
72 /// Confidence score for this match (0-100)
73 ///
74 /// Higher values indicate more specific or reliable matches.
75 /// Generic patterns typically have lower confidence scores.
76 pub confidence: u8,
77
78 /// Optional MIME type associated with this match
79 ///
80 /// When available, provides the standard MIME type corresponding
81 /// to the detected file format.
82 pub mime_type: Option<String>,
83}
84
85/// Complete evaluation result for a file
86///
87/// Contains all matches found during rule evaluation, along with metadata
88/// about the evaluation process and the file being analyzed.
89///
90/// # Examples
91///
92/// ```
93/// use libmagic_rs::output::{EvaluationResult, MatchResult, EvaluationMetadata};
94/// use libmagic_rs::parser::ast::Value;
95/// use std::path::PathBuf;
96///
97/// let result = EvaluationResult {
98/// filename: PathBuf::from("example.bin"),
99/// matches: vec![
100/// MatchResult {
101/// message: "ELF executable".to_string(),
102/// offset: 0,
103/// length: 4,
104/// value: Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
105/// rule_path: vec!["elf".to_string()],
106/// confidence: 95,
107/// mime_type: Some("application/x-executable".to_string()),
108/// }
109/// ],
110/// metadata: EvaluationMetadata {
111/// file_size: 8192,
112/// evaluation_time_ms: 2.5,
113/// rules_evaluated: 42,
114/// rules_matched: 1,
115/// },
116/// error: None,
117/// };
118///
119/// assert_eq!(result.matches.len(), 1);
120/// assert_eq!(result.metadata.file_size, 8192);
121/// ```
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct EvaluationResult {
124 /// Path to the file that was analyzed
125 pub filename: PathBuf,
126
127 /// All successful rule matches found during evaluation
128 ///
129 /// Matches are typically ordered by offset, then by confidence score.
130 /// The first match is often considered the primary file type.
131 pub matches: Vec<MatchResult>,
132
133 /// Metadata about the evaluation process
134 pub metadata: EvaluationMetadata,
135
136 /// Error that occurred during evaluation, if any
137 ///
138 /// When present, indicates that evaluation was incomplete or failed.
139 /// Partial results may still be available in the matches vector.
140 pub error: Option<String>,
141}
142
143/// Metadata about the evaluation process
144///
145/// Provides diagnostic information about how the evaluation was performed,
146/// including performance metrics and statistics about rule processing.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct EvaluationMetadata {
149 /// Size of the analyzed file in bytes
150 pub file_size: u64,
151
152 /// Time taken for evaluation in milliseconds
153 pub evaluation_time_ms: f64,
154
155 /// Total number of rules that were evaluated
156 ///
157 /// This includes rules that were tested but did not match.
158 pub rules_evaluated: u32,
159
160 /// Number of rules that successfully matched
161 pub rules_matched: u32,
162}
163
164impl MatchResult {
165 /// Create a new match result with basic information
166 ///
167 /// # Arguments
168 ///
169 /// * `message` - Human-readable description of the match
170 /// * `offset` - Byte offset where the match occurred
171 /// * `value` - The matched value
172 ///
173 /// # Examples
174 ///
175 /// ```
176 /// use libmagic_rs::output::MatchResult;
177 /// use libmagic_rs::parser::ast::Value;
178 ///
179 /// let result = MatchResult::new(
180 /// "PNG image".to_string(),
181 /// 0,
182 /// Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47])
183 /// );
184 ///
185 /// assert_eq!(result.message, "PNG image");
186 /// assert_eq!(result.offset, 0);
187 /// assert_eq!(result.confidence, 50); // Default confidence
188 /// ```
189 #[must_use]
190 pub fn new(message: String, offset: usize, value: Value) -> Self {
191 Self {
192 message,
193 offset,
194 length: match &value {
195 Value::Bytes(bytes) => bytes.len(),
196 Value::String(s) => s.len(),
197 Value::Uint(_) | Value::Int(_) => std::mem::size_of::<u64>(),
198 },
199 value,
200 rule_path: Vec::new(),
201 confidence: 50, // Default moderate confidence
202 mime_type: None,
203 }
204 }
205
206 /// Create a new match result with full metadata
207 ///
208 /// # Arguments
209 ///
210 /// * `message` - Human-readable description of the match
211 /// * `offset` - Byte offset where the match occurred
212 /// * `length` - Number of bytes examined
213 /// * `value` - The matched value
214 /// * `rule_path` - Hierarchical path of rules that led to this match
215 /// * `confidence` - Confidence score (0-100)
216 /// * `mime_type` - Optional MIME type
217 ///
218 /// # Examples
219 ///
220 /// ```
221 /// use libmagic_rs::output::MatchResult;
222 /// use libmagic_rs::parser::ast::Value;
223 ///
224 /// let result = MatchResult::with_metadata(
225 /// "JPEG image".to_string(),
226 /// 0,
227 /// 2,
228 /// Value::Bytes(vec![0xff, 0xd8]),
229 /// vec!["image".to_string(), "jpeg".to_string()],
230 /// 85,
231 /// Some("image/jpeg".to_string())
232 /// );
233 ///
234 /// assert_eq!(result.rule_path.len(), 2);
235 /// assert_eq!(result.confidence, 85);
236 /// assert_eq!(result.mime_type, Some("image/jpeg".to_string()));
237 /// ```
238 #[must_use]
239 pub fn with_metadata(
240 message: String,
241 offset: usize,
242 length: usize,
243 value: Value,
244 rule_path: Vec<String>,
245 confidence: u8,
246 mime_type: Option<String>,
247 ) -> Self {
248 Self {
249 message,
250 offset,
251 length,
252 value,
253 rule_path,
254 confidence: confidence.min(100), // Clamp to valid range
255 mime_type,
256 }
257 }
258
259 /// Convert from an evaluator [`RuleMatch`](crate::evaluator::RuleMatch) to an output `MatchResult`
260 ///
261 /// This adapts the internal evaluation result format to the richer output format
262 /// used for JSON and structured output. It extracts rule paths from match messages
263 /// and converts confidence from 0.0-1.0 to 0-100 scale.
264 ///
265 /// # Arguments
266 ///
267 /// * `m` - The evaluator rule match to convert
268 /// * `mime_type` - Optional MIME type to associate with this match
269 #[must_use]
270 pub fn from_evaluator_match(m: &crate::evaluator::RuleMatch, mime_type: Option<&str>) -> Self {
271 let rule_path =
272 DEFAULT_TAG_EXTRACTOR.extract_rule_path(std::iter::once(m.message.as_str()));
273
274 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
275 let confidence = (m.confidence * 100.0).min(100.0) as u8;
276
277 // TODO: Numeric length is hardcoded to 4 bytes. Value::Uint/Int don't encode
278 // their source width, so byte/short/long/quad all report 4. Carrying TypeKind
279 // in RuleMatch would allow accurate lengths (1, 2, 4, 8).
280 let length = match &m.value {
281 Value::Bytes(b) => b.len(),
282 Value::String(s) => s.len(),
283 Value::Uint(_) | Value::Int(_) => 4,
284 };
285
286 Self::with_metadata(
287 m.message.clone(),
288 m.offset,
289 length,
290 m.value.clone(),
291 rule_path,
292 confidence,
293 mime_type.map(String::from),
294 )
295 }
296
297 /// Set the confidence score for this match
298 ///
299 /// The confidence score is automatically clamped to the range 0-100.
300 ///
301 /// # Examples
302 ///
303 /// ```
304 /// use libmagic_rs::output::MatchResult;
305 /// use libmagic_rs::parser::ast::Value;
306 ///
307 /// let mut result = MatchResult::new(
308 /// "Text file".to_string(),
309 /// 0,
310 /// Value::String("Hello".to_string())
311 /// );
312 ///
313 /// result.set_confidence(75);
314 /// assert_eq!(result.confidence, 75);
315 ///
316 /// // Values over 100 are clamped
317 /// result.set_confidence(150);
318 /// assert_eq!(result.confidence, 100);
319 /// ```
320 pub fn set_confidence(&mut self, confidence: u8) {
321 self.confidence = confidence.min(100);
322 }
323
324 /// Add a rule name to the rule path
325 ///
326 /// This is typically used during evaluation to build up the hierarchical
327 /// path of rules that led to a match.
328 ///
329 /// # Examples
330 ///
331 /// ```
332 /// use libmagic_rs::output::MatchResult;
333 /// use libmagic_rs::parser::ast::Value;
334 ///
335 /// let mut result = MatchResult::new(
336 /// "Archive".to_string(),
337 /// 0,
338 /// Value::String("PK".to_string())
339 /// );
340 ///
341 /// result.add_rule_path("archive".to_string());
342 /// result.add_rule_path("zip".to_string());
343 ///
344 /// assert_eq!(result.rule_path, vec!["archive", "zip"]);
345 /// ```
346 pub fn add_rule_path(&mut self, rule_name: String) {
347 self.rule_path.push(rule_name);
348 }
349
350 /// Set the MIME type for this match
351 ///
352 /// # Examples
353 ///
354 /// ```
355 /// use libmagic_rs::output::MatchResult;
356 /// use libmagic_rs::parser::ast::Value;
357 ///
358 /// let mut result = MatchResult::new(
359 /// "PDF document".to_string(),
360 /// 0,
361 /// Value::String("%PDF".to_string())
362 /// );
363 ///
364 /// result.set_mime_type(Some("application/pdf".to_string()));
365 /// assert_eq!(result.mime_type, Some("application/pdf".to_string()));
366 /// ```
367 pub fn set_mime_type(&mut self, mime_type: Option<String>) {
368 self.mime_type = mime_type;
369 }
370}
371
372impl EvaluationResult {
373 /// Create a new evaluation result
374 ///
375 /// # Arguments
376 ///
377 /// * `filename` - Path to the analyzed file
378 /// * `matches` - Vector of successful matches
379 /// * `metadata` - Evaluation metadata
380 ///
381 /// # Examples
382 ///
383 /// ```
384 /// use libmagic_rs::output::{EvaluationResult, EvaluationMetadata};
385 /// use std::path::PathBuf;
386 ///
387 /// let result = EvaluationResult::new(
388 /// PathBuf::from("test.txt"),
389 /// vec![],
390 /// EvaluationMetadata {
391 /// file_size: 1024,
392 /// evaluation_time_ms: 1.2,
393 /// rules_evaluated: 10,
394 /// rules_matched: 0,
395 /// }
396 /// );
397 ///
398 /// assert_eq!(result.filename, PathBuf::from("test.txt"));
399 /// assert!(result.matches.is_empty());
400 /// assert!(result.error.is_none());
401 /// ```
402 #[must_use]
403 pub fn new(filename: PathBuf, matches: Vec<MatchResult>, metadata: EvaluationMetadata) -> Self {
404 Self {
405 filename,
406 matches,
407 metadata,
408 error: None,
409 }
410 }
411
412 /// Convert from a library `EvaluationResult` to an output `EvaluationResult`
413 ///
414 /// This adapts the library's evaluation result into the output format used for
415 /// JSON and structured output. Converts all matches and metadata, and enriches
416 /// the first match's rule path with tags extracted from the overall description.
417 ///
418 /// # Arguments
419 ///
420 /// * `result` - The library evaluation result to convert
421 /// * `filename` - Path to the file that was evaluated
422 #[must_use]
423 pub fn from_library_result(
424 result: &crate::EvaluationResult,
425 filename: &std::path::Path,
426 ) -> Self {
427 let mut output_matches: Vec<MatchResult> = result
428 .matches
429 .iter()
430 .map(|m| MatchResult::from_evaluator_match(m, result.mime_type.as_deref()))
431 .collect();
432
433 // Enrich the first match with tags from the overall description
434 if let Some(first) = output_matches.first_mut()
435 && first.rule_path.is_empty()
436 {
437 first.rule_path = DEFAULT_TAG_EXTRACTOR.extract_tags(&result.description);
438 }
439
440 #[allow(clippy::cast_possible_truncation)]
441 let rules_evaluated = result.metadata.rules_evaluated as u32;
442 #[allow(clippy::cast_possible_truncation)]
443 let rules_matched = output_matches.len() as u32;
444
445 Self::new(
446 filename.to_path_buf(),
447 output_matches,
448 EvaluationMetadata::new(
449 result.metadata.file_size,
450 result.metadata.evaluation_time_ms,
451 rules_evaluated,
452 rules_matched,
453 ),
454 )
455 }
456
457 /// Create an evaluation result with an error
458 ///
459 /// # Arguments
460 ///
461 /// * `filename` - Path to the analyzed file
462 /// * `error` - Error message describing what went wrong
463 /// * `metadata` - Evaluation metadata (may be partial)
464 ///
465 /// # Examples
466 ///
467 /// ```
468 /// use libmagic_rs::output::{EvaluationResult, EvaluationMetadata};
469 /// use std::path::PathBuf;
470 ///
471 /// let result = EvaluationResult::with_error(
472 /// PathBuf::from("missing.txt"),
473 /// "File not found".to_string(),
474 /// EvaluationMetadata {
475 /// file_size: 0,
476 /// evaluation_time_ms: 0.0,
477 /// rules_evaluated: 0,
478 /// rules_matched: 0,
479 /// }
480 /// );
481 ///
482 /// assert_eq!(result.error, Some("File not found".to_string()));
483 /// assert!(result.matches.is_empty());
484 /// ```
485 #[must_use]
486 pub fn with_error(filename: PathBuf, error: String, metadata: EvaluationMetadata) -> Self {
487 Self {
488 filename,
489 matches: Vec::new(),
490 metadata,
491 error: Some(error),
492 }
493 }
494
495 /// Add a match result to this evaluation
496 ///
497 /// # Examples
498 ///
499 /// ```
500 /// use libmagic_rs::output::{EvaluationResult, MatchResult, EvaluationMetadata};
501 /// use libmagic_rs::parser::ast::Value;
502 /// use std::path::PathBuf;
503 ///
504 /// let mut result = EvaluationResult::new(
505 /// PathBuf::from("data.bin"),
506 /// vec![],
507 /// EvaluationMetadata {
508 /// file_size: 512,
509 /// evaluation_time_ms: 0.8,
510 /// rules_evaluated: 5,
511 /// rules_matched: 0,
512 /// }
513 /// );
514 ///
515 /// let match_result = MatchResult::new(
516 /// "Binary data".to_string(),
517 /// 0,
518 /// Value::Bytes(vec![0x00, 0x01, 0x02])
519 /// );
520 ///
521 /// result.add_match(match_result);
522 /// assert_eq!(result.matches.len(), 1);
523 /// ```
524 pub fn add_match(&mut self, match_result: MatchResult) {
525 #[cfg(debug_assertions)]
526 Self::validate_match_result(&match_result);
527
528 self.matches.push(match_result);
529 }
530
531 /// Validate a match result before adding it
532 #[cfg(debug_assertions)]
533 fn validate_match_result(match_result: &MatchResult) {
534 // Validate confidence score range
535 if match_result.confidence > 100 {
536 eprintln!(
537 "Warning: Match result has confidence score > 100: {}",
538 match_result.confidence
539 );
540 }
541 }
542
543 /// Get the primary match (first match with highest confidence)
544 ///
545 /// Returns the match that is most likely to represent the primary file type.
546 /// This is typically the first match, but if multiple matches exist, the one
547 /// with the highest confidence score is preferred.
548 ///
549 /// # Examples
550 ///
551 /// ```
552 /// use libmagic_rs::output::{EvaluationResult, MatchResult, EvaluationMetadata};
553 /// use libmagic_rs::parser::ast::Value;
554 /// use std::path::PathBuf;
555 ///
556 /// let mut result = EvaluationResult::new(
557 /// PathBuf::from("test.exe"),
558 /// vec![
559 /// MatchResult::with_metadata(
560 /// "Executable".to_string(),
561 /// 0, 2,
562 /// Value::String("MZ".to_string()),
563 /// vec!["pe".to_string()],
564 /// 60,
565 /// None
566 /// ),
567 /// MatchResult::with_metadata(
568 /// "PE32 executable".to_string(),
569 /// 60, 4,
570 /// Value::String("PE\0\0".to_string()),
571 /// vec!["pe".to_string(), "pe32".to_string()],
572 /// 90,
573 /// Some("application/x-msdownload".to_string())
574 /// ),
575 /// ],
576 /// EvaluationMetadata {
577 /// file_size: 4096,
578 /// evaluation_time_ms: 1.5,
579 /// rules_evaluated: 15,
580 /// rules_matched: 2,
581 /// }
582 /// );
583 ///
584 /// let primary = result.primary_match();
585 /// assert!(primary.is_some());
586 /// assert_eq!(primary.unwrap().confidence, 90);
587 /// ```
588 #[must_use]
589 pub fn primary_match(&self) -> Option<&MatchResult> {
590 self.matches
591 .iter()
592 .max_by_key(|match_result| match_result.confidence)
593 }
594
595 /// Check if the evaluation was successful (no errors)
596 ///
597 /// # Examples
598 ///
599 /// ```
600 /// use libmagic_rs::output::{EvaluationResult, EvaluationMetadata};
601 /// use std::path::PathBuf;
602 ///
603 /// let success = EvaluationResult::new(
604 /// PathBuf::from("good.txt"),
605 /// vec![],
606 /// EvaluationMetadata {
607 /// file_size: 100,
608 /// evaluation_time_ms: 0.5,
609 /// rules_evaluated: 3,
610 /// rules_matched: 0,
611 /// }
612 /// );
613 ///
614 /// let failure = EvaluationResult::with_error(
615 /// PathBuf::from("bad.txt"),
616 /// "Parse error".to_string(),
617 /// EvaluationMetadata {
618 /// file_size: 0,
619 /// evaluation_time_ms: 0.0,
620 /// rules_evaluated: 0,
621 /// rules_matched: 0,
622 /// }
623 /// );
624 ///
625 /// assert!(success.is_success());
626 /// assert!(!failure.is_success());
627 /// ```
628 #[must_use]
629 pub fn is_success(&self) -> bool {
630 self.error.is_none()
631 }
632}
633
634impl EvaluationMetadata {
635 /// Create new evaluation metadata
636 ///
637 /// # Arguments
638 ///
639 /// * `file_size` - Size of the analyzed file in bytes
640 /// * `evaluation_time_ms` - Time taken for evaluation in milliseconds
641 /// * `rules_evaluated` - Number of rules that were tested
642 /// * `rules_matched` - Number of rules that matched
643 ///
644 /// # Examples
645 ///
646 /// ```
647 /// use libmagic_rs::output::EvaluationMetadata;
648 ///
649 /// let metadata = EvaluationMetadata::new(2048, 3.7, 25, 3);
650 ///
651 /// assert_eq!(metadata.file_size, 2048);
652 /// assert_eq!(metadata.evaluation_time_ms, 3.7);
653 /// assert_eq!(metadata.rules_evaluated, 25);
654 /// assert_eq!(metadata.rules_matched, 3);
655 /// ```
656 #[must_use]
657 pub fn new(
658 file_size: u64,
659 evaluation_time_ms: f64,
660 rules_evaluated: u32,
661 rules_matched: u32,
662 ) -> Self {
663 Self {
664 file_size,
665 evaluation_time_ms,
666 rules_evaluated,
667 rules_matched,
668 }
669 }
670
671 /// Get the match rate as a percentage
672 ///
673 /// Returns the percentage of evaluated rules that resulted in matches.
674 ///
675 /// # Examples
676 ///
677 /// ```
678 /// use libmagic_rs::output::EvaluationMetadata;
679 ///
680 /// let metadata = EvaluationMetadata::new(1024, 1.0, 20, 5);
681 /// assert_eq!(metadata.match_rate(), 25.0);
682 ///
683 /// let no_rules = EvaluationMetadata::new(1024, 1.0, 0, 0);
684 /// assert_eq!(no_rules.match_rate(), 0.0);
685 /// ```
686 #[must_use]
687 pub fn match_rate(&self) -> f64 {
688 if self.rules_evaluated == 0 {
689 0.0
690 } else {
691 (f64::from(self.rules_matched) / f64::from(self.rules_evaluated)) * 100.0
692 }
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn test_match_result_new() {
702 let result = MatchResult::new(
703 "Test file".to_string(),
704 42,
705 Value::String("test".to_string()),
706 );
707
708 assert_eq!(result.message, "Test file");
709 assert_eq!(result.offset, 42);
710 assert_eq!(result.length, 4); // Length of "test"
711 assert_eq!(result.value, Value::String("test".to_string()));
712 assert!(result.rule_path.is_empty());
713 assert_eq!(result.confidence, 50);
714 assert!(result.mime_type.is_none());
715 }
716
717 #[test]
718 fn test_match_result_with_metadata() {
719 let result = MatchResult::with_metadata(
720 "ELF executable".to_string(),
721 0,
722 4,
723 Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
724 vec!["elf".to_string()],
725 95,
726 Some("application/x-executable".to_string()),
727 );
728
729 assert_eq!(result.message, "ELF executable");
730 assert_eq!(result.offset, 0);
731 assert_eq!(result.length, 4);
732 assert_eq!(result.rule_path, vec!["elf"]);
733 assert_eq!(result.confidence, 95);
734 assert_eq!(
735 result.mime_type,
736 Some("application/x-executable".to_string())
737 );
738 }
739
740 #[test]
741 fn test_match_result_length_calculation() {
742 // Test length calculation for different value types
743 let bytes_result = MatchResult::new("Bytes".to_string(), 0, Value::Bytes(vec![1, 2, 3]));
744 assert_eq!(bytes_result.length, 3);
745
746 let string_result =
747 MatchResult::new("String".to_string(), 0, Value::String("hello".to_string()));
748 assert_eq!(string_result.length, 5);
749
750 let uint_result = MatchResult::new("Uint".to_string(), 0, Value::Uint(42));
751 assert_eq!(uint_result.length, 8); // size_of::<u64>()
752
753 let int_result = MatchResult::new("Int".to_string(), 0, Value::Int(-42));
754 assert_eq!(int_result.length, 8); // size_of::<u64>()
755 }
756
757 #[test]
758 fn test_match_result_set_confidence() {
759 let mut result = MatchResult::new("Test".to_string(), 0, Value::Uint(0));
760
761 result.set_confidence(75);
762 assert_eq!(result.confidence, 75);
763
764 // Test clamping to 100
765 result.set_confidence(150);
766 assert_eq!(result.confidence, 100);
767
768 result.set_confidence(0);
769 assert_eq!(result.confidence, 0);
770 }
771
772 #[test]
773 fn test_match_result_confidence_clamping_in_constructor() {
774 let result = MatchResult::with_metadata(
775 "Test".to_string(),
776 0,
777 1,
778 Value::Uint(0),
779 vec![],
780 200, // Over 100
781 None,
782 );
783
784 assert_eq!(result.confidence, 100);
785 }
786
787 #[test]
788 fn test_match_result_add_rule_path() {
789 let mut result = MatchResult::new("Test".to_string(), 0, Value::Uint(0));
790
791 result.add_rule_path("root".to_string());
792 result.add_rule_path("child".to_string());
793 result.add_rule_path("grandchild".to_string());
794
795 assert_eq!(result.rule_path, vec!["root", "child", "grandchild"]);
796 }
797
798 #[test]
799 fn test_match_result_set_mime_type() {
800 let mut result = MatchResult::new("Test".to_string(), 0, Value::Uint(0));
801
802 result.set_mime_type(Some("text/plain".to_string()));
803 assert_eq!(result.mime_type, Some("text/plain".to_string()));
804
805 result.set_mime_type(None);
806 assert!(result.mime_type.is_none());
807 }
808
809 #[test]
810 fn test_match_result_serialization() {
811 let result = MatchResult::with_metadata(
812 "PNG image".to_string(),
813 0,
814 8,
815 Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
816 vec!["image".to_string(), "png".to_string()],
817 90,
818 Some("image/png".to_string()),
819 );
820
821 let json = serde_json::to_string(&result).expect("Failed to serialize MatchResult");
822 let deserialized: MatchResult =
823 serde_json::from_str(&json).expect("Failed to deserialize MatchResult");
824
825 assert_eq!(result, deserialized);
826 }
827
828 #[test]
829 fn test_evaluation_result_new() {
830 let metadata = EvaluationMetadata::new(1024, 2.5, 10, 2);
831 let result = EvaluationResult::new(PathBuf::from("test.bin"), vec![], metadata);
832
833 assert_eq!(result.filename, PathBuf::from("test.bin"));
834 assert!(result.matches.is_empty());
835 assert!(result.error.is_none());
836 assert_eq!(result.metadata.file_size, 1024);
837 }
838
839 #[test]
840 fn test_evaluation_result_with_error() {
841 let metadata = EvaluationMetadata::new(0, 0.0, 0, 0);
842 let result = EvaluationResult::with_error(
843 PathBuf::from("missing.txt"),
844 "File not found".to_string(),
845 metadata,
846 );
847
848 assert_eq!(result.error, Some("File not found".to_string()));
849 assert!(result.matches.is_empty());
850 assert!(!result.is_success());
851 }
852
853 #[test]
854 fn test_evaluation_result_add_match() {
855 let metadata = EvaluationMetadata::new(512, 1.0, 5, 0);
856 let mut result = EvaluationResult::new(PathBuf::from("data.bin"), vec![], metadata);
857
858 let match_result =
859 MatchResult::new("Binary data".to_string(), 0, Value::Bytes(vec![0x00, 0x01]));
860
861 result.add_match(match_result);
862 assert_eq!(result.matches.len(), 1);
863 assert_eq!(result.matches[0].message, "Binary data");
864 }
865
866 #[test]
867 fn test_evaluation_result_primary_match() {
868 let metadata = EvaluationMetadata::new(2048, 3.0, 20, 3);
869 let matches = vec![
870 MatchResult::with_metadata(
871 "Low confidence".to_string(),
872 0,
873 2,
874 Value::String("AB".to_string()),
875 vec![],
876 30,
877 None,
878 ),
879 MatchResult::with_metadata(
880 "High confidence".to_string(),
881 10,
882 4,
883 Value::String("TEST".to_string()),
884 vec![],
885 95,
886 None,
887 ),
888 MatchResult::with_metadata(
889 "Medium confidence".to_string(),
890 20,
891 3,
892 Value::String("XYZ".to_string()),
893 vec![],
894 60,
895 None,
896 ),
897 ];
898
899 let result = EvaluationResult::new(PathBuf::from("test.dat"), matches, metadata);
900
901 let primary = result.primary_match();
902 assert!(primary.is_some());
903 assert_eq!(primary.unwrap().message, "High confidence");
904 assert_eq!(primary.unwrap().confidence, 95);
905 }
906
907 #[test]
908 fn test_evaluation_result_primary_match_empty() {
909 let metadata = EvaluationMetadata::new(0, 0.0, 0, 0);
910 let result = EvaluationResult::new(PathBuf::from("empty.txt"), vec![], metadata);
911
912 assert!(result.primary_match().is_none());
913 }
914
915 #[test]
916 fn test_evaluation_result_is_success() {
917 let metadata = EvaluationMetadata::new(100, 0.5, 3, 1);
918
919 let success = EvaluationResult::new(PathBuf::from("good.txt"), vec![], metadata.clone());
920
921 let failure = EvaluationResult::with_error(
922 PathBuf::from("bad.txt"),
923 "Error occurred".to_string(),
924 metadata,
925 );
926
927 assert!(success.is_success());
928 assert!(!failure.is_success());
929 }
930
931 #[test]
932 fn test_evaluation_result_serialization() {
933 let match_result = MatchResult::new(
934 "Text file".to_string(),
935 0,
936 Value::String("Hello".to_string()),
937 );
938
939 let metadata = EvaluationMetadata::new(1024, 1.5, 8, 1);
940 let result =
941 EvaluationResult::new(PathBuf::from("hello.txt"), vec![match_result], metadata);
942
943 let json = serde_json::to_string(&result).expect("Failed to serialize EvaluationResult");
944 let deserialized: EvaluationResult =
945 serde_json::from_str(&json).expect("Failed to deserialize EvaluationResult");
946
947 assert_eq!(result.filename, deserialized.filename);
948 assert_eq!(result.matches.len(), deserialized.matches.len());
949 assert_eq!(result.metadata.file_size, deserialized.metadata.file_size);
950 }
951
952 #[test]
953 fn test_evaluation_metadata_new() {
954 let metadata = EvaluationMetadata::new(4096, 5.2, 50, 8);
955
956 assert_eq!(metadata.file_size, 4096);
957 assert!((metadata.evaluation_time_ms - 5.2).abs() < f64::EPSILON);
958 assert_eq!(metadata.rules_evaluated, 50);
959 assert_eq!(metadata.rules_matched, 8);
960 }
961
962 #[test]
963 fn test_evaluation_metadata_match_rate() {
964 let metadata = EvaluationMetadata::new(1024, 1.0, 20, 5);
965 assert!((metadata.match_rate() - 25.0).abs() < f64::EPSILON);
966
967 let perfect_match = EvaluationMetadata::new(1024, 1.0, 10, 10);
968 assert!((perfect_match.match_rate() - 100.0).abs() < f64::EPSILON);
969
970 let no_matches = EvaluationMetadata::new(1024, 1.0, 15, 0);
971 assert!((no_matches.match_rate() - 0.0).abs() < f64::EPSILON);
972
973 let no_rules = EvaluationMetadata::new(1024, 1.0, 0, 0);
974 assert!((no_rules.match_rate() - 0.0).abs() < f64::EPSILON);
975 }
976
977 #[test]
978 fn test_evaluation_metadata_serialization() {
979 let metadata = EvaluationMetadata::new(2048, 3.7, 25, 4);
980
981 let json =
982 serde_json::to_string(&metadata).expect("Failed to serialize EvaluationMetadata");
983 let deserialized: EvaluationMetadata =
984 serde_json::from_str(&json).expect("Failed to deserialize EvaluationMetadata");
985
986 assert_eq!(metadata.file_size, deserialized.file_size);
987 assert!(
988 (metadata.evaluation_time_ms - deserialized.evaluation_time_ms).abs() < f64::EPSILON
989 );
990 assert_eq!(metadata.rules_evaluated, deserialized.rules_evaluated);
991 assert_eq!(metadata.rules_matched, deserialized.rules_matched);
992 }
993
994 #[test]
995 fn test_match_result_equality() {
996 let result1 = MatchResult::new("Test".to_string(), 0, Value::Uint(42));
997
998 let result2 = MatchResult::new("Test".to_string(), 0, Value::Uint(42));
999
1000 let result3 = MatchResult::new("Different".to_string(), 0, Value::Uint(42));
1001
1002 assert_eq!(result1, result2);
1003 assert_ne!(result1, result3);
1004 }
1005
1006 #[test]
1007 fn test_complex_evaluation_result() {
1008 // Test a complex scenario with multiple matches and full metadata
1009 let matches = vec![
1010 MatchResult::with_metadata(
1011 "ELF 64-bit LSB executable".to_string(),
1012 0,
1013 4,
1014 Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
1015 vec!["elf".to_string(), "elf64".to_string()],
1016 95,
1017 Some("application/x-executable".to_string()),
1018 ),
1019 MatchResult::with_metadata(
1020 "x86-64 architecture".to_string(),
1021 18,
1022 2,
1023 Value::Uint(0x3e),
1024 vec!["elf".to_string(), "elf64".to_string(), "x86_64".to_string()],
1025 85,
1026 None,
1027 ),
1028 MatchResult::with_metadata(
1029 "dynamically linked".to_string(),
1030 16,
1031 2,
1032 Value::Uint(0x02),
1033 vec![
1034 "elf".to_string(),
1035 "elf64".to_string(),
1036 "dynamic".to_string(),
1037 ],
1038 80,
1039 None,
1040 ),
1041 ];
1042
1043 let metadata = EvaluationMetadata::new(8192, 4.2, 35, 3);
1044 let result = EvaluationResult::new(PathBuf::from("/usr/bin/ls"), matches, metadata);
1045
1046 assert_eq!(result.matches.len(), 3);
1047 let expected_rate = (3.0 / 35.0) * 100.0;
1048 assert!((result.metadata.match_rate() - expected_rate).abs() < f64::EPSILON);
1049
1050 let primary = result.primary_match().unwrap();
1051 assert_eq!(primary.message, "ELF 64-bit LSB executable");
1052 assert_eq!(primary.confidence, 95);
1053 assert_eq!(
1054 primary.mime_type,
1055 Some("application/x-executable".to_string())
1056 );
1057
1058 // Verify all matches have proper rule paths
1059 for match_result in &result.matches {
1060 assert!(!match_result.rule_path.is_empty());
1061 assert!(match_result.rule_path[0] == "elf");
1062 }
1063 }
1064}