use hyprforge_mime::cli::{self, shown_name, Option_};
use hyprforge_mime::{App, MimeDb};
use std::borrow::Cow;
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
mod exit {
pub use hyprforge_mime::cli::exit::NO_FILES;
pub const UNKNOWN_TYPE: i32 = 5;
pub const NO_APPLICATION: i32 = 6;
pub const CANCELLED: i32 = 8;
}
#[derive(Debug, Default)]
struct Args {
files: Vec<String>,
ask: bool,
ask_default: bool,
no_ask: bool,
magic_only: bool,
dereference: bool,
database: Option<String>,
debug: bool,
help: bool,
version: bool,
}
const OPTIONS: &[Option_] = &[
("help", Some('h'), false),
("usage", Some('u'), false),
("version", Some('v'), false),
("dereference", Some('L'), false),
("debug", Some('D'), false),
("database", None, true),
("magic-only", Some('M'), false),
("ask", Some('a'), false),
("ask-default", Some('d'), false),
("no-ask", Some('n'), false),
];
fn main() {
let args = match parse(std::env::args().skip(1).collect()) {
Ok(args) => args,
Err((message, code)) => {
eprintln!("{message}");
if code != exit::NO_FILES {
eprintln!("Try 'mimeopen --help' for more information.");
}
std::process::exit(code);
}
};
if args.help {
print!("{}", usage());
return;
}
if args.version {
println!("mimeopen (hyprforge-mime) {}", env!("CARGO_PKG_VERSION"));
return;
}
if args.files.is_empty() {
eprintln!("usage: mimeopen [options] files");
std::process::exit(exit::NO_FILES);
}
let dirs: Vec<PathBuf> = cli::database_dirs(&args.database);
if args.debug {
let shown: Vec<String> = dirs.iter().map(|d| d.display().to_string()).collect();
println!("> Data dirs are: {}", shown.join(", "));
}
let db = MimeDb::load_from(&dirs, &hyprforge_mime::mimeapps_paths());
let first = PathBuf::from(&args.files[0]);
let mime = match args.magic_only {
true => db.lookup().magic.of_file(&first).map(|m| m.mime),
false => Some(db.lookup().of_file(&first, args.dereference).mime),
};
let Some(mime) = mime.filter(|mime| !mime.is_empty()) else {
eprintln!("Could not determine mimetype for file: {}", shown_name(&args.files[0]));
std::process::exit(exit::UNKNOWN_TYPE);
};
let candidates = db.apps_for_including_parents(&mime);
let default = db.default_for(&mime).filter(|app| app.installed);
let chosen: Option<Cow<'_, App>> = if args.no_ask {
default.or_else(|| candidates.first().copied()).map(Cow::Borrowed)
} else if args.ask {
choose(&mime, false, &candidates)
} else if args.ask_default {
choose(&mime, true, &candidates)
} else if default.is_some() {
default.map(Cow::Borrowed)
} else {
match candidates.as_slice() {
[only] => Some(Cow::Borrowed(*only)),
_ => choose(&mime, true, &candidates),
}
};
let Some(chosen) = chosen else {
eprintln!("No applications found for mimetype: {mime}");
std::process::exit(exit::NO_APPLICATION);
};
if args.ask_default || (!args.ask && !args.no_ask && default.is_none()) {
if let Err(e) = hyprforge_mime::set_default(&mime, &chosen.id) {
eprintln!("mimeopen: couldn't remember that choice: {e}");
}
}
let names: Vec<String> = args.files.iter().map(|f| shown_name(f)).collect();
println!("Opening {} with {} ({mime})", names.join(", "), chosen.name);
match launch(&chosen.path, &args.files) {
Ok(()) => {}
Err(message) => {
eprintln!("mimeopen: {message}");
std::process::exit(exit::NO_APPLICATION);
}
}
}
fn launch(entry: &Path, files: &[String]) -> Result<(), String> {
let mut command = std::process::Command::new("gio");
command.arg("launch").arg(entry).args(files);
match hyprforge_process::output(&mut command, hyprforge_process::TIMEOUT) {
Ok(out) if out.status.success() => Ok(()),
Ok(out) => Err(format!("the application exited with {}", out.status)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(
"gio isn't installed, so a chosen application can't be started. Install glib2."
.to_string(),
),
Err(e) => Err(e.to_string()),
}
}
fn choose<'a>(mime: &str, set_default: bool, apps: &[&'a App]) -> Option<Cow<'a, App>> {
if apps.is_empty() && !set_default {
return None;
}
let mut out = std::io::stdout().lock();
if set_default {
let _ = writeln!(out, "Please choose a default application for files of type {mime}\n");
} else {
let _ = writeln!(out, "Please choose an application\n");
}
for (index, app) in apps.iter().enumerate() {
let entry = app.id.trim_end_matches(".desktop");
let _ = writeln!(out, "\t{}) {} ({entry})", index + 1, app.name);
}
if set_default {
let _ = writeln!(out, "\t{}) Other...", apps.len() + 1);
}
let _ = write!(out, "\nuse application #");
let _ = out.flush();
let mut answer = String::new();
if std::io::stdin().lock().read_line(&mut answer).is_err() {
eprintln!("Cancelled");
std::process::exit(exit::CANCELLED);
}
let Ok(number) = answer.trim().parse::<usize>() else {
eprintln!("Cancelled");
std::process::exit(exit::CANCELLED);
};
if set_default && number == apps.len() + 1 {
return custom();
}
match number.checked_sub(1).and_then(|index| apps.get(index)) {
Some(app) => Some(Cow::Borrowed(*app)),
None => {
eprintln!("Cancelled");
std::process::exit(exit::CANCELLED);
}
}
}
fn custom<'a>() -> Option<Cow<'a, App>> {
let mut out = std::io::stdout().lock();
let _ = write!(out, "use command: ");
let _ = out.flush();
let mut command = String::new();
if std::io::stdin().lock().read_line(&mut command).is_err() || command.trim().is_empty() {
eprintln!("Cancelled");
std::process::exit(exit::CANCELLED);
}
let command = command.trim().to_string();
let applications = hyprforge_paths::data_home().join("applications");
match hyprforge_mime::apps::write_custom_entry(&applications, &command) {
Ok((id, name)) => {
let path = applications.join(&id);
Some(Cow::Owned(App { name, id, icon: None, path, installed: true }))
}
Err(e) => {
eprintln!("mimeopen: couldn't record that command: {e}");
std::process::exit(exit::NO_APPLICATION);
}
}
}
fn parse(raw: Vec<String>) -> Result<Args, (String, i32)> {
let parsed = cli::parse("mimeopen", OPTIONS, raw)?;
let mut args = Args { files: parsed.files, ..Args::default() };
for (name, value) in parsed.options {
set(&mut args, name, value);
}
Ok(args)
}
fn set(args: &mut Args, name: &str, value: Option<String>) {
match name {
"help" | "usage" => args.help = true,
"version" => args.version = true,
"dereference" => args.dereference = true,
"debug" => args.debug = true,
"database" => args.database = value,
"magic-only" => args.magic_only = true,
"ask" => args.ask = true,
"ask-default" => args.ask_default = true,
"no-ask" => args.no_ask = true,
_ => {}
}
}
fn usage() -> String {
"\
usage: mimeopen [options] files
-h, --help this text
-u, --usage the same
-v, --version print the version
-a, --ask ask which application, and do not remember
-d, --ask-default ask, and make the answer the default
-n, --no-ask never ask; use the default if there is one
-M, --magic-only decide the type from the contents alone
-L, --dereference follow a symlink to what it points at
-D, --debug say which directories are being read
--database DIRS read these directories instead of the XDG ones
With none of -a, -d or -n: the default is used if there is one, and
otherwise you are asked and the answer is remembered.
"
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn parsed(argv: &[&str]) -> Args {
parse(argv.iter().map(|a| a.to_string()).collect()).expect("these parse")
}
#[test]
fn the_three_asking_modes_are_told_apart() {
assert!(parsed(&["-a", "f"]).ask);
assert!(parsed(&["-d", "f"]).ask_default);
assert!(parsed(&["-n", "f"]).no_ask);
let plain = parsed(&["f"]);
assert!(!plain.ask && !plain.ask_default && !plain.no_ask);
}
#[test]
fn the_form_xdg_open_calls_is_parsed() {
let args = parsed(&["-L", "-n", "/tmp/part.3mf"]);
assert!(args.dereference && args.no_ask);
assert_eq!(args.files, ["/tmp/part.3mf"]);
}
#[test]
fn its_own_options_reach_the_shared_parser() {
assert_eq!(parsed(&["--database", "/a:/b", "f"]).database.as_deref(), Some("/a:/b"));
let (message, code) = parse(vec!["--nope".to_string()]).unwrap_err();
assert!(message.starts_with("mimeopen:"), "{message}");
assert_eq!(code, hyprforge_mime::cli::exit::UNKNOWN_OPTION);
}
#[test]
fn nothing_to_choose_from_is_not_a_question() {
assert!(choose("model/stl", false, &[]).is_none());
}
}