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}