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