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