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