#![forbid(unsafe_code)] use std::{ env, fs, io, path::{Path, PathBuf}, }; const COMMANDS: [&str; 2] = ["ironstorage", "ironstorage-tui"]; pub(crate) fn install_packaged_launchers() -> io::Result<[PathBuf; 2]> { let executable = env::current_exe()?; let bundle_directory = executable.parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "the desktop executable has no parent directory", ) })?; let home = env::var_os("HOME") .or_else(|| env::var_os("USERPROFILE")) .map(PathBuf::from) .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))?; install_launchers(bundle_directory, &home) } fn install_launchers(bundle_directory: &Path, home: &Path) -> io::Result<[PathBuf; 2]> { let sources = COMMANDS .map(|command| bundle_directory.join(format!("{command}{}", env::consts::EXE_SUFFIX))); for source in &sources { if !source.is_file() { return Err(io::Error::new( io::ErrorKind::NotFound, format!("packaged command not found: {}", source.display()), )); } } let sources = [sources[0].canonicalize()?, sources[1].canonicalize()?]; let directory = home.join(".local/bin"); let targets = COMMANDS.map(|command| directory.join(command)); for target in &targets { match fs::symlink_metadata(target) { Ok(metadata) if metadata.is_dir() => { return Err(io::Error::new( io::ErrorKind::IsADirectory, format!("command link target is a directory: {}", target.display()), )); } Ok(_) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error), } } fs::create_dir_all(&directory)?; for (source, target) in sources.iter().zip(&targets) { if fs::symlink_metadata(target).is_ok() { fs::remove_file(target)?; } symlink(source, target)?; } Ok(targets) } #[cfg(unix)] fn symlink(source: &Path, target: &Path) -> io::Result<()> { std::os::unix::fs::symlink(source, target) } #[cfg(windows)] fn symlink(source: &Path, target: &Path) -> io::Result<()> { std::os::windows::fs::symlink_file(source, target) } #[cfg(not(any(unix, windows)))] fn symlink(_source: &Path, _target: &Path) -> io::Result<()> { Err(io::Error::new( io::ErrorKind::Unsupported, "command links are not supported on this platform", )) } #[cfg(test)] mod tests { use super::*; fn packaged_commands(directory: &Path) -> [PathBuf; 2] { let sources = COMMANDS.map(|command| directory.join(format!("{command}{}", env::consts::EXE_SUFFIX))); for source in &sources { fs::write(source, command_contents(source)).expect("write packaged command"); } sources } fn command_contents(path: &Path) -> String { path.file_name() .expect("command name") .to_string_lossy() .into_owned() } #[test] fn installs_both_packaged_commands_and_replaces_existing_files() { let bundle = tempfile::tempdir().expect("bundle directory"); let home = tempfile::tempdir().expect("home directory"); let sources = packaged_commands(bundle.path()); let bin = home.path().join(".local/bin"); fs::create_dir_all(&bin).expect("bin directory"); fs::write(bin.join("ironstorage"), "old command").expect("old command"); let targets = install_launchers(bundle.path(), home.path()).expect("install launchers"); install_launchers(bundle.path(), home.path()).expect("reinstall launchers"); for ((source, target), expected_target) in sources .iter() .zip(&targets) .zip([bin.join("ironstorage"), bin.join("ironstorage-tui")]) { assert_eq!(target, &expected_target); assert_eq!( fs::read_link(target).expect("read link"), source.canonicalize().expect("canonical source") ); } } #[test] fn validates_all_sources_before_changing_existing_commands() { let bundle = tempfile::tempdir().expect("bundle directory"); let home = tempfile::tempdir().expect("home directory"); let first = bundle .path() .join(format!("ironstorage{}", env::consts::EXE_SUFFIX)); fs::write(&first, "new command").expect("packaged command"); let bin = home.path().join(".local/bin"); fs::create_dir_all(&bin).expect("bin directory"); let existing = bin.join("ironstorage"); fs::write(&existing, "old command").expect("old command"); let error = install_launchers(bundle.path(), home.path()).expect_err("missing TUI"); assert_eq!(error.kind(), io::ErrorKind::NotFound); assert_eq!( fs::read_to_string(existing).expect("existing command"), "old command" ); } #[test] fn rejects_directory_targets_before_changing_other_commands() { let bundle = tempfile::tempdir().expect("bundle directory"); let home = tempfile::tempdir().expect("home directory"); packaged_commands(bundle.path()); let bin = home.path().join(".local/bin"); fs::create_dir_all(bin.join("ironstorage-tui")).expect("directory target"); let existing = bin.join("ironstorage"); fs::write(&existing, "old command").expect("old command"); let error = install_launchers(bundle.path(), home.path()).expect_err("directory target"); assert_eq!(error.kind(), io::ErrorKind::IsADirectory); assert_eq!( fs::read_to_string(existing).expect("existing command"), "old command" ); } }