Current section
Files
Jump to
Current section
Files
lib/magic_mime.ex
defmodule MagicMime do
@moduledoc """
Fast and secure MIME type detection using the system's `file` command.
This library provides a safe interface to detect MIME types for files
without requiring NIFs or external dependencies (other than the system's
`file` command).
## Examples
iex> {:ok, mime_type} = MagicMime.detect("mix.exs")
iex> String.starts_with?(mime_type, "text/")
true
iex> mime_type = MagicMime.detect!("mix.exs")
iex> String.starts_with?(mime_type, "text/")
true
iex> results = MagicMime.detect_many(["mix.exs", "README.md"])
iex> Enum.all?(results, fn {_path, {:ok, mime_type}} -> String.starts_with?(mime_type, "text/") end)
true
## Requirements
This library requires the `file` command to be available on the system.
It's pre-installed on most Unix-like systems (Linux, macOS, etc.).
**Note**: Windows is not supported.
"""
alias MagicMime.{CLI, Error}
@type path :: Path.t()
@type mime_type :: String.t()
@type error_reason :: atom() | {atom(), term()} | {atom(), term(), term()}
@doc """
Detects the MIME type of a file.
Returns `{:ok, mime_type}` on success, or `{:error, reason}` on failure.
## Options
- `:command_executor` - Custom command executor for dependency injection
- `:file_system` - Custom file system for dependency injection
## Examples
iex> {:ok, mime_type} = MagicMime.detect("mix.exs")
iex> String.starts_with?(mime_type, "text/")
true
iex> MagicMime.detect("nonexistent.file")
{:error, :enoent}
## Error Types
- `:file_command_not_found` - The `file` command is not available
- `:enoent` - File does not exist
- `:eacces` - Permission denied
- `{:command_error, exit_code, stderr}` - Command execution failed
"""
@spec detect(path(), keyword()) :: {:ok, mime_type()} | {:error, error_reason()}
def detect(path, opts \\ []) do
with :ok <- check_file_command_available(opts),
expanded_path <- Path.expand(path),
{:ok, mime_type} <- CLI.detect_mime_type(expanded_path, opts) do
{:ok, mime_type}
else
{:error, {:command_error, exit_code, error_output}} ->
# Parse file command errors to provide better error categorization
cond do
String.contains?(error_output, "No such file") or
(String.contains?(error_output, "cannot open") and
String.contains?(error_output, "No such file or directory")) ->
{:error, :enoent}
String.contains?(error_output, "Permission denied") ->
{:error, :eacces}
true ->
{:error, {:command_error, exit_code, error_output}}
end
{:error, reason} ->
{:error, reason}
end
end
@doc """
Detects the MIME type of a file, raising an exception on failure.
Returns the MIME type as a string on success, or raises `MagicMime.Error`
on failure.
## Options
- `:command_executor` - Custom command executor for dependency injection
- `:file_system` - Custom file system for dependency injection
## Examples
iex> mime_type = MagicMime.detect!("mix.exs")
iex> String.starts_with?(mime_type, "text/")
true
iex> MagicMime.detect!("nonexistent.file")
** (MagicMime.Error) File does not exist: nonexistent.file
"""
@spec detect!(path(), keyword()) :: mime_type() | no_return()
def detect!(path, opts \\ []) do
case detect(path, opts) do
{:ok, mime_type} ->
mime_type
{:error, :file_command_not_found} ->
raise Error, {:file_command_not_found, "The file command is not available on this system"}
{:error, :enoent} ->
raise Error, {:enoent, "File does not exist: #{path}"}
{:error, :eacces} ->
raise Error, {:eacces, "Permission denied: #{path}"}
{:error, {:command_error, exit_code, stderr}} ->
raise Error,
{:command_error, "Command failed with exit code #{exit_code}",
%{exit_code: exit_code, stderr: stderr}}
{:error, %struct{} = exception} when struct in [RuntimeError, ArgumentError, ErlangError] ->
raise exception
{:error, reason} ->
raise Error, {:unknown, "Unknown error: #{inspect(reason)}"}
end
end
@doc """
Detects MIME types for multiple files in parallel.
Returns a list of tuples containing the file path and the result
(`{:ok, mime_type}` or `{:error, reason}`).
## Options
- `:concurrency` - Number of concurrent tasks (default: `System.schedulers_online()`)
- `:command_executor` - Custom command executor for dependency injection
- `:file_system` - Custom file system for dependency injection
## Examples
iex> results = MagicMime.detect_many(["mix.exs", "README.md"])
iex> Enum.all?(results, fn {_path, {:ok, mime_type}} -> String.starts_with?(mime_type, "text/") end)
true
iex> results = MagicMime.detect_many(["mix.exs", "README.md"], concurrency: 2)
iex> Enum.all?(results, fn {_path, {:ok, mime_type}} -> String.starts_with?(mime_type, "text/") end)
true
"""
@spec detect_many([path()], keyword()) :: [
{path(), {:ok, mime_type()} | {:error, error_reason()}}
]
def detect_many(paths, opts \\ []) do
concurrency = Keyword.get(opts, :concurrency, System.schedulers_online())
paths
|> Task.async_stream(
fn path ->
{path, detect(path, opts)}
end,
max_concurrency: concurrency
)
|> Enum.map(fn {:ok, result} -> result end)
end
@doc """
Checks if MIME type detection is supported on this system.
Returns `true` if the `file` command is available, `false` otherwise.
## Options
- `:command_executor` - Custom command executor for dependency injection
## Examples
iex> MagicMime.mime_supported?()
true
"""
@spec mime_supported?(keyword()) :: boolean()
def mime_supported?(opts \\ []) do
CLI.file_command_available?(opts)
end
@doc """
Returns the version of the `file` command.
Returns `{:ok, version}` on success, or `{:error, reason}` on failure.
## Options
- `:command_executor` - Custom command executor for dependency injection
## Examples
iex> {:ok, version} = MagicMime.version()
iex> is_binary(version)
true
iex> {:ok, version} = MagicMime.version()
iex> is_binary(version)
true
"""
@spec version(keyword()) :: {:ok, String.t()} | {:error, error_reason()}
def version(opts \\ []) do
CLI.get_version(opts)
end
# Private helper functions
defp check_file_command_available(opts) do
if CLI.file_command_available?(opts) do
:ok
else
{:error, :file_command_not_found}
end
end
end