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