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}