hyprforge-mime 0.1.0

Reads the freedesktop shared MIME database: what type a file is by name or by its contents, which applications handle a type, which one is the default, and which icon a type has.
Documentation
//! Which applications can open a type, and what they are called.
//!
//! Two data files, both plain text and both generated by the desktop:
//! `mimeinfo.cache` next to the desktop entries says which entries
//! registered for a type, and each entry says what it is called and what
//! it runs.
//!
//! # What this refuses to do
//!
//! It does not *launch* anything, and it does not interpret `Exec=`
//! beyond its first word. Field codes (`%f`, `%U`, `%c`), `TryExec`,
//! `Terminal=true`, D-Bus activation — that is the whole desktop entry
//! specification, and reimplementing it is how this suite would end up
//! as a third opinion about which application opens a file, disagreeing
//! with the rest of the desktop in ways nobody can see. Launching stays
//! `gio launch`, with `xdg-open` behind it; see
//! `hyprforge_files::launch`.
//!
//! The first word is read for one reason only: to answer "is this
//! actually installed". An entry naming a program that is not there is
//! the failure that started all this — `mimeapps.list` here pointed
//! `.3mf` at fstl, which had been uninstalled, and a chooser that
//! offered it would be offering a dead end.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

/// One application, as the desktop describes it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct App {
    /// The desktop entry's file name, e.g. `view3d.desktop`. This is the
    /// identity `mimeapps.list` uses, so it is what a default is set to.
    pub id: String,
    /// `Name=`, for showing a person.
    pub name: String,
    /// `Icon=`, when it has one.
    pub icon: Option<String>,
    /// Where the entry was found — what `gio launch` is handed.
    pub path: PathBuf,
    /// Whether the program `Exec=` names can actually be run. See this
    /// module's doc for why this is the one thing `Exec=` is read for.
    pub installed: bool,
}

/// Reads one desktop entry. `None` when it has no `Name=`, or when it
/// asks not to be shown (`NoDisplay=true`, `Hidden=true`) — those are
/// entries the desktop itself keeps out of menus, and a chooser is a
/// menu.
pub fn parse_entry(path: &Path, text: &str, is_installed: &dyn Fn(&str) -> bool) -> Option<App> {
    let mut name = None;
    let mut icon = None;
    let mut exec = None;
    let mut hidden = false;
    // Only the `[Desktop Entry]` group. The `[Desktop Action ...]`
    // groups below it have their own Name and Exec, and reading those
    // would rename the application after one of its right-click actions.
    let mut in_entry = false;
    for line in text.lines() {
        let line = line.trim();
        if line.starts_with('[') {
            in_entry = line == "[Desktop Entry]";
            continue;
        }
        if !in_entry {
            continue;
        }
        match line.split_once('=') {
            // `Name[de]=` is a translation; the plain key is the one
            // this asks for, since nothing here knows the user's locale.
            Some(("Name", value)) => name = Some(value.trim().to_string()),
            Some(("Icon", value)) => icon = Some(value.trim().to_string()),
            Some(("Exec", value)) => exec = Some(value.trim().to_string()),
            Some(("NoDisplay" | "Hidden", value)) => hidden |= value.trim() == "true",
            _ => {}
        }
    }
    if hidden {
        return None;
    }
    let program = exec.as_deref().and_then(first_word).unwrap_or_default();
    Some(App {
        id: path.file_name()?.to_string_lossy().into_owned(),
        name: name?,
        icon,
        path: path.to_path_buf(),
        installed: !program.is_empty() && is_installed(program),
    })
}

/// The program an `Exec=` line runs, before its arguments. Quoted
/// because a path with a space in it is allowed to be.
fn first_word(exec: &str) -> Option<&str> {
    let exec = exec.trim();
    match exec.strip_prefix('"') {
        Some(rest) => rest.split('"').next(),
        None => exec.split_whitespace().next(),
    }
    .filter(|word| !word.is_empty())
}

/// `mimeinfo.cache`: which entries registered for each type.
///
/// This is registration, not choice — every application that says it can
/// open PNGs is in here, which is why a chooser shows several and why
/// the *default* is a separate question (see [`crate::defaults`]).
///
/// The file has the same shape as `mimeapps.list` — a `[section]` of
/// `type=one.desktop;two.desktop;` lines — so it is read by the same
/// scanner. They were two near-copies that disagreed about whether a
/// repeated key replaces or accumulates, which is the disagreement
/// CLAUDE.md's rule about a last-one-wins format is about.
pub fn parse_cache(text: &str) -> BTreeMap<String, Vec<String>> {
    crate::defaults::parse_section(text, "[MIME Cache]")
}

/// Whether a program can be run, by walking `PATH`. The real
/// `is_installed` behind [`parse_entry`].
pub fn on_path(program: &str) -> bool {
    // An absolute Exec (`/usr/bin/google-chrome-stable`) names itself.
    if program.contains('/') {
        return Path::new(program).is_file();
    }
    let Some(path) = std::env::var_os("PATH") else { return false };
    std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
}

/// Writes a desktop entry for a command a person typed, and hands back
/// the entry's file name.
///
/// This is the "Other…" answer in a chooser: none of the applications
/// offered, run *this* instead. The entry goes in the user's own
/// applications directory, marked `NoDisplay=true` so it stays out of
/// menus — it is a record of one person's one-off choice, not an
/// application anybody installed.
///
/// The name follows the convention every other implementation uses
/// (`word-usercreated-1.desktop`), counting up rather than overwriting,
/// so two different commands beginning with the same word do not
/// quietly become one.
///
/// Returns the entry's file name and the name written inside it. Both,
/// because a caller needs to show one and record the other, and working
/// the name out a second time is how they came to disagree: the entry
/// on disk said `zeditor` while the caller's copy said
/// `/usr/bin/zeditor`.
///
/// `command` is written into `Exec=` as given. Nothing here parses it —
/// see this module's doc — which does mean a command that is nonsense
/// produces an entry that fails to launch. That is visible immediately
/// and recoverable by choosing again; guessing at what somebody meant
/// would not be.
pub fn write_custom_entry(applications: &Path, command: &str) -> std::io::Result<(String, String)> {
    let word = command
        .split_whitespace()
        .next()
        .and_then(|first| first.rsplit('/').next())
        .filter(|word| !word.is_empty())
        .ok_or_else(|| {
            std::io::Error::new(std::io::ErrorKind::InvalidInput, "that command names no program")
        })?;
    let safe: String = word.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect();
    let stem = if safe.is_empty() { "command".to_string() } else { safe };

    std::fs::create_dir_all(applications)?;
    for attempt in 1..1000 {
        let id = format!("{stem}-usercreated-{attempt}.desktop");
        let path = applications.join(&id);
        if path.exists() {
            continue;
        }
        let entry = format!(
            "[Desktop Entry]\nType=Application\nName={word}\nNoDisplay=true\nExec={command} %f\n"
        );
        hyprforge_paths::write_atomic(&path, &entry)?;
        return Ok((id, word.to_string()));
    }
    Err(std::io::Error::other("too many entries already exist for that command"))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn everything_installed(_: &str) -> bool {
        true
    }

    fn entry(text: &str) -> Option<App> {
        parse_entry(Path::new("/a/view3d.desktop"), text, &everything_installed)
    }

    #[test]
    fn an_entry_reads_as_its_name_icon_and_identity() {
        let app = entry("[Desktop Entry]\nName=3D Viewer\nIcon=view3d\nExec=view3d %f\n").unwrap();
        assert_eq!(app.id, "view3d.desktop", "what mimeapps.list calls it");
        assert_eq!(app.name, "3D Viewer");
        assert_eq!(app.icon.as_deref(), Some("view3d"));
        assert!(app.installed);
    }

    /// The fstl case: the entry is fine, the program is gone. A chooser
    /// that offered it would be offering a dead end.
    #[test]
    fn an_entry_whose_program_is_missing_is_marked_not_installed() {
        let app = parse_entry(
            Path::new("/a/fstl.desktop"),
            "[Desktop Entry]\nName=fstl\nExec=fstl %f\n",
            &|program| program != "fstl",
        )
        .unwrap();
        assert!(!app.installed);
        assert_eq!(app.name, "fstl", "still named, so it can be said what is missing");
    }

    /// A desktop entry's action groups have their own Name and Exec.
    /// Reading those would rename the application after one of its
    /// right-click actions.
    #[test]
    fn only_the_desktop_entry_group_is_read() {
        let app = entry(
            "[Desktop Entry]\nName=Chrome\nExec=chrome %U\n\n\
             [Desktop Action new-window]\nName=New Window\nExec=chrome --new-window\n",
        )
        .unwrap();
        assert_eq!(app.name, "Chrome");
    }

    #[test]
    fn an_entry_the_desktop_hides_is_not_offered() {
        assert!(entry("[Desktop Entry]\nName=Hidden Helper\nExec=x\nNoDisplay=true\n").is_none());
        assert!(entry("[Desktop Entry]\nName=Old\nExec=x\nHidden=true\n").is_none());
        assert!(entry("[Desktop Entry]\nExec=x\n").is_none(), "no name, nothing to show");
    }

    #[test]
    fn an_exec_path_with_a_space_keeps_its_whole_path() {
        let app = entry("[Desktop Entry]\nName=X\nExec=\"/opt/my app/run\" %f\n").unwrap();
        assert!(app.installed);
        assert_eq!(first_word("\"/opt/my app/run\" %f"), Some("/opt/my app/run"));
        assert_eq!(first_word("/usr/bin/chrome %U"), Some("/usr/bin/chrome"));
    }

    #[test]
    fn the_cache_lists_every_application_that_registered_for_a_type() {
        let cache = parse_cache(
            "[MIME Cache]\n\
             model/stl=BambuStudio.desktop;plasticity.desktop;view3d.desktop;\n\
             image/png=firefox.desktop;\n",
        );
        assert_eq!(
            cache.get("model/stl").unwrap(),
            &["BambuStudio.desktop", "plasticity.desktop", "view3d.desktop"]
        );
        assert_eq!(cache.get("text/plain"), None);
    }

    #[test]
    fn a_typed_command_becomes_an_entry_that_stays_out_of_menus() {
        let dir = tempfile::tempdir().unwrap();
        let (id, name) = write_custom_entry(dir.path(), "kate --new").unwrap();
        assert_eq!(id, "kate-usercreated-1.desktop");
        assert_eq!(name, "kate", "the same name the entry itself carries");
        let text = std::fs::read_to_string(dir.path().join(&id)).unwrap();
        assert!(text.contains("Exec=kate --new %f"), "{text}");
        assert!(text.contains("NoDisplay=true"), "a one-off choice is not a menu entry");

        // And it reads back as an application, which is the whole point.
        let app = parse_entry(&dir.path().join(&id), &text, &everything_installed);
        assert!(app.is_none(), "NoDisplay keeps it out of a chooser's own list");
    }

    /// Two different commands starting with the same word must not
    /// become one entry.
    #[test]
    fn a_second_command_with_the_same_first_word_gets_its_own_entry() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(write_custom_entry(dir.path(), "kate a").unwrap().0, "kate-usercreated-1.desktop");
        assert_eq!(write_custom_entry(dir.path(), "kate b").unwrap().0, "kate-usercreated-2.desktop");
    }

    #[test]
    fn a_command_with_a_path_is_named_by_its_program() {
        let dir = tempfile::tempdir().unwrap();
        let (id, name) = write_custom_entry(dir.path(), "/usr/bin/zeditor --wait").unwrap();
        assert_eq!(id, "zeditor-usercreated-1.desktop");
        assert_eq!(name, "zeditor", "the path is stripped in both places, or neither");
        assert!(std::fs::read_to_string(dir.path().join(&id)).unwrap().contains("Exec=/usr/bin/zeditor --wait %f"));
    }

    #[test]
    fn an_empty_command_is_refused_rather_than_written() {
        let dir = tempfile::tempdir().unwrap();
        assert!(write_custom_entry(dir.path(), "   ").is_err());
    }

    #[test]
    fn a_program_is_found_on_the_path_and_an_absolute_one_by_itself() {
        assert!(on_path("sh"), "every machine running these tests has a shell");
        assert!(!on_path("definitely-not-a-program-xyz"));
        assert!(on_path("/bin/sh") || on_path("/usr/bin/sh"));
        assert!(!on_path("/nonexistent-xyz/sh"));
    }
}