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}