~savoy/mailcap

82bed888997ab535d8259cdb1d500937fefa2dfc — savoy 2 years ago 6983055 0.1.0
preparation for publishing and v0.1.0

The only code diff is the removal of the `note` Entry field, which is
not a field included in RFC 1524.

Signed-off-by: savoy <git@liberation.red>
3 files changed, 267 insertions(+), 12 deletions(-)

M Cargo.toml
A README.md
M src/lib.rs
M Cargo.toml => Cargo.toml +12 -1
@@ 2,9 2,20 @@
name = "mailcap"
version = "0.1.0"
edition = "2021"
authors = ["savoy <git@liberation.red>"]
description = "mailcap parsing library"
homepage = "https://sr.ht/~savoy/mailcap"
repository = "https://git.sr.ht/~savoy/mailcap"
readme = "README.md"
license = "GPL-3.0-or-later"
license-file = "LICENSE.txt"
keywords = ["mailcap", "parser", "mime"]
categories = ["config", "filesystem", "parser-implementations"]

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
serial_test = "0.6.0"
libc = "0.2.0"

[dev-dependencies]
serial_test = "0.6.0"

A README.md => README.md +89 -0
@@ 0,0 1,89 @@
![](https://img.shields.io/badge/rust-%231d1f21.svg?style=flat-square&logo=rust&logoColor=white)
![](https://img.shields.io/badge/linux-%231d1f21.svg?style=flat-square&logo=linux&logoColor=white)
![](https://img.shields.io/badge/license-GPLv3-blueviolet?style=flat-square&labelColor=373b41)
![](https://img.shields.io/badge/type-library-informational?style=flat-square&labelColor=373b41)
![](https://img.shields.io/badge/build-passing-success?style=flat-square&labelColor=373b41)
![](https://img.shields.io/badge/coverage-83%25-green?style=flat-square&labelColor=373b41)
![](https://img.shields.io/badge/crates.io-v0.1.0-orange?style=flat-square&labelColor=373b41)

# About

Mailcap files are a format documented in [RFC
1524](https://www.rfc-editor.org/rfc/rfc1524.html), "A User Agent Configuration
Mechanism For Multimedia Mail Format Information." They allow the handling of
MIME types by software aware of those types. For example, a mailcap line of
`text/html; qutebrowser '%s'; test=test -n "$DISPLAY"` would instruct the
software to open any HTML file with qutebrowser if you are running a graphical
session, with the file replacing the `'%s'`.

`mailcap` is a parsing library that looks at either a present `$MAILCAPS` env
variable or cycles through the four paths where a mailcap file would be found in
ascending order of importance: `/usr/local/etc/mailcap`, `/usr/etc/mailcap`,
`/etc/mailcap`, and `$HOME/.mailcap`. It builds the mailcap from all available
files, with duplicate entries being squashed with newer lines, allowing
`$HOME/.mailcap` to be the final decider.

The entries that make up the mailcap include only those that are relevant i.e.
those that have passed the `test` field (if present). With the above `text/html`
example, that test would fail if run through SSH, and unless another existing
`text/html` entry (or `text/*`) exists that doesn't require a display server, no
entry would exist for that mime type.

# Installation

Add the following to your `Cargo.toml`:

```toml
[dependencies]
mailcap = "0.1.0"
```

# Usage

```rust
use mailcap::Mailcap;

fn main() {
    let cap = Mailcap::new().unwrap();
    if let Some(i) = cap.get("text/html") {
        let command = i.viewer("/var/www/index.html");
        assert_eq!(command, "qutebrowser '/var/www/index.html'");
    }
}
```

Wildcard fallbacks are also supported.

```rust
use mailcap::Mailcap;

fn main() {
    let cap = Mailcap::new().unwrap();
    if let Some(i) = cap.get("video/avi") {
        // if no video/avi MIME entry available
        let mime_type = i.mime();
        assert_eq!(mime_type, "video/*");
    }
}
```

Code documentation is [available here](https://docs.rs/crate/mailcap/latest).

# Roadmap

To-be-included features can be seen [through the current open
issues](https://todo.sr.ht/~savoy/mailcap?search=status%3Aopen+label%3Afeature).

# Support

Refer to the [announcement mailing
list](https://lists.sr.ht/~savoy/mailcap-announce) for project updates and the
[devel mailing list](https://lists.sr.ht/~savoy/mailcap-devel) for contributions
and collaboration.

Issues should be directed to the project [issue
tracker](https://todo.sr.ht/~savoy/mailcap).

# License

This project is licensed under the GPLv3.

M src/lib.rs => src/lib.rs +166 -11
@@ 14,6 14,62 @@
// You should have received a copy of the GNU General Public License
// along with mailcap.  If not, see <http://www.gnu.org/licenses/>.

//! # mailcap
//!
//! `mailcap` is a parsing library for mailcap files.
//!
//! Mailcap files are a format documented in [RFC
//! 1524](https://www.rfc-editor.org/rfc/rfc1524.html), "A User Agent Configuration
//! Mechanism For Multimedia Mail Format Information." They allow the handling of
//! MIME types by software aware of those types. For example, a mailcap line of
//! `text/html; qutebrowser '%s'; test=test -n "$DISPLAY"` would instruct the
//! software to open any HTML file with qutebrowser if you are running a graphical
//! session, with the file replacing the `'%s'`.
//!
//! `mailcap` is a parsing library that looks at either a present `$MAILCAPS` env
//! variable or cycles through the four paths where a mailcap file would be found in
//! ascending order of importance: `/usr/local/etc/mailcap`, `/usr/etc/mailcap`,
//! `/etc/mailcap`, and `$HOME/.mailcap`. It builds the mailcap from all available
//! files, with duplicate entries being squashed with newer lines, allowing
//! `$HOME/.mailcap` to be the final decider.
//!
//! The entries that make up the mailcap include only those that are relevant i.e.
//! those that have passed the `test` field (if present). With the above `text/html`
//! example, that test would fail if run through SSH, and unless another existing
//! `text/html` entry (or `text/*`) exists that doesn't require a display server, no
//! entry would exist for that mime type.
//!
//! # Usage
//!
//! ```rust
//! use mailcap::{Mailcap, MailcapError};
//!
//! fn main() -> Result<(), MailcapError> {
//!     let cap = Mailcap::new()?;
//!     if let Some(i) = cap.get("text/html") {
//!         let command = i.viewer("/var/www/index.html");
//!         assert_eq!(command, "qutebrowser '/var/www/index.html'");
//!     }
//!     Ok(())
//! }
//! ```
//!
//! Wildcard fallbacks are also supported.
//!
//! ```rust
//! use mailcap::{Mailcap, MailcapError};
//!
//! fn main() -> Result<(), MailcapError> {
//!     let cap = Mailcap::new()?;
//!     if let Some(i) = cap.get("video/avi") {
//!         // if no video/avi MIME entry available
//!         let mime_type = i.mime();
//!         assert_eq!(mime_type, "video/*");
//!     }
//!     Ok(())
//! }
//! ```

use libc::system;
use std::collections::HashMap;
use std::ffi::CString;


@@ 22,19 78,27 @@ use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::{env, fmt};

/// The error type for `mailcap`.
#[derive(Debug, PartialEq)]
pub enum MailcapError {
    /// The mailcap line was unable to be parsed.
    LineParseError,
    /// The mailcap file was unable to be parsed.
    FileParseError,
    /// There are no valid mailcap files to parse.
    NoValidFiles,
}

/// Meta representation of all available mailcap files and their combined lines.
#[derive(Default, Debug, PartialEq, Clone)]
pub struct Mailcap {
    files: Vec<PathBuf>,
    data: HashMap<String, Entry>,
}

/// Parsed mailcap line. Each mailcap entry consists of a number of fields, separated by
/// semi-colons. The first two fields are required, and must occur in the specified order. The
/// remaining fields are optional, and may appear in any order.
#[derive(Default, Debug, PartialEq, Clone)]
pub struct Entry {
    mime_type: String,


@@ 44,12 108,11 @@ pub struct Entry {
    edit: Option<String>,
    print: Option<String>,
    test: Option<String>,
    note: Option<String>,
    description: Option<String>,
    name_template: Option<String>,
    needs_terminal: bool,
    copious_output: bool,
    textual_new_lines: bool, // either 1 or 0 in file
    textual_new_lines: bool,
}

impl fmt::Display for MailcapError {


@@ 63,6 126,30 @@ impl fmt::Display for MailcapError {
}

impl Mailcap {
    /// Returns a combined mailcap from all available default files or a $MAILCAPS env.
    /// The default list (in ascending order of importance) includes:
    ///
    /// - `/usr/local/etc/mailcap`
    /// - `/usr/etc/mailcap`
    /// - `/etc/mailcap`
    /// - `$HOME/.mailcap`
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use mailcap::{Mailcap, MailcapError};
    /// # fn main() -> Result<(), MailcapError> {
    /// let cap = Mailcap::new()?;
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// # Errors
    ///
    /// If there are no available mailcap files in the default locations or no $MAILCAPS env has
    /// been set, or if the files or empty or contain no valid mailcap lines, `MailcapError` will
    /// be returned. The implementation is loose: as long as one file exists with at least one
    /// valid mailcap line, the `Result` will be `Ok`.
    pub fn new() -> Result<Mailcap, MailcapError> {
        let mut files = Self::list_potential_files();
        Self::check_files_exist(&mut files)?;


@@ 87,6 174,22 @@ impl Mailcap {
        Ok(Mailcap { files, data })
    }

    /// Given a specific mime-type value, will lookup if there is an existing mailcap entry for
    /// that type.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use mailcap::{Mailcap, MailcapError};
    /// # fn main() -> Result<(), MailcapError> {
    /// let cap = Mailcap::new()?;
    /// if let Some(i) = cap.get("text/html") {
    ///     let command = i.viewer("/var/www/index.html");
    ///     assert_eq!(command, "qutebrowser '/var/www/index.html'");
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub fn get(&self, key: &str) -> Option<&Entry> {
        match self.data.get(key) {
            Some(v) => Some(v),


@@ 185,7 288,6 @@ impl Mailcap {
impl Entry {
    fn from(line: &Vec<String>) -> Option<Entry> {
        let mut entry = Entry::default();
        // TODO: validate mime_type against database
        entry.mime_type = line[0].to_owned();
        entry.viewer = line[1].to_owned();



@@ 196,7 298,6 @@ impl Entry {
                Some(("edit", v)) => entry.edit = Some(v[1..].to_string()),
                Some(("print", v)) => entry.print = Some(v[1..].to_string()),
                Some(("test", v)) => entry.test = Some(v[1..].to_string()),
                Some(("note", v)) => entry.note = Some(v[1..].to_string()),
                Some(("description", v)) => entry.description = Some(v[1..].to_string()),
                Some(("nametemplate", v)) => entry.name_template = Some(v[1..].to_string()),
                Some(("needsterminal", _)) => entry.needs_terminal = true,


@@ 215,54 316,111 @@ impl Entry {
        }
    }

    /// The `mime_type`, which indicates the type of data this mailcap entry describes how to
    /// handle. It is to be matched against the type/subtype specification in the "Content-Type"
    /// header field of an Internet mail message. If the subtype is specified as "*", it is
    /// intended to match all subtypes of the named `mime_type`.
    pub fn mime(&self) -> &String {
        &self.mime_type
    }

    /// The second field, `viewer`, is a specification of how the message or body part can be
    /// viewed at the local site. Although the syntax of this field is fully specified, the
    /// semantics of program execution are necessarily somewhat operating system dependent. UNIX
    /// semantics are given in Appendix A of RFC 1524.
    pub fn viewer(&self, filename: &str) -> String {
        self.viewer.replace("%s", filename)
    }

    /// The `compose` field may be used to specify a program that can be used to compose a new body
    /// or body part in the given format. Its intended use is to support mail composing agents that
    /// support the composition of multiple types of mail using external composing agents. As with
    /// `viewer`, the semantics of program execution are operating system dependent, with UNIX
    /// semantics specified in Appendix A of RFC 1524. The result of the composing program may be
    /// data that is not yet suitable for mail transport -- that is, a Content-Transfer-Encoding
    /// may need to be applied to the data.
    pub fn compose(&self) -> &Option<String> {
        &self.compose
    }

    /// The `compose_typed` field is similar to the `compose` field, but is to be used when the
    /// composing program needs to specify the Content-type header field to be applied to the
    /// composed data. The `compose` field is simpler, and is preferred for use with existing
    /// (non-mail-oriented) programs for composing data in a given format. The `compose_typed` field
    /// is necessary when the Content-type information must include auxilliary parameters, and the
    /// composition program must then know enough about mail formats to produce output that
    /// includes the mail type information.
    pub fn compose_typed(&self) -> &Option<String> {
        &self.compose_typed
    }

    /// The `edit` field may be used to specify a program that can be used to edit a body or body
    /// part in the given format. In many cases, it may be identical in content to the `compose`
    /// field, and shares the operating-system dependent semantics for program execution.
    pub fn edit(&self) -> &Option<String> {
        &self.edit
    }

    /// The `print` field may be used to specify a program that can be used to print a message or
    /// body part in the given format. As with `viewer`, the semantics of program execution are
    /// operating system dependent, with UNIX semantics specified in Appendix A of RFC 1524.
    pub fn print(&self) -> &Option<String> {
        &self.print
    }

    /// The `test` field may be used to test some external condition (e.g., the machine
    /// architecture, or the window system in use) to determine whether or not the mailcap line
    /// applies. It specifies a program to be run to test some condition. The semantics of
    /// execution and of the value returned by the test program are operating system dependent,
    /// with UNIX semantics specified in Appendix A of RFC 1524. If the test fails, a subsequent
    /// mailcap entry should be sought. Multiple test fields are not permitted -- since a test can
    /// call a program, it can already be arbitrarily complex.
    pub fn test(&self) -> &Option<String> {
        &self.test
    }

    pub fn note(&self) -> &Option<String> {
        &self.note
    }

    /// The `description` field simply provides a textual description, optionally quoted, that
    /// describes the type of data, to be used optionally by mail readers that wish to describe the
    /// data before offering to display it.
    pub fn description(&self) -> &Option<String> {
        &self.description
    }

    /// The `name_template` field gives a file name format, in which %s will be replaced by a short
    /// unique string to give the name of the temporary file to be passed to the viewing command.
    /// This is only expected to be relevant in environments where filename extensions are
    /// meaningful, e.g., one coulld specify that a GIF file being passed to a gif viewer should
    /// have a name eding in ".gif" by using "nametemplate=%s.gif".
    pub fn name_template(&self) -> &Option<String> {
        &self.name_template
    }

    /// The `needs_terminal` field indicates that the `viewer` must be run on an interactive
    /// terminal. This is needed to inform window-oriented user agents that an interactive
    /// terminal is needed. (The decision is not left exclusively to `viewer` because in
    /// some circumstances it may not be possible for such programs to tell whether or not they are
    /// on interactive terminals). The `needs_terminal` command should be assumed to apply to the
    /// compose and edit commands, too, if they exist. Note that this is NOT a test -- it is a
    /// requirement for the environment in which the program will be executed, and should typically
    /// cause the creation of a terminal window when not executed on either a real terminal or a
    /// terminal window.
    pub fn needs_terminal(&self) -> &bool {
        &self.needs_terminal
    }

    /// The `copious_output` field indicates that the output from `viewer` will be an
    /// extended stream of output, and is to be interpreted as advice to the UA (User Agent
    /// mail-reading program) that the output should be either paged or made scrollable. Note that
    /// it is probably a mistake if `needs_terminal` and `copious_output` are both specified.
    pub fn copious_output(&self) -> &bool {
        &self.copious_output
    }

    /// The `textual_new_lines` field, if set to any non-zero value, indicates that this type of data
    /// is line-oriented and that, if encoded in base64, all newlines should be converted to
    /// canonical form (CRLF) before encoding, and will be in that form after decoding. In general,
    /// this field is needed only if there is line-oriented data of some type other than text/* or
    /// non-line-oriented data that is a subtype of text.
    pub fn textual_new_lines(&self) -> &bool {
        &self.textual_new_lines
    }


@@ 310,7 468,6 @@ mod tests {
                edit: None,
                print: None,
                test: Some("test -n \"$DISPLAY\"".to_string()),
                note: None,
                description: None,
                name_template: Some("%s.html".to_string()),
                needs_terminal: true,


@@ 328,7 485,6 @@ mod tests {
                edit: None,
                print: None,
                test: Some("test -n \"$DISPLAY\"".to_string()),
                note: None,
                description: None,
                name_template: Some("%s.html".to_string()),
                needs_terminal: true,


@@ 428,7 584,6 @@ mod tests {
                edit: None,
                print: None,
                test: Some("test -n \"$DISPLAY\"".to_string()),
                note: None,
                description: None,
                name_template: Some("%s.html".to_string()),
                needs_terminal: true,