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