Skip to main content

libmagic_rs/output/
json.rs

1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! JSON output formatting for magic rule evaluation results
5//!
6//! This module provides JSON-specific data structures and formatting functions
7//! for outputting magic rule evaluation results in a structured format compatible
8//! with the original libmagic specification.
9//!
10//! The JSON output format follows the original spec with fields for text, offset,
11//! value, tags, and score, providing a machine-readable alternative to the
12//! human-readable text output format.
13
14use serde::{Deserialize, Serialize};
15use std::path::Path;
16
17use crate::output::{EvaluationResult, MatchResult};
18use crate::parser::ast::Value;
19
20/// JSON representation of a magic rule match result
21///
22/// This structure follows the original libmagic JSON specification format,
23/// providing a standardized way to represent file type detection results
24/// in JSON format for programmatic consumption.
25///
26/// # Fields
27///
28/// * `text` - Human-readable description of the file type or pattern match
29/// * `offset` - Byte offset in the file where the match occurred
30/// * `value` - Hexadecimal representation of the matched bytes
31/// * `tags` - Array of classification tags derived from the rule hierarchy
32/// * `score` - Confidence score for this match (0-100)
33///
34/// # Examples
35///
36/// ```
37/// use libmagic_rs::output::json::JsonMatchResult;
38///
39/// let json_result = JsonMatchResult {
40///     text: "ELF 64-bit LSB executable".to_string(),
41///     offset: 0,
42///     value: "7f454c46".to_string(),
43///     tags: vec!["executable".to_string(), "elf".to_string()],
44///     score: 90,
45/// };
46///
47/// assert_eq!(json_result.text, "ELF 64-bit LSB executable");
48/// assert_eq!(json_result.offset, 0);
49/// assert_eq!(json_result.value, "7f454c46");
50/// assert_eq!(json_result.tags.len(), 2);
51/// assert_eq!(json_result.score, 90);
52/// ```
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct JsonMatchResult {
55    /// Human-readable description of the file type or pattern match
56    ///
57    /// This field contains the same descriptive text that would appear
58    /// in the traditional text output format, providing context about
59    /// what type of file or pattern was detected.
60    pub text: String,
61
62    /// Byte offset in the file where the match occurred
63    ///
64    /// Indicates the exact position in the file where the magic rule
65    /// found the matching pattern. This is useful for understanding
66    /// the structure of the file and for debugging rule evaluation.
67    pub offset: usize,
68
69    /// Hexadecimal representation of the matched bytes
70    ///
71    /// Contains the actual byte values that were matched, encoded as
72    /// a hexadecimal string without separators. For string matches,
73    /// this represents the UTF-8 bytes of the matched text.
74    pub value: String,
75
76    /// Array of classification tags derived from the rule hierarchy
77    ///
78    /// These tags are extracted from the rule path and provide
79    /// machine-readable classification information about the detected
80    /// file type. Tags are typically ordered from general to specific.
81    pub tags: Vec<String>,
82
83    /// Confidence score for this match (0-100)
84    ///
85    /// Indicates how confident the detection algorithm is about this
86    /// particular match. Higher scores indicate more specific or
87    /// reliable patterns, while lower scores may indicate generic
88    /// or ambiguous matches.
89    pub score: u8,
90}
91
92impl JsonMatchResult {
93    /// Create a new JSON match result from a `MatchResult`
94    ///
95    /// Converts the internal `MatchResult` representation to the JSON format
96    /// specified in the original libmagic specification, including proper
97    /// formatting of the value field and extraction of tags from the rule path.
98    ///
99    /// # Arguments
100    ///
101    /// * `match_result` - The internal match result to convert
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use libmagic_rs::output::{MatchResult, json::JsonMatchResult};
107    /// use libmagic_rs::parser::ast::Value;
108    ///
109    /// let match_result = MatchResult::with_metadata(
110    ///     "PNG image".to_string(),
111    ///     0,
112    ///     8,
113    ///     Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
114    ///     vec!["image".to_string(), "png".to_string()],
115    ///     85,
116    ///     Some("image/png".to_string())
117    /// );
118    ///
119    /// let json_result = JsonMatchResult::from_match_result(&match_result);
120    ///
121    /// assert_eq!(json_result.text, "PNG image");
122    /// assert_eq!(json_result.offset, 0);
123    /// assert_eq!(json_result.value, "89504e470d0a1a0a");
124    /// assert_eq!(json_result.tags, vec!["image", "png"]);
125    /// assert_eq!(json_result.score, 85);
126    /// ```
127    #[must_use]
128    pub fn from_match_result(match_result: &MatchResult) -> Self {
129        Self {
130            text: match_result.message.clone(),
131            offset: match_result.offset,
132            value: format_value_as_hex(&match_result.value),
133            tags: match_result.rule_path.clone(),
134            score: match_result.confidence,
135        }
136    }
137
138    /// Create a new JSON match result with explicit values
139    ///
140    /// # Arguments
141    ///
142    /// * `text` - Human-readable description
143    /// * `offset` - Byte offset where match occurred
144    /// * `value` - Hexadecimal string representation of matched bytes
145    /// * `tags` - Classification tags
146    /// * `score` - Confidence score (0-100)
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use libmagic_rs::output::json::JsonMatchResult;
152    ///
153    /// let json_result = JsonMatchResult::new(
154    ///     "JPEG image".to_string(),
155    ///     0,
156    ///     "ffd8".to_string(),
157    ///     vec!["image".to_string(), "jpeg".to_string()],
158    ///     80
159    /// );
160    ///
161    /// assert_eq!(json_result.text, "JPEG image");
162    /// assert_eq!(json_result.value, "ffd8");
163    /// assert_eq!(json_result.score, 80);
164    /// ```
165    #[must_use]
166    pub fn new(text: String, offset: usize, value: String, tags: Vec<String>, score: u8) -> Self {
167        Self {
168            text,
169            offset,
170            value,
171            tags,
172            score: score.min(100), // Clamp score to valid range
173        }
174    }
175
176    /// Add a tag to the tags array
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use libmagic_rs::output::json::JsonMatchResult;
182    ///
183    /// let mut json_result = JsonMatchResult::new(
184    ///     "Archive".to_string(),
185    ///     0,
186    ///     "504b0304".to_string(),
187    ///     vec!["archive".to_string()],
188    ///     75
189    /// );
190    ///
191    /// json_result.add_tag("zip".to_string());
192    /// assert_eq!(json_result.tags, vec!["archive", "zip"]);
193    /// ```
194    pub fn add_tag(&mut self, tag: String) {
195        self.tags.push(tag);
196    }
197
198    /// Set the confidence score, clamping to valid range
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// use libmagic_rs::output::json::JsonMatchResult;
204    ///
205    /// let mut json_result = JsonMatchResult::new(
206    ///     "Text".to_string(),
207    ///     0,
208    ///     "48656c6c6f".to_string(),
209    ///     vec![],
210    ///     50
211    /// );
212    ///
213    /// json_result.set_score(95);
214    /// assert_eq!(json_result.score, 95);
215    ///
216    /// // Values over 100 are clamped
217    /// json_result.set_score(150);
218    /// assert_eq!(json_result.score, 100);
219    /// ```
220    pub fn set_score(&mut self, score: u8) {
221        self.score = score.min(100);
222    }
223}
224
225/// Format a Value as a hexadecimal string for JSON output
226///
227/// Converts different Value types to their hexadecimal string representation
228/// suitable for inclusion in JSON output. Byte arrays are converted directly,
229/// while other types are first converted to their byte representation.
230///
231/// # Arguments
232///
233/// * `value` - The Value to format as hexadecimal
234///
235/// # Returns
236///
237/// A lowercase hexadecimal string without separators or prefixes
238///
239/// # Examples
240///
241/// ```
242/// use libmagic_rs::output::json::format_value_as_hex;
243/// use libmagic_rs::parser::ast::Value;
244///
245/// let bytes_value = Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]);
246/// assert_eq!(format_value_as_hex(&bytes_value), "7f454c46");
247///
248/// let string_value = Value::String("PNG".to_string());
249/// assert_eq!(format_value_as_hex(&string_value), "504e47");
250///
251/// let uint_value = Value::Uint(0x1234);
252/// assert_eq!(format_value_as_hex(&uint_value), "3412000000000000"); // Little-endian u64
253/// ```
254#[must_use]
255pub fn format_value_as_hex(value: &Value) -> String {
256    fn hex_string(bytes: &[u8]) -> String {
257        use std::fmt::Write;
258
259        let mut result = String::with_capacity(bytes.len().saturating_mul(2));
260        for &b in bytes {
261            // fmt::Write to a String is infallible; discard the Result
262            // rather than unwrap so the no-panic policy holds regardless.
263            #[allow(clippy::let_underscore_must_use)]
264            let _ = write!(result, "{b:02x}");
265        }
266        result
267    }
268
269    // Numeric values are converted to little-endian bytes for consistency.
270    match value {
271        Value::Bytes(bytes) => hex_string(bytes),
272        Value::String(s) => hex_string(s.as_bytes()),
273        Value::Uint(n) => hex_string(&n.to_le_bytes()),
274        Value::Int(n) => hex_string(&n.to_le_bytes()),
275        Value::Float(f) => hex_string(&f.to_le_bytes()),
276    }
277}
278
279/// JSON output structure containing an array of matches
280///
281/// This structure represents the complete JSON output format for file type
282/// detection results, containing an array of matches that can be serialized
283/// to JSON for programmatic consumption.
284///
285/// # Examples
286///
287/// ```
288/// use libmagic_rs::output::json::{JsonOutput, JsonMatchResult};
289///
290/// let json_output = JsonOutput {
291///     matches: vec![
292///         JsonMatchResult::new(
293///             "ELF executable".to_string(),
294///             0,
295///             "7f454c46".to_string(),
296///             vec!["executable".to_string(), "elf".to_string()],
297///             90
298///         )
299///     ]
300/// };
301///
302/// assert_eq!(json_output.matches.len(), 1);
303/// ```
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct JsonOutput {
306    /// Array of match results found during evaluation
307    pub matches: Vec<JsonMatchResult>,
308}
309
310impl JsonOutput {
311    /// Create a new JSON output structure
312    ///
313    /// # Arguments
314    ///
315    /// * `matches` - Vector of JSON match results
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// use libmagic_rs::output::json::{JsonOutput, JsonMatchResult};
321    ///
322    /// let matches = vec![
323    ///     JsonMatchResult::new(
324    ///         "Text file".to_string(),
325    ///         0,
326    ///         "48656c6c6f".to_string(),
327    ///         vec!["text".to_string()],
328    ///         60
329    ///     )
330    /// ];
331    ///
332    /// let output = JsonOutput::new(matches);
333    /// assert_eq!(output.matches.len(), 1);
334    /// ```
335    #[must_use]
336    pub fn new(matches: Vec<JsonMatchResult>) -> Self {
337        Self { matches }
338    }
339
340    /// Create JSON output from an `EvaluationResult`
341    ///
342    /// Converts the internal evaluation result to the JSON format specified
343    /// in the original libmagic specification.
344    ///
345    /// # Arguments
346    ///
347    /// * `result` - The evaluation result to convert
348    ///
349    /// # Examples
350    ///
351    /// ```
352    /// use libmagic_rs::output::{EvaluationResult, MatchResult, EvaluationMetadata, json::JsonOutput};
353    /// use libmagic_rs::parser::ast::Value;
354    /// use std::path::PathBuf;
355    ///
356    /// let match_result = MatchResult::with_metadata(
357    ///     "Binary data".to_string(),
358    ///     0,
359    ///     4,
360    ///     Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]),
361    ///     vec!["binary".to_string()],
362    ///     70,
363    ///     None
364    /// );
365    ///
366    /// let metadata = EvaluationMetadata::new(1024, 1.5, 10, 1);
367    /// let eval_result = EvaluationResult::new(
368    ///     PathBuf::from("test.bin"),
369    ///     vec![match_result],
370    ///     metadata
371    /// );
372    ///
373    /// let json_output = JsonOutput::from_evaluation_result(&eval_result);
374    /// assert_eq!(json_output.matches.len(), 1);
375    /// assert_eq!(json_output.matches[0].text, "Binary data");
376    /// assert_eq!(json_output.matches[0].value, "deadbeef");
377    /// ```
378    #[must_use]
379    pub fn from_evaluation_result(result: &EvaluationResult) -> Self {
380        let matches = result
381            .matches
382            .iter()
383            .map(JsonMatchResult::from_match_result)
384            .collect();
385
386        Self { matches }
387    }
388
389    /// Add a match result to the output
390    ///
391    /// # Examples
392    ///
393    /// ```
394    /// use libmagic_rs::output::json::{JsonOutput, JsonMatchResult};
395    ///
396    /// let mut output = JsonOutput::new(vec![]);
397    ///
398    /// let match_result = JsonMatchResult::new(
399    ///     "PDF document".to_string(),
400    ///     0,
401    ///     "25504446".to_string(),
402    ///     vec!["document".to_string(), "pdf".to_string()],
403    ///     85
404    /// );
405    ///
406    /// output.add_match(match_result);
407    /// assert_eq!(output.matches.len(), 1);
408    /// ```
409    pub fn add_match(&mut self, match_result: JsonMatchResult) {
410        self.matches.push(match_result);
411    }
412
413    /// Check if there are any matches
414    ///
415    /// # Examples
416    ///
417    /// ```
418    /// use libmagic_rs::output::json::JsonOutput;
419    ///
420    /// let empty_output = JsonOutput::new(vec![]);
421    /// assert!(!empty_output.has_matches());
422    ///
423    /// let output_with_matches = JsonOutput::new(vec![
424    ///     libmagic_rs::output::json::JsonMatchResult::new(
425    ///         "Test".to_string(),
426    ///         0,
427    ///         "74657374".to_string(),
428    ///         vec![],
429    ///         50
430    ///     )
431    /// ]);
432    /// assert!(output_with_matches.has_matches());
433    /// ```
434    #[must_use]
435    pub fn has_matches(&self) -> bool {
436        !self.matches.is_empty()
437    }
438
439    /// Get the number of matches
440    ///
441    /// # Examples
442    ///
443    /// ```
444    /// use libmagic_rs::output::json::{JsonOutput, JsonMatchResult};
445    ///
446    /// let matches = vec![
447    ///     JsonMatchResult::new("Match 1".to_string(), 0, "01".to_string(), vec![], 50),
448    ///     JsonMatchResult::new("Match 2".to_string(), 10, "02".to_string(), vec![], 60),
449    /// ];
450    ///
451    /// let output = JsonOutput::new(matches);
452    /// assert_eq!(output.match_count(), 2);
453    /// ```
454    #[must_use]
455    pub fn match_count(&self) -> usize {
456        self.matches.len()
457    }
458}
459
460/// Format match results as JSON output string
461///
462/// Converts a vector of `MatchResult` objects into a JSON string following
463/// the original libmagic specification format. The output contains a matches
464/// array with proper field mapping for programmatic consumption.
465///
466/// # Arguments
467///
468/// * `match_results` - Vector of match results to format
469///
470/// # Returns
471///
472/// A JSON string containing the formatted match results, or an error if
473/// serialization fails.
474///
475/// # Examples
476///
477/// ```
478/// use libmagic_rs::output::{MatchResult, json::format_json_output};
479/// use libmagic_rs::parser::ast::Value;
480///
481/// let match_results = vec![
482///     MatchResult::with_metadata(
483///         "ELF 64-bit LSB executable".to_string(),
484///         0,
485///         4,
486///         Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
487///         vec!["executable".to_string(), "elf".to_string()],
488///         90,
489///         Some("application/x-executable".to_string())
490///     ),
491///     MatchResult::with_metadata(
492///         "x86-64 architecture".to_string(),
493///         18,
494///         2,
495///         Value::Uint(0x3e00),
496///         vec!["elf".to_string(), "x86_64".to_string()],
497///         85,
498///         None
499///     )
500/// ];
501///
502/// let json_output = format_json_output(&match_results).unwrap();
503/// assert!(json_output.contains("\"matches\""));
504/// assert!(json_output.contains("\"text\": \"ELF 64-bit LSB executable\""));
505/// assert!(json_output.contains("\"offset\": 0"));
506/// assert!(json_output.contains("\"value\": \"7f454c46\""));
507/// assert!(json_output.contains("\"score\": 90"));
508/// ```
509///
510/// # Errors
511///
512/// Returns a `serde_json::Error` if the match results cannot be serialized
513/// to JSON, which should be rare in practice since all fields are serializable.
514pub fn format_json_output(match_results: &[MatchResult]) -> Result<String, serde_json::Error> {
515    let json_matches: Vec<JsonMatchResult> = match_results
516        .iter()
517        .map(JsonMatchResult::from_match_result)
518        .collect();
519
520    let output = JsonOutput::new(json_matches);
521    serde_json::to_string_pretty(&output)
522}
523
524/// Format match results as compact JSON output string
525///
526/// Similar to `format_json_output` but produces compact JSON without
527/// pretty-printing for more efficient transmission or storage.
528///
529/// # Arguments
530///
531/// * `match_results` - Vector of match results to format
532///
533/// # Returns
534///
535/// A compact JSON string containing the formatted match results.
536///
537/// # Examples
538///
539/// ```
540/// use libmagic_rs::output::{MatchResult, json::format_json_output_compact};
541/// use libmagic_rs::parser::ast::Value;
542///
543/// let match_results = vec![
544///     MatchResult::new(
545///         "PNG image".to_string(),
546///         0,
547///         Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47])
548///     )
549/// ];
550///
551/// let json_output = format_json_output_compact(&match_results).unwrap();
552/// assert!(!json_output.contains('\n')); // No newlines in compact format
553/// assert!(json_output.contains("\"matches\""));
554/// ```
555///
556/// # Errors
557///
558/// Returns a `serde_json::Error` if the match results cannot be serialized.
559pub fn format_json_output_compact(
560    match_results: &[MatchResult],
561) -> Result<String, serde_json::Error> {
562    let json_matches: Vec<JsonMatchResult> = match_results
563        .iter()
564        .map(JsonMatchResult::from_match_result)
565        .collect();
566
567    let output = JsonOutput::new(json_matches);
568    serde_json::to_string(&output)
569}
570
571/// JSON Lines output structure with filename and matches
572///
573/// This structure is used for multi-file JSON output, where each line
574/// represents one file's results. It includes the filename alongside the
575/// match results to provide context in a streaming format.
576///
577/// JSON Lines format is used when processing multiple files to provide
578/// immediate per-file output and clear filename association.
579///
580/// # Examples
581///
582/// ```
583/// use libmagic_rs::output::json::{JsonLineOutput, JsonMatchResult};
584/// use std::path::PathBuf;
585///
586/// let matches = vec![
587///     JsonMatchResult::new(
588///         "ELF executable".to_string(),
589///         0,
590///         "7f454c46".to_string(),
591///         vec!["executable".to_string()],
592///         90
593///     )
594/// ];
595///
596/// let output = JsonLineOutput::new("file.bin".to_string(), matches);
597/// assert_eq!(output.filename, "file.bin");
598/// assert_eq!(output.matches.len(), 1);
599/// ```
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct JsonLineOutput {
602    /// Filename or path of the analyzed file
603    pub filename: String,
604    /// Array of match results found during evaluation
605    pub matches: Vec<JsonMatchResult>,
606}
607
608impl JsonLineOutput {
609    /// Create a new JSON Lines output structure
610    ///
611    /// # Arguments
612    ///
613    /// * `filename` - The filename or path as a string
614    /// * `matches` - Vector of JSON match results
615    ///
616    /// # Examples
617    ///
618    /// ```
619    /// use libmagic_rs::output::json::{JsonLineOutput, JsonMatchResult};
620    ///
621    /// let matches = vec![
622    ///     JsonMatchResult::new(
623    ///         "Text file".to_string(),
624    ///         0,
625    ///         "48656c6c6f".to_string(),
626    ///         vec!["text".to_string()],
627    ///         60
628    ///     )
629    /// ];
630    ///
631    /// let output = JsonLineOutput::new("test.txt".to_string(), matches);
632    /// assert_eq!(output.filename, "test.txt");
633    /// assert_eq!(output.matches.len(), 1);
634    /// ```
635    #[must_use]
636    pub fn new(filename: String, matches: Vec<JsonMatchResult>) -> Self {
637        Self { filename, matches }
638    }
639
640    /// Create JSON Lines output from match results and filename
641    ///
642    /// # Arguments
643    ///
644    /// * `filename` - Path to the analyzed file
645    /// * `match_results` - Vector of match results to convert
646    ///
647    /// # Examples
648    ///
649    /// ```
650    /// use libmagic_rs::output::{MatchResult, json::JsonLineOutput};
651    /// use libmagic_rs::parser::ast::Value;
652    /// use std::path::Path;
653    ///
654    /// let match_results = vec![
655    ///     MatchResult::with_metadata(
656    ///         "Binary data".to_string(),
657    ///         0,
658    ///         4,
659    ///         Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]),
660    ///         vec!["binary".to_string()],
661    ///         70,
662    ///         None
663    ///     )
664    /// ];
665    ///
666    /// let output = JsonLineOutput::from_match_results(Path::new("test.bin"), &match_results);
667    /// assert_eq!(output.filename, "test.bin");
668    /// assert_eq!(output.matches.len(), 1);
669    /// ```
670    #[must_use]
671    pub fn from_match_results(filename: &Path, match_results: &[MatchResult]) -> Self {
672        let json_matches: Vec<JsonMatchResult> = match_results
673            .iter()
674            .map(JsonMatchResult::from_match_result)
675            .collect();
676
677        Self {
678            filename: filename.display().to_string(),
679            matches: json_matches,
680        }
681    }
682}
683
684/// Format match results as JSON Lines output string
685///
686/// Produces compact single-line JSON output suitable for JSON Lines format.
687/// This is used when processing multiple files to provide immediate per-file
688/// output with filename context. Unlike `format_json_output`, this function
689/// produces compact JSON without pretty-printing.
690///
691/// # Arguments
692///
693/// * `filename` - Path to the analyzed file
694/// * `match_results` - Vector of match results to format
695///
696/// # Returns
697///
698/// A compact JSON string containing the filename and formatted match results,
699/// or an error if serialization fails.
700///
701/// # Examples
702///
703/// ```
704/// use libmagic_rs::output::{MatchResult, json::format_json_line_output};
705/// use libmagic_rs::parser::ast::Value;
706/// use std::path::Path;
707///
708/// let match_results = vec![
709///     MatchResult::with_metadata(
710///         "ELF 64-bit LSB executable".to_string(),
711///         0,
712///         4,
713///         Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
714///         vec!["executable".to_string(), "elf".to_string()],
715///         90,
716///         Some("application/x-executable".to_string())
717///     )
718/// ];
719///
720/// let json_line = format_json_line_output(Path::new("file.bin"), &match_results).unwrap();
721/// assert!(json_line.contains("\"filename\":\"file.bin\""));
722/// assert!(json_line.contains("\"text\":\"ELF 64-bit LSB executable\""));
723/// assert!(!json_line.contains('\n')); // Compact format, no newlines
724/// ```
725///
726/// # Errors
727///
728/// Returns a `serde_json::Error` if the match results cannot be serialized
729/// to JSON, which should be rare in practice since all fields are serializable.
730pub fn format_json_line_output(
731    filename: &Path,
732    match_results: &[MatchResult],
733) -> Result<String, serde_json::Error> {
734    let output = JsonLineOutput::from_match_results(filename, match_results);
735    serde_json::to_string(&output)
736}
737
738#[cfg(test)]
739mod tests {
740    // Restriction lints without an allow-*-in-tests config option;
741    // non-ASCII test data exercises JSON string escaping.
742    #![allow(clippy::non_ascii_literal)]
743
744    use super::*;
745    use crate::output::{EvaluationMetadata, EvaluationResult, MatchResult};
746    use std::path::PathBuf;
747
748    #[test]
749    fn test_json_match_result_new() {
750        let result = JsonMatchResult::new(
751            "Test file".to_string(),
752            42,
753            "74657374".to_string(),
754            vec!["test".to_string()],
755            75,
756        );
757
758        assert_eq!(result.text, "Test file");
759        assert_eq!(result.offset, 42);
760        assert_eq!(result.value, "74657374");
761        assert_eq!(result.tags, vec!["test"]);
762        assert_eq!(result.score, 75);
763    }
764
765    #[test]
766    fn test_json_match_result_score_clamping() {
767        let result = JsonMatchResult::new(
768            "Test".to_string(),
769            0,
770            "00".to_string(),
771            vec![],
772            200, // Over 100
773        );
774
775        assert_eq!(result.score, 100);
776    }
777
778    #[test]
779    fn test_json_match_result_from_match_result() {
780        let match_result = MatchResult::with_metadata(
781            "ELF 64-bit LSB executable".to_string(),
782            0,
783            4,
784            Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
785            vec!["elf".to_string(), "elf64".to_string()],
786            95,
787            Some("application/x-executable".to_string()),
788        );
789
790        let json_result = JsonMatchResult::from_match_result(&match_result);
791
792        assert_eq!(json_result.text, "ELF 64-bit LSB executable");
793        assert_eq!(json_result.offset, 0);
794        assert_eq!(json_result.value, "7f454c46");
795        assert_eq!(json_result.tags, vec!["elf", "elf64"]);
796        assert_eq!(json_result.score, 95);
797    }
798
799    #[test]
800    fn test_json_match_result_add_tag() {
801        let mut result = JsonMatchResult::new(
802            "Archive".to_string(),
803            0,
804            "504b0304".to_string(),
805            vec!["archive".to_string()],
806            80,
807        );
808
809        result.add_tag("zip".to_string());
810        result.add_tag("compressed".to_string());
811
812        assert_eq!(result.tags, vec!["archive", "zip", "compressed"]);
813    }
814
815    #[test]
816    fn test_json_match_result_set_score() {
817        let mut result = JsonMatchResult::new("Test".to_string(), 0, "00".to_string(), vec![], 50);
818
819        result.set_score(85);
820        assert_eq!(result.score, 85);
821
822        // Test clamping
823        result.set_score(150);
824        assert_eq!(result.score, 100);
825
826        result.set_score(0);
827        assert_eq!(result.score, 0);
828    }
829
830    #[test]
831    fn test_format_value_as_hex_bytes() {
832        let value = Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]);
833        assert_eq!(format_value_as_hex(&value), "7f454c46");
834
835        let empty_bytes = Value::Bytes(vec![]);
836        assert_eq!(format_value_as_hex(&empty_bytes), "");
837
838        let single_byte = Value::Bytes(vec![0xff]);
839        assert_eq!(format_value_as_hex(&single_byte), "ff");
840    }
841
842    #[test]
843    fn test_format_value_as_hex_string() {
844        let value = Value::String("PNG".to_string());
845        assert_eq!(format_value_as_hex(&value), "504e47");
846
847        let empty_string = Value::String(String::new());
848        assert_eq!(format_value_as_hex(&empty_string), "");
849
850        let unicode_string = Value::String("🦀".to_string());
851        // Rust crab emoji in UTF-8: F0 9F A6 80
852        assert_eq!(format_value_as_hex(&unicode_string), "f09fa680");
853    }
854
855    #[test]
856    fn test_format_value_as_hex_uint() {
857        let value = Value::Uint(0x1234);
858        // Little-endian u64: 0x1234 -> 34 12 00 00 00 00 00 00
859        assert_eq!(format_value_as_hex(&value), "3412000000000000");
860
861        let zero = Value::Uint(0);
862        assert_eq!(format_value_as_hex(&zero), "0000000000000000");
863
864        let max_value = Value::Uint(u64::MAX);
865        assert_eq!(format_value_as_hex(&max_value), "ffffffffffffffff");
866    }
867
868    #[test]
869    fn test_format_value_as_hex_int() {
870        let positive = Value::Int(0x1234);
871        assert_eq!(format_value_as_hex(&positive), "3412000000000000");
872
873        let negative = Value::Int(-1);
874        // -1 as i64 in little-endian: FF FF FF FF FF FF FF FF
875        assert_eq!(format_value_as_hex(&negative), "ffffffffffffffff");
876
877        let zero = Value::Int(0);
878        assert_eq!(format_value_as_hex(&zero), "0000000000000000");
879    }
880
881    #[test]
882    fn test_json_output_new() {
883        let matches = vec![
884            JsonMatchResult::new(
885                "Match 1".to_string(),
886                0,
887                "01".to_string(),
888                vec!["tag1".to_string()],
889                60,
890            ),
891            JsonMatchResult::new(
892                "Match 2".to_string(),
893                10,
894                "02".to_string(),
895                vec!["tag2".to_string()],
896                70,
897            ),
898        ];
899
900        let output = JsonOutput::new(matches);
901        assert_eq!(output.matches.len(), 2);
902        assert_eq!(output.matches[0].text, "Match 1");
903        assert_eq!(output.matches[1].text, "Match 2");
904    }
905
906    #[test]
907    fn test_json_output_from_evaluation_result() {
908        let match_results = vec![
909            MatchResult::with_metadata(
910                "PNG image".to_string(),
911                0,
912                8,
913                Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
914                vec!["image".to_string(), "png".to_string()],
915                90,
916                Some("image/png".to_string()),
917            ),
918            MatchResult::with_metadata(
919                "8-bit color".to_string(),
920                25,
921                1,
922                Value::Uint(8),
923                vec!["image".to_string(), "png".to_string(), "color".to_string()],
924                75,
925                None,
926            ),
927        ];
928
929        let metadata = EvaluationMetadata::new(2048, 3.2, 15, 2);
930        let eval_result = EvaluationResult::new(PathBuf::from("test.png"), match_results, metadata);
931
932        let json_output = JsonOutput::from_evaluation_result(&eval_result);
933
934        assert_eq!(json_output.matches.len(), 2);
935        assert_eq!(json_output.matches[0].text, "PNG image");
936        assert_eq!(json_output.matches[0].value, "89504e470d0a1a0a");
937        assert_eq!(json_output.matches[0].tags, vec!["image", "png"]);
938        assert_eq!(json_output.matches[0].score, 90);
939
940        assert_eq!(json_output.matches[1].text, "8-bit color");
941        assert_eq!(json_output.matches[1].value, "0800000000000000");
942        assert_eq!(json_output.matches[1].tags, vec!["image", "png", "color"]);
943        assert_eq!(json_output.matches[1].score, 75);
944    }
945
946    #[test]
947    fn test_json_output_add_match() {
948        let mut output = JsonOutput::new(vec![]);
949
950        let match_result = JsonMatchResult::new(
951            "PDF document".to_string(),
952            0,
953            "25504446".to_string(),
954            vec!["document".to_string(), "pdf".to_string()],
955            85,
956        );
957
958        output.add_match(match_result);
959        assert_eq!(output.matches.len(), 1);
960        assert_eq!(output.matches[0].text, "PDF document");
961    }
962
963    #[test]
964    fn test_json_output_has_matches() {
965        let empty_output = JsonOutput::new(vec![]);
966        assert!(!empty_output.has_matches());
967
968        let output_with_matches = JsonOutput::new(vec![JsonMatchResult::new(
969            "Test".to_string(),
970            0,
971            "74657374".to_string(),
972            vec![],
973            50,
974        )]);
975        assert!(output_with_matches.has_matches());
976    }
977
978    #[test]
979    fn test_json_output_match_count() {
980        let empty_output = JsonOutput::new(vec![]);
981        assert_eq!(empty_output.match_count(), 0);
982
983        let matches = vec![
984            JsonMatchResult::new("Match 1".to_string(), 0, "01".to_string(), vec![], 50),
985            JsonMatchResult::new("Match 2".to_string(), 10, "02".to_string(), vec![], 60),
986            JsonMatchResult::new("Match 3".to_string(), 20, "03".to_string(), vec![], 70),
987        ];
988
989        let output = JsonOutput::new(matches);
990        assert_eq!(output.match_count(), 3);
991    }
992
993    #[test]
994    fn test_json_match_result_serialization() {
995        let result = JsonMatchResult::new(
996            "JPEG image".to_string(),
997            0,
998            "ffd8".to_string(),
999            vec!["image".to_string(), "jpeg".to_string()],
1000            80,
1001        );
1002
1003        let json = serde_json::to_string(&result).expect("Failed to serialize JsonMatchResult");
1004        let deserialized: JsonMatchResult =
1005            serde_json::from_str(&json).expect("Failed to deserialize JsonMatchResult");
1006
1007        assert_eq!(result, deserialized);
1008    }
1009
1010    #[test]
1011    fn test_json_output_serialization() {
1012        let matches = vec![
1013            JsonMatchResult::new(
1014                "ELF executable".to_string(),
1015                0,
1016                "7f454c46".to_string(),
1017                vec!["executable".to_string(), "elf".to_string()],
1018                95,
1019            ),
1020            JsonMatchResult::new(
1021                "64-bit".to_string(),
1022                4,
1023                "02".to_string(),
1024                vec!["elf".to_string(), "64bit".to_string()],
1025                85,
1026            ),
1027        ];
1028
1029        let output = JsonOutput::new(matches);
1030
1031        let json = serde_json::to_string(&output).expect("Failed to serialize JsonOutput");
1032        let deserialized: JsonOutput =
1033            serde_json::from_str(&json).expect("Failed to deserialize JsonOutput");
1034
1035        assert_eq!(output.matches.len(), deserialized.matches.len());
1036        assert_eq!(output.matches[0].text, deserialized.matches[0].text);
1037        assert_eq!(output.matches[1].text, deserialized.matches[1].text);
1038    }
1039
1040    #[test]
1041    fn test_json_output_serialization_format() {
1042        let matches = vec![JsonMatchResult::new(
1043            "Test file".to_string(),
1044            0,
1045            "74657374".to_string(),
1046            vec!["test".to_string()],
1047            75,
1048        )];
1049
1050        let output = JsonOutput::new(matches);
1051        let json = serde_json::to_string_pretty(&output).expect("Failed to serialize");
1052
1053        // Verify the JSON structure matches the expected format
1054        assert!(json.contains("\"matches\""));
1055        assert!(json.contains("\"text\": \"Test file\""));
1056        assert!(json.contains("\"offset\": 0"));
1057        assert!(json.contains("\"value\": \"74657374\""));
1058        assert!(json.contains("\"tags\""));
1059        assert!(json.contains("\"test\""));
1060        assert!(json.contains("\"score\": 75"));
1061    }
1062
1063    #[test]
1064    fn test_json_match_result_equality() {
1065        let result1 = JsonMatchResult::new(
1066            "Test".to_string(),
1067            0,
1068            "74657374".to_string(),
1069            vec!["test".to_string()],
1070            50,
1071        );
1072
1073        let result2 = JsonMatchResult::new(
1074            "Test".to_string(),
1075            0,
1076            "74657374".to_string(),
1077            vec!["test".to_string()],
1078            50,
1079        );
1080
1081        let result3 = JsonMatchResult::new(
1082            "Different".to_string(),
1083            0,
1084            "74657374".to_string(),
1085            vec!["test".to_string()],
1086            50,
1087        );
1088
1089        assert_eq!(result1, result2);
1090        assert_ne!(result1, result3);
1091    }
1092
1093    #[test]
1094    fn test_complex_json_conversion() {
1095        // Test conversion of a complex match result with all fields populated
1096        let match_result = MatchResult::with_metadata(
1097            "ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked"
1098                .to_string(),
1099            0,
1100            4,
1101            Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
1102            vec![
1103                "executable".to_string(),
1104                "elf".to_string(),
1105                "elf64".to_string(),
1106                "x86_64".to_string(),
1107                "pie".to_string(),
1108                "dynamic".to_string(),
1109            ],
1110            98,
1111            Some("application/x-pie-executable".to_string()),
1112        );
1113
1114        let json_result = JsonMatchResult::from_match_result(&match_result);
1115
1116        assert_eq!(
1117            json_result.text,
1118            "ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked"
1119        );
1120        assert_eq!(json_result.offset, 0);
1121        assert_eq!(json_result.value, "7f454c46");
1122        assert_eq!(
1123            json_result.tags,
1124            vec!["executable", "elf", "elf64", "x86_64", "pie", "dynamic"]
1125        );
1126        assert_eq!(json_result.score, 98);
1127    }
1128
1129    #[test]
1130    fn test_format_json_output_single_match() {
1131        let match_results = vec![MatchResult::with_metadata(
1132            "PNG image".to_string(),
1133            0,
1134            8,
1135            Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
1136            vec!["image".to_string(), "png".to_string()],
1137            90,
1138            Some("image/png".to_string()),
1139        )];
1140
1141        let json_output = format_json_output(&match_results).expect("Failed to format JSON");
1142
1143        // Verify JSON structure
1144        assert!(json_output.contains("\"matches\""));
1145        assert!(json_output.contains("\"text\": \"PNG image\""));
1146        assert!(json_output.contains("\"offset\": 0"));
1147        assert!(json_output.contains("\"value\": \"89504e470d0a1a0a\""));
1148        assert!(json_output.contains("\"tags\""));
1149        assert!(json_output.contains("\"image\""));
1150        assert!(json_output.contains("\"png\""));
1151        assert!(json_output.contains("\"score\": 90"));
1152
1153        // Verify it's valid JSON
1154        let parsed: JsonOutput =
1155            serde_json::from_str(&json_output).expect("Generated JSON should be valid");
1156        assert_eq!(parsed.matches.len(), 1);
1157        assert_eq!(parsed.matches[0].text, "PNG image");
1158        assert_eq!(parsed.matches[0].offset, 0);
1159        assert_eq!(parsed.matches[0].value, "89504e470d0a1a0a");
1160        assert_eq!(parsed.matches[0].tags, vec!["image", "png"]);
1161        assert_eq!(parsed.matches[0].score, 90);
1162    }
1163
1164    #[test]
1165    fn test_format_json_output_multiple_matches() {
1166        let match_results = vec![
1167            MatchResult::with_metadata(
1168                "ELF 64-bit LSB executable".to_string(),
1169                0,
1170                4,
1171                Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
1172                vec!["executable".to_string(), "elf".to_string()],
1173                95,
1174                Some("application/x-executable".to_string()),
1175            ),
1176            MatchResult::with_metadata(
1177                "x86-64 architecture".to_string(),
1178                18,
1179                2,
1180                Value::Uint(0x3e00),
1181                vec!["elf".to_string(), "x86_64".to_string()],
1182                85,
1183                None,
1184            ),
1185            MatchResult::with_metadata(
1186                "dynamically linked".to_string(),
1187                16,
1188                2,
1189                Value::Uint(0x0200),
1190                vec!["elf".to_string(), "dynamic".to_string()],
1191                80,
1192                None,
1193            ),
1194        ];
1195
1196        let json_output = format_json_output(&match_results).expect("Failed to format JSON");
1197
1198        // Verify JSON structure contains all matches
1199        assert!(json_output.contains("\"text\": \"ELF 64-bit LSB executable\""));
1200        assert!(json_output.contains("\"text\": \"x86-64 architecture\""));
1201        assert!(json_output.contains("\"text\": \"dynamically linked\""));
1202
1203        // Verify different offsets are preserved
1204        assert!(json_output.contains("\"offset\": 0"));
1205        assert!(json_output.contains("\"offset\": 18"));
1206        assert!(json_output.contains("\"offset\": 16"));
1207
1208        // Verify different values are formatted correctly
1209        assert!(json_output.contains("\"value\": \"7f454c46\""));
1210        assert!(json_output.contains("\"value\": \"003e000000000000\""));
1211        assert!(json_output.contains("\"value\": \"0002000000000000\""));
1212
1213        // Verify it's valid JSON with correct structure
1214        let parsed: JsonOutput =
1215            serde_json::from_str(&json_output).expect("Generated JSON should be valid");
1216        assert_eq!(parsed.matches.len(), 3);
1217
1218        // Verify first match
1219        assert_eq!(parsed.matches[0].text, "ELF 64-bit LSB executable");
1220        assert_eq!(parsed.matches[0].offset, 0);
1221        assert_eq!(parsed.matches[0].score, 95);
1222
1223        // Verify second match
1224        assert_eq!(parsed.matches[1].text, "x86-64 architecture");
1225        assert_eq!(parsed.matches[1].offset, 18);
1226        assert_eq!(parsed.matches[1].score, 85);
1227
1228        // Verify third match
1229        assert_eq!(parsed.matches[2].text, "dynamically linked");
1230        assert_eq!(parsed.matches[2].offset, 16);
1231        assert_eq!(parsed.matches[2].score, 80);
1232    }
1233
1234    #[test]
1235    fn test_format_json_output_empty_matches() {
1236        let match_results: Vec<MatchResult> = vec![];
1237
1238        let json_output = format_json_output(&match_results).expect("Failed to format JSON");
1239
1240        // Verify JSON structure for empty matches
1241        assert!(json_output.contains("\"matches\": []"));
1242
1243        // Verify it's valid JSON
1244        let parsed: JsonOutput =
1245            serde_json::from_str(&json_output).expect("Generated JSON should be valid");
1246        assert_eq!(parsed.matches.len(), 0);
1247        assert!(!parsed.has_matches());
1248    }
1249
1250    #[test]
1251    fn test_format_json_output_compact_single_match() {
1252        let match_results = vec![MatchResult::new(
1253            "JPEG image".to_string(),
1254            0,
1255            Value::Bytes(vec![0xff, 0xd8]),
1256        )];
1257
1258        let json_output =
1259            format_json_output_compact(&match_results).expect("Failed to format compact JSON");
1260
1261        // Verify it's compact (no newlines or extra spaces)
1262        assert!(!json_output.contains('\n'));
1263        assert!(!json_output.contains("  ")); // No double spaces
1264
1265        // Verify it contains expected content
1266        assert!(json_output.contains("\"matches\""));
1267        assert!(json_output.contains("\"text\":\"JPEG image\""));
1268        assert!(json_output.contains("\"offset\":0"));
1269        assert!(json_output.contains("\"value\":\"ffd8\""));
1270
1271        // Verify it's valid JSON
1272        let parsed: JsonOutput =
1273            serde_json::from_str(&json_output).expect("Generated JSON should be valid");
1274        assert_eq!(parsed.matches.len(), 1);
1275        assert_eq!(parsed.matches[0].text, "JPEG image");
1276    }
1277
1278    #[test]
1279    fn test_format_json_output_compact_multiple_matches() {
1280        let match_results = vec![
1281            MatchResult::new("Match 1".to_string(), 0, Value::String("test1".to_string())),
1282            MatchResult::new(
1283                "Match 2".to_string(),
1284                10,
1285                Value::String("test2".to_string()),
1286            ),
1287        ];
1288
1289        let json_output =
1290            format_json_output_compact(&match_results).expect("Failed to format compact JSON");
1291
1292        // Verify it's compact
1293        assert!(!json_output.contains('\n'));
1294
1295        // Verify it contains both matches
1296        assert!(json_output.contains("\"text\":\"Match 1\""));
1297        assert!(json_output.contains("\"text\":\"Match 2\""));
1298
1299        // Verify it's valid JSON
1300        let parsed: JsonOutput =
1301            serde_json::from_str(&json_output).expect("Generated JSON should be valid");
1302        assert_eq!(parsed.matches.len(), 2);
1303    }
1304
1305    #[test]
1306    fn test_format_json_output_compact_empty() {
1307        let match_results: Vec<MatchResult> = vec![];
1308
1309        let json_output =
1310            format_json_output_compact(&match_results).expect("Failed to format compact JSON");
1311
1312        // Verify it's compact and contains empty matches array
1313        assert!(!json_output.contains('\n'));
1314        assert!(json_output.contains("\"matches\":[]"));
1315
1316        // Verify it's valid JSON
1317        let parsed: JsonOutput =
1318            serde_json::from_str(&json_output).expect("Generated JSON should be valid");
1319        assert_eq!(parsed.matches.len(), 0);
1320    }
1321
1322    #[test]
1323    fn test_format_json_output_field_mapping() {
1324        // Test that all fields are properly mapped from MatchResult to JSON
1325        let match_result = MatchResult::with_metadata(
1326            "Test file with all fields".to_string(),
1327            42,
1328            8,
1329            Value::Bytes(vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]),
1330            vec![
1331                "category".to_string(),
1332                "subcategory".to_string(),
1333                "specific".to_string(),
1334            ],
1335            75,
1336            Some("application/test".to_string()),
1337        );
1338
1339        let json_output = format_json_output(&[match_result]).expect("Failed to format JSON");
1340
1341        // Verify all fields are present and correctly mapped
1342        assert!(json_output.contains("\"text\": \"Test file with all fields\""));
1343        assert!(json_output.contains("\"offset\": 42"));
1344        assert!(json_output.contains("\"value\": \"0102030405060708\""));
1345        assert!(json_output.contains("\"tags\""));
1346        assert!(json_output.contains("\"category\""));
1347        assert!(json_output.contains("\"subcategory\""));
1348        assert!(json_output.contains("\"specific\""));
1349        assert!(json_output.contains("\"score\": 75"));
1350
1351        // Verify the JSON structure matches the expected format
1352        let parsed: JsonOutput =
1353            serde_json::from_str(&json_output).expect("Generated JSON should be valid");
1354        assert_eq!(parsed.matches.len(), 1);
1355
1356        let json_match = &parsed.matches[0];
1357        assert_eq!(json_match.text, "Test file with all fields");
1358        assert_eq!(json_match.offset, 42);
1359        assert_eq!(json_match.value, "0102030405060708");
1360        assert_eq!(json_match.tags, vec!["category", "subcategory", "specific"]);
1361        assert_eq!(json_match.score, 75);
1362    }
1363
1364    #[test]
1365    fn test_format_json_output_different_value_types() {
1366        let match_results = vec![
1367            MatchResult::new(
1368                "Bytes value".to_string(),
1369                0,
1370                Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]),
1371            ),
1372            MatchResult::new(
1373                "String value".to_string(),
1374                10,
1375                Value::String("Hello, World!".to_string()),
1376            ),
1377            MatchResult::new("Uint value".to_string(), 20, Value::Uint(0x1234_5678)),
1378            MatchResult::new("Int value".to_string(), 30, Value::Int(-42)),
1379        ];
1380
1381        let json_output = format_json_output(&match_results).expect("Failed to format JSON");
1382
1383        // Verify different value types are formatted correctly as hex
1384        assert!(json_output.contains("\"value\": \"deadbeef\""));
1385        assert!(json_output.contains("\"value\": \"48656c6c6f2c20576f726c6421\""));
1386        assert!(json_output.contains("\"value\": \"7856341200000000\""));
1387        assert!(json_output.contains("\"value\": \"d6ffffffffffffff\""));
1388
1389        // Verify it's valid JSON
1390        let parsed: JsonOutput =
1391            serde_json::from_str(&json_output).expect("Generated JSON should be valid");
1392        assert_eq!(parsed.matches.len(), 4);
1393    }
1394
1395    #[test]
1396    fn test_format_json_output_validation() {
1397        // Test that the output format matches the original libmagic JSON specification
1398        let match_result = MatchResult::with_metadata(
1399            "PDF document".to_string(),
1400            0,
1401            4,
1402            Value::String("%PDF".to_string()),
1403            vec!["document".to_string(), "pdf".to_string()],
1404            88,
1405            Some("application/pdf".to_string()),
1406        );
1407
1408        let json_output = format_json_output(&[match_result]).expect("Failed to format JSON");
1409
1410        // Parse and verify the structure matches the expected format
1411        let parsed: serde_json::Value =
1412            serde_json::from_str(&json_output).expect("Generated JSON should be valid");
1413
1414        // Verify top-level structure
1415        assert!(parsed.is_object());
1416        assert!(parsed.get("matches").is_some());
1417        assert!(parsed.get("matches").unwrap().is_array());
1418
1419        // Verify match structure
1420        let matches = parsed.get("matches").unwrap().as_array().unwrap();
1421        assert_eq!(matches.len(), 1);
1422
1423        let match_obj = &matches[0];
1424        assert!(match_obj.get("text").is_some());
1425        assert!(match_obj.get("offset").is_some());
1426        assert!(match_obj.get("value").is_some());
1427        assert!(match_obj.get("tags").is_some());
1428        assert!(match_obj.get("score").is_some());
1429
1430        // Verify field types
1431        assert!(match_obj.get("text").unwrap().is_string());
1432        assert!(match_obj.get("offset").unwrap().is_number());
1433        assert!(match_obj.get("value").unwrap().is_string());
1434        assert!(match_obj.get("tags").unwrap().is_array());
1435        assert!(match_obj.get("score").unwrap().is_number());
1436
1437        // Verify field values
1438        assert_eq!(
1439            match_obj.get("text").unwrap().as_str().unwrap(),
1440            "PDF document"
1441        );
1442        assert_eq!(match_obj.get("offset").unwrap().as_u64().unwrap(), 0);
1443        assert_eq!(
1444            match_obj.get("value").unwrap().as_str().unwrap(),
1445            "25504446"
1446        );
1447        assert_eq!(match_obj.get("score").unwrap().as_u64().unwrap(), 88);
1448
1449        let tags = match_obj.get("tags").unwrap().as_array().unwrap();
1450        assert_eq!(tags.len(), 2);
1451        assert_eq!(tags[0].as_str().unwrap(), "document");
1452        assert_eq!(tags[1].as_str().unwrap(), "pdf");
1453    }
1454}