Skip to main content

qubit_mime/detector/
file_command_mime_detector.rs

1// =============================================================================
2//    Copyright (c) 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! MIME detector backed by the system `file` command.
9//!
10//! This detector uses the embedded repository for filename guesses and invokes
11//! `file --mime-type --brief <path>` for content guesses on local files or
12//! temporary files staged from seekable readers. It is registered under the
13//! built-in provider id `file` and aliases such as `file-command`.
14
15use qubit_command::{
16    Command,
17    CommandRunner,
18};
19use qubit_io::ReadSeek;
20use std::path::Path;
21
22use crate::{
23    FileBasedMimeDetector,
24    MimeConfig,
25    MimeDetectionPolicy,
26    MimeDetector,
27    MimeDetectorCore,
28    MimeRepository,
29    MimeResult,
30};
31
32use super::repository_mime_detector::default_repository;
33
34/// MIME detector backed by `file --mime-type --brief`.
35#[derive(Debug, Clone)]
36pub struct FileCommandMimeDetector<'a> {
37    /// The shared detector core.
38    core: MimeDetectorCore,
39    /// The repository used for filename detection.
40    repository: &'a MimeRepository,
41    /// The command runner used for command execution.
42    command_runner: CommandRunner,
43}
44
45impl FileCommandMimeDetector<'static> {
46    /// Creates a detector using the embedded repository for filename guesses.
47    ///
48    /// # Returns
49    /// File command detector.
50    pub fn new() -> Self {
51        Self::with_repository(default_repository())
52    }
53
54    /// Creates a detector using the embedded repository and explicit config.
55    ///
56    /// # Parameters
57    /// - `config`: MIME detector configuration.
58    ///
59    /// # Returns
60    /// File command detector.
61    pub fn from_mime_config(config: MimeConfig) -> Self {
62        Self::with_repository_runner_and_config(
63            default_repository(),
64            Self::default_command_runner(),
65            config,
66        )
67    }
68}
69
70impl<'a> FileCommandMimeDetector<'a> {
71    /// System command executable name.
72    pub const COMMAND: &'static str = "file";
73    /// Argument enabling MIME type output.
74    pub const MIME_TYPE_ARG: &'static str = "--mime-type";
75    /// Argument enabling concise output.
76    pub const BRIEF_ARG: &'static str = "--brief";
77
78    /// Creates a detector using an explicit repository for filename guesses.
79    ///
80    /// # Parameters
81    /// - `repository`: Repository used for filename detection.
82    ///
83    /// # Returns
84    /// File command detector borrowing `repository`.
85    pub fn with_repository(repository: &'a MimeRepository) -> Self {
86        Self::with_repository_and_runner(
87            repository,
88            Self::default_command_runner(),
89        )
90    }
91
92    /// Creates a detector using an explicit repository and command runner.
93    ///
94    /// # Parameters
95    /// - `repository`: Repository used for filename detection.
96    /// - `command_runner`: Runner used for all `file` command executions.
97    ///
98    /// # Returns
99    /// File command detector borrowing `repository` and owning the supplied
100    /// runner.
101    pub fn with_repository_and_runner(
102        repository: &'a MimeRepository,
103        command_runner: CommandRunner,
104    ) -> Self {
105        Self::with_repository_runner_and_config(
106            repository,
107            command_runner,
108            MimeConfig::default(),
109        )
110    }
111
112    /// Creates a detector using an explicit repository, runner, and config.
113    ///
114    /// # Parameters
115    /// - `repository`: Repository used for filename detection.
116    /// - `command_runner`: Runner used for all `file` command executions.
117    /// - `config`: MIME detector configuration.
118    ///
119    /// # Returns
120    /// File command detector borrowing `repository` and owning the supplied
121    /// runner.
122    pub fn with_repository_runner_and_config(
123        repository: &'a MimeRepository,
124        command_runner: CommandRunner,
125        config: MimeConfig,
126    ) -> Self {
127        Self {
128            core: MimeDetectorCore::from_mime_config(config),
129            repository,
130            command_runner,
131        }
132    }
133
134    /// Gets the shared detector core.
135    ///
136    /// # Returns
137    /// Shared detector core.
138    pub fn core(&self) -> &MimeDetectorCore {
139        &self.core
140    }
141
142    /// Gets mutable shared detector core.
143    ///
144    /// # Returns
145    /// Mutable shared detector core.
146    pub fn core_mut(&mut self) -> &mut MimeDetectorCore {
147        &mut self.core
148    }
149
150    /// Gets the repository used for filename detection.
151    ///
152    /// # Returns
153    /// Repository reference.
154    pub fn repository(&self) -> &'a MimeRepository {
155        self.repository
156    }
157
158    /// Gets the command runner used by this detector.
159    ///
160    /// # Returns
161    /// Runner used for `file` command executions.
162    pub fn command_runner(&self) -> &CommandRunner {
163        &self.command_runner
164    }
165
166    /// Replaces the command runner used by this detector.
167    ///
168    /// # Parameters
169    /// - `command_runner`: New runner configuration.
170    pub fn set_command_runner(&mut self, command_runner: CommandRunner) {
171        self.command_runner = command_runner;
172    }
173
174    /// Replaces the command runner and returns the updated detector.
175    ///
176    /// # Parameters
177    /// - `command_runner`: New runner configuration.
178    ///
179    /// # Returns
180    /// The updated detector.
181    pub fn with_command_runner(
182        mut self,
183        command_runner: CommandRunner,
184    ) -> Self {
185        self.command_runner = command_runner;
186        self
187    }
188
189    /// Detects content from a local file using the `file` command only.
190    ///
191    /// # Parameters
192    /// - `file`: Local file path to inspect.
193    ///
194    /// # Returns
195    /// MIME type name, or `None`.
196    ///
197    /// # Errors
198    /// Returns [`MimeError::Command`](crate::MimeError::Command) when the
199    /// command cannot be executed.
200    pub fn detect_file_by_content(
201        &self,
202        file: &Path,
203    ) -> MimeResult<Option<String>> {
204        Ok(self.guess_from_file_command(file)?.into_iter().next())
205    }
206
207    /// Detects a local file from filename and content.
208    ///
209    /// # Parameters
210    /// - `file`: Local file path.
211    /// - `policy`: Strategy for resolving filename and content results.
212    ///
213    /// # Returns
214    /// Selected MIME type name, or `None`.
215    ///
216    /// # Errors
217    /// Returns [`MimeError::Command`](crate::MimeError::Command) when command
218    /// execution fails.
219    pub fn detect_file(
220        &self,
221        file: &Path,
222        policy: MimeDetectionPolicy,
223    ) -> MimeResult<Option<String>> {
224        <Self as MimeDetector>::detect_file(self, file, policy)
225    }
226
227    /// Detects a seekable reader by staging its prefix to a temporary file.
228    ///
229    /// # Parameters
230    /// - `reader`: Reader to inspect. The original position is restored.
231    /// - `filename`: Optional filename.
232    /// - `policy`: Strategy for resolving filename and content results.
233    ///
234    /// # Returns
235    /// Selected MIME type name, or `None`.
236    ///
237    /// # Errors
238    /// Returns [`MimeError::Io`](crate::MimeError::Io) when stream operations
239    /// fail.
240    pub fn detect_reader(
241        &self,
242        reader: &mut dyn ReadSeek,
243        filename: Option<&str>,
244        policy: MimeDetectionPolicy,
245    ) -> MimeResult<Option<String>> {
246        <Self as MimeDetector>::detect_reader(self, reader, filename, policy)
247    }
248
249    /// Checks whether the `file` command is available.
250    ///
251    /// Availability is checked by executing `file --mime-type --brief .` with a
252    /// quiet command runner. This only validates that the command can be
253    /// started successfully in the current process environment; it does not
254    /// guarantee that every future file path can be inspected.
255    ///
256    /// # Returns
257    /// `true` when the command can be executed.
258    pub fn is_available() -> bool {
259        CommandRunner::new()
260            .disable_logging(true)
261            .run(Self::command_for_path(Path::new(".")))
262            .is_ok()
263    }
264
265    /// Gets filename candidates from the repository.
266    ///
267    /// # Parameters
268    /// - `filename`: Filename or path.
269    ///
270    /// # Returns
271    /// Candidate MIME type names.
272    fn guess_from_filename(&self, filename: &str) -> Vec<String> {
273        self.repository
274            .detect_by_filename(filename)
275            .into_iter()
276            .map(|mime_type| mime_type.name().to_owned())
277            .collect()
278    }
279
280    /// Gets content candidates from `file`.
281    ///
282    /// # Parameters
283    /// - `path`: Local path to inspect.
284    ///
285    /// # Returns
286    /// Zero or one MIME type names.
287    ///
288    /// # Errors
289    /// Returns [`MimeError::Command`](crate::MimeError::Command) when command
290    /// execution fails.
291    fn guess_from_file_command(&self, path: &Path) -> MimeResult<Vec<String>> {
292        let output = self.command_runner.run(Self::command_for_path(path))?;
293        let text = output.stdout_lossy_text();
294        let result = text.trim();
295        if result.is_empty() {
296            Ok(Vec::new())
297        } else {
298            Ok(vec![result.to_owned()])
299        }
300    }
301
302    /// Creates the default command runner for file detection.
303    ///
304    /// # Returns
305    /// Runner used by the default detector.
306    fn default_command_runner() -> CommandRunner {
307        CommandRunner::new()
308    }
309
310    /// Builds the structured `file` command for one path.
311    ///
312    /// # Parameters
313    /// - `path`: Local file path passed as an argument without shell parsing.
314    ///
315    /// # Returns
316    /// Structured command description.
317    fn command_for_path(path: &Path) -> Command {
318        Command::new(Self::COMMAND)
319            .arg(Self::MIME_TYPE_ARG)
320            .arg(Self::BRIEF_ARG)
321            .arg_os(path)
322    }
323}
324
325impl Default for FileCommandMimeDetector<'static> {
326    /// Creates a detector using the embedded repository.
327    fn default() -> Self {
328        Self::new()
329    }
330}
331
332impl<'a> FileBasedMimeDetector for FileCommandMimeDetector<'a> {
333    /// Gets the shared detector core.
334    fn core(&self) -> &MimeDetectorCore {
335        &self.core
336    }
337
338    /// Gets the maximum content prefix length from the repository.
339    fn max_test_bytes(&self) -> usize {
340        self.repository.max_test_bytes()
341    }
342
343    /// Guesses MIME type names from filename rules.
344    fn guess_from_filename(&self, filename: &str) -> Vec<String> {
345        FileCommandMimeDetector::guess_from_filename(self, filename)
346    }
347
348    /// Guesses MIME type names from a local file using the file command.
349    fn guess_from_local_file(&self, file: &Path) -> MimeResult<Vec<String>> {
350        self.guess_from_file_command(file)
351    }
352}