use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::preview::process::run_command_capture_stdout_cancellable;
use super::super::super::state::OpenWithApp;
use super::desktop_file::{DesktopEntryCandidate, parse_desktop_entry};
use super::exec::expand_exec_template;
use super::scan::desktop_entry_dirs;
pub(super) fn discover_via_gio(
mime: &str,
path: &Path,
canceled: &impl Fn() -> bool,
) -> Option<Vec<OpenWithApp>> {
let mut cmd = Command::new("gio");
cmd.args(["mime", mime]);
let output = run_command_capture_stdout_cancellable(cmd, "open-with-gio", canceled)?;
let text = String::from_utf8_lossy(&output);
let entries = parse_gio_mime_output(&text);
if entries.is_empty() {
return Some(vec![]);
}
let dirs = desktop_entry_dirs();
let desktops = super::current_desktops();
let mut apps = Vec::new();
for (desktop_id, is_default) in entries {
if let Some(app) =
read_desktop_entry_for_id(&desktop_id, &dirs, path, is_default, &desktops)
{
apps.push(app);
}
}
Some(apps)
}
fn read_desktop_entry_for_id(
desktop_id: &str,
dirs: &[PathBuf],
target: &Path,
is_default: bool,
desktops: &[String],
) -> Option<OpenWithApp> {
for dir in dirs {
for entry_path in candidate_paths_for_desktop_id(dir, desktop_id) {
let Ok(contents) = std::fs::read_to_string(&entry_path) else {
continue; };
let candidate: DesktopEntryCandidate = parse_desktop_entry(&contents)?;
if !candidate.is_shown_in(desktops) {
return None;
}
let (program, args) = expand_exec_template(&candidate.exec, target)?;
return Some(OpenWithApp {
display_name: candidate.name,
desktop_id: Some(desktop_id.to_string()),
program,
args,
is_default,
requires_terminal: candidate.terminal,
});
}
}
None
}
fn candidate_paths_for_desktop_id(base: &Path, desktop_id: &str) -> Vec<PathBuf> {
let segments: Vec<&str> = desktop_id.split('-').collect();
(0..segments.len())
.map(|k| {
let file_part = segments[k..].join("-");
if k == 0 {
base.join(&file_part)
} else {
let dir_part = segments[..k].join("/");
base.join(&dir_part).join(&file_part)
}
})
.collect()
}
fn parse_gio_mime_output(text: &str) -> Vec<(String, bool)> {
let mut default_app: Option<String> = None;
let mut ordered: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for line in text.lines() {
if line.starts_with("Default application for ") {
let desktop_id = line
.find("\u{201D}: ")
.map(|i| &line[i + "\u{201D}: ".len()..])
.or_else(|| line.find("': ").map(|i| &line[i + 3..]))
.or_else(|| line.find("\": ").map(|i| &line[i + 3..]))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
if let Some(id) = desktop_id {
default_app = Some(id);
}
continue;
}
if line.starts_with('\t') {
let desktop_id = line.trim().to_string();
if !desktop_id.is_empty() && seen.insert(desktop_id.clone()) {
ordered.push(desktop_id);
}
}
}
let mut result: Vec<(String, bool)> = Vec::new();
if let Some(ref def) = default_app {
result.push((def.clone(), true));
}
for id in ordered {
if default_app.as_deref() != Some(&id) {
result.push((id, false));
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_gio_mime_output_extracts_default_and_registered_curly_quotes() {
let output = "Default application for \u{201C}text/markdown\u{201D}: org.gnome.TextEditor.desktop\nRegistered applications:\n\tcode.desktop\n\torg.gnome.TextEditor.desktop\nRecommended applications:\n\tcode.desktop\n\torg.gnome.TextEditor.desktop\n";
let result = parse_gio_mime_output(output);
assert_eq!(
result[0],
("org.gnome.TextEditor.desktop".to_string(), true)
);
assert_eq!(result[1], ("code.desktop".to_string(), false));
assert_eq!(result.len(), 2, "default + one non-default, no duplicates");
}
#[test]
fn parse_gio_mime_output_extracts_default_and_registered_ascii_quotes() {
let output = "\
Default application for 'text/markdown': org.gnome.TextEditor.desktop
Registered applications:
\tcode.desktop
\torg.gnome.TextEditor.desktop
";
let result = parse_gio_mime_output(output);
assert_eq!(
result[0],
("org.gnome.TextEditor.desktop".to_string(), true)
);
assert_eq!(result[1], ("code.desktop".to_string(), false));
assert_eq!(result.len(), 2);
}
#[test]
fn parse_gio_mime_output_no_default_returns_registered_only() {
let output = "No default applications for \u{201C}application/octet-stream\u{201D}\nRegistered applications:\n\tfoo.desktop\n\tbar.desktop\n";
let result = parse_gio_mime_output(output);
assert_eq!(
result,
vec![
("foo.desktop".to_string(), false),
("bar.desktop".to_string(), false),
]
);
}
#[test]
fn parse_gio_mime_output_empty_when_no_apps() {
let output = "No default applications for \u{201C}application/x-unknown\u{201D}\nNo registered applications\nNo recommended applications\n";
let result = parse_gio_mime_output(output);
assert!(result.is_empty());
}
#[test]
fn parse_gio_mime_output_deduplicates_across_sections() {
let output = "Default application for \u{201C}text/plain\u{201D}: gedit.desktop\nRegistered applications:\n\tgedit.desktop\n\tcode.desktop\nRecommended applications:\n\tcode.desktop\n\tkate.desktop\n";
let result = parse_gio_mime_output(output);
assert_eq!(result[0], ("gedit.desktop".to_string(), true));
let ids: Vec<&str> = result.iter().map(|(id, _)| id.as_str()).collect();
assert!(
ids.contains(&"code.desktop"),
"code.desktop should be present"
);
assert!(
ids.contains(&"kate.desktop"),
"kate.desktop should be present"
);
assert_eq!(
result.len(),
3,
"gedit(default) + code + kate, no duplicates"
);
for (_, is_default) in &result[1..] {
assert!(
!is_default,
"only the default entry should have is_default=true"
);
}
}
#[test]
fn parse_gio_mime_output_default_not_in_registered_section() {
let output = "Default application for \u{201C}image/png\u{201D}: eog.desktop\nRegistered applications:\n\tfeh.desktop\n";
let result = parse_gio_mime_output(output);
assert_eq!(result[0], ("eog.desktop".to_string(), true));
assert_eq!(result[1], ("feh.desktop".to_string(), false));
assert_eq!(result.len(), 2);
}
#[test]
fn parse_gio_mime_output_handles_empty_input() {
let result = parse_gio_mime_output("");
assert!(result.is_empty());
}
#[test]
fn candidate_paths_no_dash_returns_flat_path() {
let base = Path::new("/usr/share/applications");
let paths = candidate_paths_for_desktop_id(base, "gedit.desktop");
assert_eq!(paths, vec![base.join("gedit.desktop")]);
}
#[test]
fn candidate_paths_one_dash_returns_flat_then_nested() {
let base = Path::new("/usr/share/applications");
let paths = candidate_paths_for_desktop_id(base, "kde-konsole.desktop");
assert_eq!(
paths,
vec![
base.join("kde-konsole.desktop"),
base.join("kde/konsole.desktop"),
]
);
}
#[test]
fn candidate_paths_two_dashes_returns_all_splits() {
let base = Path::new("/usr/share/applications");
let paths = candidate_paths_for_desktop_id(base, "org-kde-konsole.desktop");
assert_eq!(
paths,
vec![
base.join("org-kde-konsole.desktop"),
base.join("org/kde-konsole.desktop"),
base.join("org/kde/konsole.desktop"),
]
);
}
#[test]
fn reads_nested_desktop_file_via_hyphenated_id() {
use std::fs;
let base = std::env::temp_dir().join(format!("elio-gio-nest-test-{}", std::process::id()));
let nested_dir = base.join("kde");
fs::create_dir_all(&nested_dir).unwrap();
fs::write(
nested_dir.join("konsole.desktop"),
"[Desktop Entry]\nName=Konsole\nExec=konsole %u\nMimeType=text/plain;\n",
)
.unwrap();
let result = read_desktop_entry_for_id(
"kde-konsole.desktop",
std::slice::from_ref(&base),
Path::new("/tmp/test.txt"),
false,
&[],
);
let _ = fs::remove_dir_all(&base);
let app = result.expect("should find kde/konsole.desktop via kde-konsole.desktop id");
assert_eq!(app.display_name, "Konsole");
assert_eq!(app.desktop_id.as_deref(), Some("kde-konsole.desktop"));
}
}