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 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            // `bit_width()` returns multiples of 8, so the division is exact.
307            #[allow(clippy::integer_division)]
308            Value::Uint(_) | Value::Int(_) | Value::Float(_) => m
309                .type_kind
310                .bit_width()
311                .map_or(0, |bits| (bits / 8) as usize),
312        };
313
314        Self::with_metadata(
315            m.message.clone(),
316            m.offset,
317            length,
318            m.value.clone(),
319            rule_path,
320            confidence,
321            mime_type.map(String::from),
322        )
323    }
324
325    /// Set the confidence score for this match
326    ///
327    /// The confidence score is automatically clamped to the range 0-100.
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    ///     "Text file".to_string(),
337    ///     0,
338    ///     Value::String("Hello".to_string())
339    /// );
340    ///
341    /// result.set_confidence(75);
342    /// assert_eq!(result.confidence, 75);
343    ///
344    /// // Values over 100 are clamped
345    /// result.set_confidence(150);
346    /// assert_eq!(result.confidence, 100);
347    /// ```
348    pub fn set_confidence(&mut self, confidence: u8) {
349        self.confidence = confidence.min(100);
350    }
351
352    /// Add a rule name to the rule path
353    ///
354    /// This is typically used during evaluation to build up the hierarchical
355    /// path of rules that led to a match.
356    ///
357    /// # Examples
358    ///
359    /// ```
360    /// use libmagic_rs::output::MatchResult;
361    /// use libmagic_rs::parser::ast::Value;
362    ///
363    /// let mut result = MatchResult::new(
364    ///     "Archive".to_string(),
365    ///     0,
366    ///     Value::String("PK".to_string())
367    /// );
368    ///
369    /// result.add_rule_path("archive".to_string());
370    /// result.add_rule_path("zip".to_string());
371    ///
372    /// assert_eq!(result.rule_path, vec!["archive", "zip"]);
373    /// ```
374    pub fn add_rule_path(&mut self, rule_name: String) {
375        self.rule_path.push(rule_name);
376    }
377
378    /// Set the MIME type for this match
379    ///
380    /// # Examples
381    ///
382    /// ```
383    /// use libmagic_rs::output::MatchResult;
384    /// use libmagic_rs::parser::ast::Value;
385    ///
386    /// let mut result = MatchResult::new(
387    ///     "PDF document".to_string(),
388    ///     0,
389    ///     Value::String("%PDF".to_string())
390    /// );
391    ///
392    /// result.set_mime_type(Some("application/pdf".to_string()));
393    /// assert_eq!(result.mime_type, Some("application/pdf".to_string()));
394    /// ```
395    pub fn set_mime_type(&mut self, mime_type: Option<String>) {
396        self.mime_type = mime_type;
397    }
398}
399
400impl EvaluationResult {
401    /// Create a new evaluation result
402    ///
403    /// # Arguments
404    ///
405    /// * `filename` - Path to the analyzed file
406    /// * `matches` - Vector of successful matches
407    /// * `metadata` - Evaluation metadata
408    ///
409    /// # Examples
410    ///
411    /// ```
412    /// use libmagic_rs::output::{EvaluationResult, EvaluationMetadata};
413    /// use std::path::PathBuf;
414    ///
415    /// let result = EvaluationResult::new(
416    ///     PathBuf::from("test.txt"),
417    ///     vec![],
418    ///     EvaluationMetadata::new(1024, 1.2, 10, 0),
419    /// );
420    ///
421    /// assert_eq!(result.filename, PathBuf::from("test.txt"));
422    /// assert!(result.matches.is_empty());
423    /// assert!(result.error.is_none());
424    /// ```
425    #[must_use]
426    pub fn new(filename: PathBuf, matches: Vec<MatchResult>, metadata: EvaluationMetadata) -> Self {
427        Self {
428            filename,
429            matches,
430            metadata,
431            error: None,
432        }
433    }
434
435    /// Convert from a library `EvaluationResult` to an output `EvaluationResult`
436    ///
437    /// This adapts the library's evaluation result into the output format used for
438    /// JSON and structured output. Converts all matches and metadata, and enriches
439    /// the first match's rule path with tags extracted from the overall description.
440    ///
441    /// # Arguments
442    ///
443    /// * `result` - The library evaluation result to convert
444    /// * `filename` - Path to the file that was evaluated
445    #[must_use]
446    pub fn from_library_result(
447        result: &crate::EvaluationResult,
448        filename: &std::path::Path,
449    ) -> Self {
450        let mut output_matches: Vec<MatchResult> = result
451            .matches
452            .iter()
453            .map(|m| MatchResult::from_evaluator_match(m, result.mime_type.as_deref()))
454            .collect();
455
456        // Enrich the first match with tags from the overall description
457        if let Some(first) = output_matches.first_mut()
458            && first.rule_path.is_empty()
459        {
460            first.rule_path = DEFAULT_TAG_EXTRACTOR.extract_tags(&result.description);
461        }
462
463        #[allow(clippy::cast_possible_truncation)]
464        let rules_evaluated = result.metadata.rules_evaluated as u32;
465        #[allow(clippy::cast_possible_truncation)]
466        let rules_matched = output_matches.len() as u32;
467
468        Self::new(
469            filename.to_path_buf(),
470            output_matches,
471            EvaluationMetadata::new(
472                result.metadata.file_size,
473                result.metadata.evaluation_time_ms,
474                rules_evaluated,
475                rules_matched,
476            ),
477        )
478    }
479
480    /// Create an evaluation result with an error
481    ///
482    /// # Arguments
483    ///
484    /// * `filename` - Path to the analyzed file
485    /// * `error` - Error message describing what went wrong
486    /// * `metadata` - Evaluation metadata (may be partial)
487    ///
488    /// # Examples
489    ///
490    /// ```
491    /// use libmagic_rs::output::{EvaluationResult, EvaluationMetadata};
492    /// use std::path::PathBuf;
493    ///
494    /// let result = EvaluationResult::with_error(
495    ///     PathBuf::from("missing.txt"),
496    ///     "File not found".to_string(),
497    ///     EvaluationMetadata::new(0, 0.0, 0, 0),
498    /// );
499    ///
500    /// assert_eq!(result.error, Some("File not found".to_string()));
501    /// assert!(result.matches.is_empty());
502    /// ```
503    #[must_use]
504    pub fn with_error(filename: PathBuf, error: String, metadata: EvaluationMetadata) -> Self {
505        Self {
506            filename,
507            matches: Vec::new(),
508            metadata,
509            error: Some(error),
510        }
511    }
512
513    /// Add a match result to this evaluation
514    ///
515    /// # Examples
516    ///
517    /// ```
518    /// use libmagic_rs::output::{EvaluationResult, MatchResult, EvaluationMetadata};
519    /// use libmagic_rs::parser::ast::Value;
520    /// use std::path::PathBuf;
521    ///
522    /// let mut result = EvaluationResult::new(
523    ///     PathBuf::from("data.bin"),
524    ///     vec![],
525    ///     EvaluationMetadata::new(512, 0.8, 5, 0),
526    /// );
527    ///
528    /// let match_result = MatchResult::new(
529    ///     "Binary data".to_string(),
530    ///     0,
531    ///     Value::Bytes(vec![0x00, 0x01, 0x02])
532    /// );
533    ///
534    /// result.add_match(match_result);
535    /// assert_eq!(result.matches.len(), 1);
536    /// ```
537    pub fn add_match(&mut self, match_result: MatchResult) {
538        Self::validate_match_result(&match_result);
539
540        self.matches.push(match_result);
541    }
542
543    /// Validate a match result before adding it
544    fn validate_match_result(match_result: &MatchResult) {
545        // Validate confidence score range
546        if match_result.confidence > 100 {
547            warn!(
548                "Match result has confidence score > 100: {}",
549                match_result.confidence
550            );
551        }
552    }
553
554    /// Get the primary match (first match with highest confidence)
555    ///
556    /// Returns the match that is most likely to represent the primary file type.
557    /// This is typically the first match, but if multiple matches exist, the one
558    /// with the highest confidence score is preferred.
559    ///
560    /// # Examples
561    ///
562    /// ```
563    /// use libmagic_rs::output::{EvaluationResult, MatchResult, EvaluationMetadata};
564    /// use libmagic_rs::parser::ast::Value;
565    /// use std::path::PathBuf;
566    ///
567    /// let mut result = EvaluationResult::new(
568    ///     PathBuf::from("test.exe"),
569    ///     vec![
570    ///         MatchResult::with_metadata(
571    ///             "Executable".to_string(),
572    ///             0, 2,
573    ///             Value::String("MZ".to_string()),
574    ///             vec!["pe".to_string()],
575    ///             60,
576    ///             None
577    ///         ),
578    ///         MatchResult::with_metadata(
579    ///             "PE32 executable".to_string(),
580    ///             60, 4,
581    ///             Value::String("PE\0\0".to_string()),
582    ///             vec!["pe".to_string(), "pe32".to_string()],
583    ///             90,
584    ///             Some("application/x-msdownload".to_string())
585    ///         ),
586    ///     ],
587    ///     EvaluationMetadata::new(4096, 1.5, 15, 2),
588    /// );
589    ///
590    /// let primary = result.primary_match();
591    /// assert!(primary.is_some());
592    /// assert_eq!(primary.unwrap().confidence, 90);
593    /// ```
594    #[must_use]
595    pub fn primary_match(&self) -> Option<&MatchResult> {
596        self.matches
597            .iter()
598            .max_by_key(|match_result| match_result.confidence)
599    }
600
601    /// Check if the evaluation was successful (no errors)
602    ///
603    /// # Examples
604    ///
605    /// ```
606    /// use libmagic_rs::output::{EvaluationResult, EvaluationMetadata};
607    /// use std::path::PathBuf;
608    ///
609    /// let success = EvaluationResult::new(
610    ///     PathBuf::from("good.txt"),
611    ///     vec![],
612    ///     EvaluationMetadata::new(100, 0.5, 3, 0),
613    /// );
614    ///
615    /// let failure = EvaluationResult::with_error(
616    ///     PathBuf::from("bad.txt"),
617    ///     "Parse error".to_string(),
618    ///     EvaluationMetadata::new(0, 0.0, 0, 0),
619    /// );
620    ///
621    /// assert!(success.is_success());
622    /// assert!(!failure.is_success());
623    /// ```
624    #[must_use]
625    pub fn is_success(&self) -> bool {
626        self.error.is_none()
627    }
628}
629
630impl EvaluationMetadata {
631    /// Create new evaluation metadata
632    ///
633    /// # Arguments
634    ///
635    /// * `file_size` - Size of the analyzed file in bytes
636    /// * `evaluation_time_ms` - Time taken for evaluation in milliseconds
637    /// * `rules_evaluated` - Number of rules that were tested
638    /// * `rules_matched` - Number of rules that matched
639    ///
640    /// # Examples
641    ///
642    /// ```
643    /// use libmagic_rs::output::EvaluationMetadata;
644    ///
645    /// let metadata = EvaluationMetadata::new(2048, 3.7, 25, 3);
646    ///
647    /// assert_eq!(metadata.file_size, 2048);
648    /// assert_eq!(metadata.evaluation_time_ms, 3.7);
649    /// assert_eq!(metadata.rules_evaluated, 25);
650    /// assert_eq!(metadata.rules_matched, 3);
651    /// ```
652    #[must_use]
653    pub fn new(
654        file_size: u64,
655        evaluation_time_ms: f64,
656        rules_evaluated: u32,
657        rules_matched: u32,
658    ) -> Self {
659        Self {
660            file_size,
661            evaluation_time_ms,
662            rules_evaluated,
663            rules_matched,
664        }
665    }
666
667    /// Get the match rate as a percentage
668    ///
669    /// Returns the percentage of evaluated rules that resulted in matches.
670    ///
671    /// # Examples
672    ///
673    /// ```
674    /// use libmagic_rs::output::EvaluationMetadata;
675    ///
676    /// let metadata = EvaluationMetadata::new(1024, 1.0, 20, 5);
677    /// assert_eq!(metadata.match_rate(), 25.0);
678    ///
679    /// let no_rules = EvaluationMetadata::new(1024, 1.0, 0, 0);
680    /// assert_eq!(no_rules.match_rate(), 0.0);
681    /// ```
682    #[must_use]
683    pub fn match_rate(&self) -> f64 {
684        if self.rules_evaluated == 0 {
685            0.0
686        } else {
687            (f64::from(self.rules_matched) / f64::from(self.rules_evaluated)) * 100.0
688        }
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn test_match_result_new() {
698        let result = MatchResult::new(
699            "Test file".to_string(),
700            42,
701            Value::String("test".to_string()),
702        );
703
704        assert_eq!(result.message, "Test file");
705        assert_eq!(result.offset, 42);
706        assert_eq!(result.length, 4); // Length of "test"
707        assert_eq!(result.value, Value::String("test".to_string()));
708        assert!(result.rule_path.is_empty());
709        assert_eq!(result.confidence, 50);
710        assert!(result.mime_type.is_none());
711    }
712
713    #[test]
714    fn test_match_result_with_metadata() {
715        let result = MatchResult::with_metadata(
716            "ELF executable".to_string(),
717            0,
718            4,
719            Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
720            vec!["elf".to_string()],
721            95,
722            Some("application/x-executable".to_string()),
723        );
724
725        assert_eq!(result.message, "ELF executable");
726        assert_eq!(result.offset, 0);
727        assert_eq!(result.length, 4);
728        assert_eq!(result.rule_path, vec!["elf"]);
729        assert_eq!(result.confidence, 95);
730        assert_eq!(
731            result.mime_type,
732            Some("application/x-executable".to_string())
733        );
734    }
735
736    #[test]
737    fn test_match_result_length_calculation() {
738        // Test length calculation for different value types
739        let bytes_result = MatchResult::new("Bytes".to_string(), 0, Value::Bytes(vec![1, 2, 3]));
740        assert_eq!(bytes_result.length, 3);
741
742        let string_result =
743            MatchResult::new("String".to_string(), 0, Value::String("hello".to_string()));
744        assert_eq!(string_result.length, 5);
745
746        let uint_result = MatchResult::new("Uint".to_string(), 0, Value::Uint(42));
747        assert_eq!(uint_result.length, 8); // size_of::<u64>()
748
749        let int_result = MatchResult::new("Int".to_string(), 0, Value::Int(-42));
750        assert_eq!(int_result.length, 8); // size_of::<u64>()
751    }
752
753    #[test]
754    fn test_match_result_set_confidence() {
755        let mut result = MatchResult::new("Test".to_string(), 0, Value::Uint(0));
756
757        result.set_confidence(75);
758        assert_eq!(result.confidence, 75);
759
760        // Test clamping to 100
761        result.set_confidence(150);
762        assert_eq!(result.confidence, 100);
763
764        result.set_confidence(0);
765        assert_eq!(result.confidence, 0);
766    }
767
768    #[test]
769    fn test_match_result_confidence_clamping_in_constructor() {
770        let result = MatchResult::with_metadata(
771            "Test".to_string(),
772            0,
773            1,
774            Value::Uint(0),
775            vec![],
776            200, // Over 100
777            None,
778        );
779
780        assert_eq!(result.confidence, 100);
781    }
782
783    #[test]
784    fn test_match_result_add_rule_path() {
785        let mut result = MatchResult::new("Test".to_string(), 0, Value::Uint(0));
786
787        result.add_rule_path("root".to_string());
788        result.add_rule_path("child".to_string());
789        result.add_rule_path("grandchild".to_string());
790
791        assert_eq!(result.rule_path, vec!["root", "child", "grandchild"]);
792    }
793
794    #[test]
795    fn test_match_result_set_mime_type() {
796        let mut result = MatchResult::new("Test".to_string(), 0, Value::Uint(0));
797
798        result.set_mime_type(Some("text/plain".to_string()));
799        assert_eq!(result.mime_type, Some("text/plain".to_string()));
800
801        result.set_mime_type(None);
802        assert!(result.mime_type.is_none());
803    }
804
805    #[test]
806    fn test_match_result_serialization() {
807        let result = MatchResult::with_metadata(
808            "PNG image".to_string(),
809            0,
810            8,
811            Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
812            vec!["image".to_string(), "png".to_string()],
813            90,
814            Some("image/png".to_string()),
815        );
816
817        let json = serde_json::to_string(&result).expect("Failed to serialize MatchResult");
818        let deserialized: MatchResult =
819            serde_json::from_str(&json).expect("Failed to deserialize MatchResult");
820
821        assert_eq!(result, deserialized);
822    }
823
824    #[test]
825    fn test_evaluation_result_new() {
826        let metadata = EvaluationMetadata::new(1024, 2.5, 10, 2);
827        let result = EvaluationResult::new(PathBuf::from("test.bin"), vec![], metadata);
828
829        assert_eq!(result.filename, PathBuf::from("test.bin"));
830        assert!(result.matches.is_empty());
831        assert!(result.error.is_none());
832        assert_eq!(result.metadata.file_size, 1024);
833    }
834
835    #[test]
836    fn test_evaluation_result_with_error() {
837        let metadata = EvaluationMetadata::new(0, 0.0, 0, 0);
838        let result = EvaluationResult::with_error(
839            PathBuf::from("missing.txt"),
840            "File not found".to_string(),
841            metadata,
842        );
843
844        assert_eq!(result.error, Some("File not found".to_string()));
845        assert!(result.matches.is_empty());
846        assert!(!result.is_success());
847    }
848
849    #[test]
850    fn test_evaluation_result_add_match() {
851        let metadata = EvaluationMetadata::new(512, 1.0, 5, 0);
852        let mut result = EvaluationResult::new(PathBuf::from("data.bin"), vec![], metadata);
853
854        let match_result =
855            MatchResult::new("Binary data".to_string(), 0, Value::Bytes(vec![0x00, 0x01]));
856
857        result.add_match(match_result);
858        assert_eq!(result.matches.len(), 1);
859        assert_eq!(result.matches[0].message, "Binary data");
860    }
861
862    #[test]
863    fn test_evaluation_result_primary_match() {
864        let metadata = EvaluationMetadata::new(2048, 3.0, 20, 3);
865        let matches = vec![
866            MatchResult::with_metadata(
867                "Low confidence".to_string(),
868                0,
869                2,
870                Value::String("AB".to_string()),
871                vec![],
872                30,
873                None,
874            ),
875            MatchResult::with_metadata(
876                "High confidence".to_string(),
877                10,
878                4,
879                Value::String("TEST".to_string()),
880                vec![],
881                95,
882                None,
883            ),
884            MatchResult::with_metadata(
885                "Medium confidence".to_string(),
886                20,
887                3,
888                Value::String("XYZ".to_string()),
889                vec![],
890                60,
891                None,
892            ),
893        ];
894
895        let result = EvaluationResult::new(PathBuf::from("test.dat"), matches, metadata);
896
897        let primary = result.primary_match();
898        assert!(primary.is_some());
899        assert_eq!(primary.unwrap().message, "High confidence");
900        assert_eq!(primary.unwrap().confidence, 95);
901    }
902
903    #[test]
904    fn test_evaluation_result_primary_match_empty() {
905        let metadata = EvaluationMetadata::new(0, 0.0, 0, 0);
906        let result = EvaluationResult::new(PathBuf::from("empty.txt"), vec![], metadata);
907
908        assert!(result.primary_match().is_none());
909    }
910
911    #[test]
912    fn test_evaluation_result_is_success() {
913        let metadata = EvaluationMetadata::new(100, 0.5, 3, 1);
914
915        let success = EvaluationResult::new(PathBuf::from("good.txt"), vec![], metadata.clone());
916
917        let failure = EvaluationResult::with_error(
918            PathBuf::from("bad.txt"),
919            "Error occurred".to_string(),
920            metadata,
921        );
922
923        assert!(success.is_success());
924        assert!(!failure.is_success());
925    }
926
927    #[test]
928    fn test_evaluation_result_serialization() {
929        let match_result = MatchResult::new(
930            "Text file".to_string(),
931            0,
932            Value::String("Hello".to_string()),
933        );
934
935        let metadata = EvaluationMetadata::new(1024, 1.5, 8, 1);
936        let result =
937            EvaluationResult::new(PathBuf::from("hello.txt"), vec![match_result], metadata);
938
939        let json = serde_json::to_string(&result).expect("Failed to serialize EvaluationResult");
940        let deserialized: EvaluationResult =
941            serde_json::from_str(&json).expect("Failed to deserialize EvaluationResult");
942
943        assert_eq!(result.filename, deserialized.filename);
944        assert_eq!(result.matches.len(), deserialized.matches.len());
945        assert_eq!(result.metadata.file_size, deserialized.metadata.file_size);
946    }
947
948    #[test]
949    fn test_evaluation_metadata_new() {
950        let metadata = EvaluationMetadata::new(4096, 5.2, 50, 8);
951
952        assert_eq!(metadata.file_size, 4096);
953        assert!((metadata.evaluation_time_ms - 5.2).abs() < f64::EPSILON);
954        assert_eq!(metadata.rules_evaluated, 50);
955        assert_eq!(metadata.rules_matched, 8);
956    }
957
958    #[test]
959    fn test_evaluation_metadata_match_rate() {
960        let metadata = EvaluationMetadata::new(1024, 1.0, 20, 5);
961        assert!((metadata.match_rate() - 25.0).abs() < f64::EPSILON);
962
963        let perfect_match = EvaluationMetadata::new(1024, 1.0, 10, 10);
964        assert!((perfect_match.match_rate() - 100.0).abs() < f64::EPSILON);
965
966        let no_matches = EvaluationMetadata::new(1024, 1.0, 15, 0);
967        assert!((no_matches.match_rate() - 0.0).abs() < f64::EPSILON);
968
969        let no_rules = EvaluationMetadata::new(1024, 1.0, 0, 0);
970        assert!((no_rules.match_rate() - 0.0).abs() < f64::EPSILON);
971    }
972
973    #[test]
974    fn test_evaluation_metadata_serialization() {
975        let metadata = EvaluationMetadata::new(2048, 3.7, 25, 4);
976
977        let json =
978            serde_json::to_string(&metadata).expect("Failed to serialize EvaluationMetadata");
979        let deserialized: EvaluationMetadata =
980            serde_json::from_str(&json).expect("Failed to deserialize EvaluationMetadata");
981
982        assert_eq!(metadata.file_size, deserialized.file_size);
983        assert!(
984            (metadata.evaluation_time_ms - deserialized.evaluation_time_ms).abs() < f64::EPSILON
985        );
986        assert_eq!(metadata.rules_evaluated, deserialized.rules_evaluated);
987        assert_eq!(metadata.rules_matched, deserialized.rules_matched);
988    }
989
990    #[test]
991    fn test_match_result_equality() {
992        let result1 = MatchResult::new("Test".to_string(), 0, Value::Uint(42));
993
994        let result2 = MatchResult::new("Test".to_string(), 0, Value::Uint(42));
995
996        let result3 = MatchResult::new("Different".to_string(), 0, Value::Uint(42));
997
998        assert_eq!(result1, result2);
999        assert_ne!(result1, result3);
1000    }
1001
1002    #[test]
1003    fn test_complex_evaluation_result() {
1004        // Test a complex scenario with multiple matches and full metadata
1005        let matches = vec![
1006            MatchResult::with_metadata(
1007                "ELF 64-bit LSB executable".to_string(),
1008                0,
1009                4,
1010                Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
1011                vec!["elf".to_string(), "elf64".to_string()],
1012                95,
1013                Some("application/x-executable".to_string()),
1014            ),
1015            MatchResult::with_metadata(
1016                "x86-64 architecture".to_string(),
1017                18,
1018                2,
1019                Value::Uint(0x3e),
1020                vec!["elf".to_string(), "elf64".to_string(), "x86_64".to_string()],
1021                85,
1022                None,
1023            ),
1024            MatchResult::with_metadata(
1025                "dynamically linked".to_string(),
1026                16,
1027                2,
1028                Value::Uint(0x02),
1029                vec![
1030                    "elf".to_string(),
1031                    "elf64".to_string(),
1032                    "dynamic".to_string(),
1033                ],
1034                80,
1035                None,
1036            ),
1037        ];
1038
1039        let metadata = EvaluationMetadata::new(8192, 4.2, 35, 3);
1040        let result = EvaluationResult::new(PathBuf::from("/usr/bin/ls"), matches, metadata);
1041
1042        assert_eq!(result.matches.len(), 3);
1043        let expected_rate = (3.0 / 35.0) * 100.0;
1044        assert!((result.metadata.match_rate() - expected_rate).abs() < f64::EPSILON);
1045
1046        let primary = result.primary_match().unwrap();
1047        assert_eq!(primary.message, "ELF 64-bit LSB executable");
1048        assert_eq!(primary.confidence, 95);
1049        assert_eq!(
1050            primary.mime_type,
1051            Some("application/x-executable".to_string())
1052        );
1053
1054        // Verify all matches have proper rule paths
1055        for match_result in &result.matches {
1056            assert!(!match_result.rule_path.is_empty());
1057            assert_eq!(match_result.rule_path[0], "elf");
1058        }
1059    }
1060}