Skip to main content

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