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