Skip to main content

libmagic_rs/output/
text.rs

1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Text output formatting for magic rule evaluation results
5//!
6//! This module provides functionality to format evaluation results in a human-readable
7//! text format compatible with the GNU `file` command output style.
8
9use crate::output::{EvaluationResult, MatchResult};
10
11/// Format a single match result as text
12///
13/// Converts a match result into a human-readable string format similar to
14/// the GNU `file` command output. The format includes the message from the
15/// matching rule.
16///
17/// # Arguments
18///
19/// * `result` - The match result to format
20///
21/// # Returns
22///
23/// A formatted string containing the match message
24///
25/// # Examples
26///
27/// ```
28/// use libmagic_rs::output::{MatchResult, text::format_text_result};
29/// use libmagic_rs::parser::ast::Value;
30///
31/// let result = MatchResult::new(
32///     "ELF 64-bit LSB executable".to_string(),
33///     0,
34///     Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46])
35/// );
36///
37/// let formatted = format_text_result(&result);
38/// assert_eq!(formatted, "ELF 64-bit LSB executable");
39/// ```
40#[must_use]
41pub fn format_text_result(result: &MatchResult) -> String {
42    result.message.clone()
43}
44
45/// Format multiple match results as concatenated text
46///
47/// Combines multiple match results into a single text string, with messages
48/// separated by commas and spaces. This follows the GNU `file` command convention
49/// of showing hierarchical matches in a single line.
50///
51/// # Arguments
52///
53/// * `results` - Vector of match results to format
54///
55/// # Returns
56///
57/// A formatted string with all match messages concatenated
58///
59/// # Examples
60///
61/// ```
62/// use libmagic_rs::output::{MatchResult, text::format_text_output};
63/// use libmagic_rs::parser::ast::Value;
64///
65/// let results = vec![
66///     MatchResult::new(
67///         "ELF 64-bit LSB executable".to_string(),
68///         0,
69///         Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46])
70///     ),
71///     MatchResult::new(
72///         "x86-64".to_string(),
73///         18,
74///         Value::Uint(0x3e)
75///     ),
76///     MatchResult::new(
77///         "dynamically linked".to_string(),
78///         16,
79///         Value::Uint(0x02)
80///     ),
81/// ];
82///
83/// let formatted = format_text_output(&results);
84/// assert_eq!(formatted, "ELF 64-bit LSB executable, x86-64, dynamically linked");
85/// ```
86#[must_use]
87pub fn format_text_output(results: &[MatchResult]) -> String {
88    if results.is_empty() {
89        return "data".to_string(); // Default fallback for unknown files
90    }
91
92    results
93        .iter()
94        .map(|result| result.message.as_str())
95        .collect::<Vec<&str>>()
96        .join(", ")
97}
98
99/// Format an evaluation result as text with filename
100///
101/// Formats a complete evaluation result in the style of the GNU `file` command,
102/// including the filename followed by a colon and the formatted match results.
103///
104/// # Arguments
105///
106/// * `evaluation` - The evaluation result to format
107///
108/// # Returns
109///
110/// A formatted string in the format "filename: description"
111///
112/// # Examples
113///
114/// ```
115/// use libmagic_rs::output::{EvaluationResult, MatchResult, EvaluationMetadata, text::format_evaluation_result};
116/// use libmagic_rs::parser::ast::Value;
117/// use std::path::PathBuf;
118///
119/// let result = MatchResult::new(
120///     "PNG image data".to_string(),
121///     0,
122///     Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47])
123/// );
124///
125/// let metadata = EvaluationMetadata::new(2048, 1.5, 10, 1);
126/// let evaluation = EvaluationResult::new(
127///     PathBuf::from("image.png"),
128///     vec![result],
129///     metadata
130/// );
131///
132/// let formatted = format_evaluation_result(&evaluation);
133/// assert_eq!(formatted, "image.png: PNG image data");
134/// ```
135#[must_use]
136pub fn format_evaluation_result(evaluation: &EvaluationResult) -> String {
137    let filename = evaluation
138        .filename
139        .file_name()
140        .and_then(|name| name.to_str())
141        .unwrap_or("unknown");
142
143    let description = if evaluation.matches.is_empty() {
144        if let Some(ref error) = evaluation.error {
145            format!("ERROR: {error}")
146        } else {
147            "data".to_string()
148        }
149    } else {
150        format_text_output(&evaluation.matches)
151    };
152
153    format!("{filename}: {description}")
154}
155
156#[cfg(test)]
157mod tests {
158    use cfg_if::cfg_if;
159
160    use super::*;
161    use crate::output::EvaluationMetadata;
162    use crate::parser::ast::Value;
163    use std::path::PathBuf;
164
165    #[test]
166    fn test_format_text_result() {
167        let result = MatchResult::new(
168            "ELF 64-bit LSB executable".to_string(),
169            0,
170            Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
171        );
172
173        let formatted = format_text_result(&result);
174        assert_eq!(formatted, "ELF 64-bit LSB executable");
175    }
176
177    #[test]
178    fn test_format_text_result_with_special_characters() {
179        let result = MatchResult::new(
180            "Text file with UTF-8 Unicode (with BOM) text".to_string(),
181            0,
182            Value::Bytes(vec![0xef, 0xbb, 0xbf]),
183        );
184
185        let formatted = format_text_result(&result);
186        assert_eq!(formatted, "Text file with UTF-8 Unicode (with BOM) text");
187    }
188
189    #[test]
190    fn test_format_text_output_single_result() {
191        let results = vec![MatchResult::new(
192            "PNG image data".to_string(),
193            0,
194            Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47]),
195        )];
196
197        let formatted = format_text_output(&results);
198        assert_eq!(formatted, "PNG image data");
199    }
200
201    #[test]
202    fn test_format_text_output_multiple_results() {
203        let results = vec![
204            MatchResult::new(
205                "ELF 64-bit LSB executable".to_string(),
206                0,
207                Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
208            ),
209            MatchResult::new("x86-64".to_string(), 18, Value::Uint(0x3e)),
210            MatchResult::new("version 1 (SYSV)".to_string(), 7, Value::Uint(0x01)),
211            MatchResult::new("dynamically linked".to_string(), 16, Value::Uint(0x02)),
212        ];
213
214        let formatted = format_text_output(&results);
215        assert_eq!(
216            formatted,
217            "ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked"
218        );
219    }
220
221    #[test]
222    fn test_format_text_output_empty_results() {
223        let results = vec![];
224        let formatted = format_text_output(&results);
225        assert_eq!(formatted, "data");
226    }
227
228    #[test]
229    fn test_format_text_output_with_confidence_variations() {
230        // Test that confidence doesn't affect text output (it's not shown in text format)
231        let results = vec![
232            MatchResult::with_metadata(
233                "JPEG image data".to_string(),
234                0,
235                2,
236                Value::Bytes(vec![0xff, 0xd8]),
237                vec!["image".to_string(), "jpeg".to_string()],
238                95,
239                Some("image/jpeg".to_string()),
240            ),
241            MatchResult::with_metadata(
242                "JFIF standard 1.01".to_string(),
243                6,
244                5,
245                Value::String("JFIF\0".to_string()),
246                vec!["image".to_string(), "jpeg".to_string(), "jfif".to_string()],
247                85,
248                None,
249            ),
250        ];
251
252        let formatted = format_text_output(&results);
253        assert_eq!(formatted, "JPEG image data, JFIF standard 1.01");
254    }
255
256    #[test]
257    fn test_format_evaluation_result_with_matches() {
258        let result = MatchResult::new(
259            "PNG image data".to_string(),
260            0,
261            Value::Bytes(vec![0x89, 0x50, 0x4e, 0x47]),
262        );
263
264        let metadata = EvaluationMetadata::new(2048, 1.5, 10, 1);
265        let evaluation = EvaluationResult::new(
266            PathBuf::from("/home/user/images/photo.png"),
267            vec![result],
268            metadata,
269        );
270
271        let formatted = format_evaluation_result(&evaluation);
272        assert_eq!(formatted, "photo.png: PNG image data");
273    }
274
275    #[test]
276    fn test_format_evaluation_result_with_multiple_matches() {
277        let results = vec![
278            MatchResult::new(
279                "ELF 64-bit LSB executable".to_string(),
280                0,
281                Value::Bytes(vec![0x7f, 0x45, 0x4c, 0x46]),
282            ),
283            MatchResult::new("x86-64".to_string(), 18, Value::Uint(0x3e)),
284            MatchResult::new("dynamically linked".to_string(), 16, Value::Uint(0x02)),
285        ];
286
287        let metadata = EvaluationMetadata::new(8192, 3.2, 25, 3);
288        let evaluation = EvaluationResult::new(PathBuf::from("/usr/bin/ls"), results, metadata);
289
290        let formatted = format_evaluation_result(&evaluation);
291        assert_eq!(
292            formatted,
293            "ls: ELF 64-bit LSB executable, x86-64, dynamically linked"
294        );
295    }
296
297    #[test]
298    fn test_format_evaluation_result_no_matches() {
299        let metadata = EvaluationMetadata::new(1024, 0.8, 5, 0);
300        let evaluation = EvaluationResult::new(PathBuf::from("unknown.bin"), vec![], metadata);
301
302        let formatted = format_evaluation_result(&evaluation);
303        assert_eq!(formatted, "unknown.bin: data");
304    }
305
306    #[test]
307    fn test_format_evaluation_result_with_error() {
308        let metadata = EvaluationMetadata::new(0, 0.0, 0, 0);
309        let evaluation = EvaluationResult::with_error(
310            PathBuf::from("missing.txt"),
311            "File not found".to_string(),
312            metadata,
313        );
314
315        let formatted = format_evaluation_result(&evaluation);
316        assert_eq!(formatted, "missing.txt: ERROR: File not found");
317    }
318
319    #[test]
320    fn test_format_evaluation_result_filename_extraction() {
321        // Test various path formats
322        let metadata = EvaluationMetadata::new(100, 0.5, 1, 0);
323
324        // Unix absolute path
325        let eval1 = EvaluationResult::new(
326            PathBuf::from("/home/user/document.pdf"),
327            vec![],
328            metadata.clone(),
329        );
330        let formatted1 = format_evaluation_result(&eval1);
331        assert_eq!(formatted1, "document.pdf: data");
332
333        // Windows path - behavior differs by platform
334        let eval2 = EvaluationResult::new(
335            PathBuf::from(r"C:\Users\user\file.exe"),
336            vec![],
337            metadata.clone(),
338        );
339        let formatted2 = format_evaluation_result(&eval2);
340        // On Windows, this extracts just the filename; on Unix, it's treated as a single component
341        cfg_if! {
342            if #[cfg(windows)] {
343                assert_eq!(formatted2, "file.exe: data");
344            } else {
345                assert_eq!(formatted2, r"C:\Users\user\file.exe: data");
346            }
347        }
348        // Relative path
349        let eval3 =
350            EvaluationResult::new(PathBuf::from("./test/sample.dat"), vec![], metadata.clone());
351        let formatted3 = format_evaluation_result(&eval3);
352        assert_eq!(formatted3, "sample.dat: data");
353
354        // Just filename
355        let eval4 = EvaluationResult::new(PathBuf::from("simple.txt"), vec![], metadata);
356        let formatted4 = format_evaluation_result(&eval4);
357        assert_eq!(formatted4, "simple.txt: data");
358    }
359
360    #[test]
361    fn test_format_evaluation_result_edge_cases() {
362        let metadata = EvaluationMetadata::new(0, 0.0, 0, 0);
363
364        // Empty filename (should use "unknown")
365        let eval1 = EvaluationResult::new(PathBuf::from(""), vec![], metadata.clone());
366        let formatted1 = format_evaluation_result(&eval1);
367        assert_eq!(formatted1, "unknown: data");
368
369        // Path with no filename component
370        let eval2 = EvaluationResult::new(PathBuf::from("/"), vec![], metadata);
371        let formatted2 = format_evaluation_result(&eval2);
372        assert_eq!(formatted2, "unknown: data");
373    }
374
375    #[test]
376    fn test_format_text_output_preserves_message_order() {
377        // Ensure that the order of messages is preserved in output
378        let results = vec![
379            MatchResult::new("First".to_string(), 0, Value::Uint(1)),
380            MatchResult::new("Second".to_string(), 4, Value::Uint(2)),
381            MatchResult::new("Third".to_string(), 8, Value::Uint(3)),
382        ];
383
384        let formatted = format_text_output(&results);
385        assert_eq!(formatted, "First, Second, Third");
386    }
387
388    #[test]
389    fn test_format_text_result_handles_empty_message() {
390        let result = MatchResult::new(String::new(), 0, Value::Uint(0));
391        let formatted = format_text_result(&result);
392        assert_eq!(formatted, "");
393    }
394
395    #[test]
396    fn test_format_text_output_with_empty_messages() {
397        let results = vec![
398            MatchResult::new("Valid message".to_string(), 0, Value::Uint(1)),
399            MatchResult::new(String::new(), 4, Value::Uint(2)),
400            MatchResult::new("Another message".to_string(), 8, Value::Uint(3)),
401        ];
402
403        let formatted = format_text_output(&results);
404        assert_eq!(formatted, "Valid message, , Another message");
405    }
406
407    #[test]
408    fn test_format_text_output_realistic_file_types() {
409        // Test with realistic file type detection results
410
411        // PDF file
412        let pdf_results = vec![
413            MatchResult::new(
414                "PDF document".to_string(),
415                0,
416                Value::String("%PDF".to_string()),
417            ),
418            MatchResult::new(
419                "version 1.4".to_string(),
420                5,
421                Value::String("1.4".to_string()),
422            ),
423        ];
424        assert_eq!(
425            format_text_output(&pdf_results),
426            "PDF document, version 1.4"
427        );
428
429        // ZIP archive
430        let zip_results = vec![
431            MatchResult::new(
432                "Zip archive data".to_string(),
433                0,
434                Value::String("PK".to_string()),
435            ),
436            MatchResult::new("at least v2.0 to extract".to_string(), 4, Value::Uint(20)),
437        ];
438        assert_eq!(
439            format_text_output(&zip_results),
440            "Zip archive data, at least v2.0 to extract"
441        );
442
443        // JPEG image
444        let jpeg_results = vec![
445            MatchResult::new(
446                "JPEG image data".to_string(),
447                0,
448                Value::Bytes(vec![0xff, 0xd8]),
449            ),
450            MatchResult::new(
451                "JFIF standard 1.01".to_string(),
452                6,
453                Value::String("JFIF".to_string()),
454            ),
455            MatchResult::new("resolution (DPI)".to_string(), 13, Value::Uint(1)),
456            MatchResult::new("density 72x72".to_string(), 14, Value::Uint(72)),
457        ];
458        assert_eq!(
459            format_text_output(&jpeg_results),
460            "JPEG image data, JFIF standard 1.01, resolution (DPI), density 72x72"
461        );
462    }
463}