elio 1.6.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
use anyhow::Result;
use std::{
    env, fs, io,
    path::{Path, PathBuf},
    process::ExitCode,
};

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("{error}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<()> {
    let args = env::args().skip(1).collect::<Vec<_>>();

    match args.as_slice() {
        [] => elio::run(),
        [arg] if arg == "--version" || arg == "-V" => {
            print_version();
            Ok(())
        }
        [arg] if arg == "--help" || arg == "-h" => {
            print_help();
            Ok(())
        }
        [arg, unexpected, ..] if arg == "--version" || arg == "-V" => {
            Err(anyhow::anyhow!(unknown_argument_message(unexpected)))
        }
        [arg, unexpected, ..] if arg == "--help" || arg == "-h" => {
            Err(anyhow::anyhow!(unknown_argument_message(unexpected)))
        }
        [arg] if arg.starts_with('-') => Err(anyhow::anyhow!(unknown_argument_message(arg))),
        [arg] => elio::run_at(resolve_startup_directory(arg)?),
        [arg, ..] => Err(anyhow::anyhow!(unknown_argument_message(arg))),
    }
}

fn print_version() {
    println!("elio {}", env!("CARGO_PKG_VERSION"));
}

fn print_help() {
    println!("elio {}", env!("CARGO_PKG_VERSION"));
    println!();
    println!("Usage: elio [OPTIONS] [DIRECTORY]");
    println!();
    println!("Arguments:");
    println!("  [DIRECTORY]  Start elio in this directory");
    println!();
    println!("Options:");
    println!("  -h, --help     Print help");
    println!("  -V, --version  Print version");
}

fn resolve_startup_directory(arg: &str) -> Result<PathBuf> {
    let path = PathBuf::from(arg);
    let metadata = fs::metadata(&path).map_err(|error| startup_path_error(&path, &error))?;
    if !metadata.is_dir() {
        return Err(anyhow::anyhow!(
            "Cannot open \"{}\": not a directory",
            path.display()
        ));
    }
    Ok(path.canonicalize().unwrap_or(path))
}

fn startup_path_error(path: &Path, error: &io::Error) -> anyhow::Error {
    let detail = match error.kind() {
        io::ErrorKind::NotFound => "no such file or directory".to_string(),
        io::ErrorKind::PermissionDenied => "permission denied".to_string(),
        _ => error.to_string(),
    };
    anyhow::anyhow!("Cannot open \"{}\": {detail}", path.display())
}

fn unknown_argument_message(arg: &str) -> String {
    let mut message = format!("error: unexpected argument '{arg}' found");

    if arg != "--version" && arg != "-V" && ("--version".starts_with(arg) || "-V".starts_with(arg))
    {
        message.push_str("\n\n  tip: a similar argument exists: '--version'");
    } else if arg != "--help" && arg != "-h" && ("--help".starts_with(arg) || "-h".starts_with(arg))
    {
        message.push_str("\n\n  tip: a similar argument exists: '--help'");
    }

    message.push_str("\n\nUsage: elio [OPTIONS] [DIRECTORY]");
    message.push_str("\n\nFor more information, try '--help'.");
    message
}

#[cfg(test)]
mod tests {
    use super::resolve_startup_directory;
    use std::{
        fs,
        path::PathBuf,
        time::{SystemTime, UNIX_EPOCH},
    };

    fn temp_path(label: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be after unix epoch")
            .as_nanos();
        std::env::temp_dir().join(format!("elio-main-{label}-{unique}"))
    }

    #[test]
    fn resolve_startup_directory_accepts_existing_directory() {
        let root = temp_path("directory");
        fs::create_dir_all(&root).expect("temp directory should be created");

        let resolved = resolve_startup_directory(root.to_str().expect("temp path should be utf-8"))
            .expect("existing directory should resolve");

        assert_eq!(
            resolved,
            root.canonicalize()
                .expect("temp directory should canonicalize successfully")
        );

        fs::remove_dir_all(root).expect("temp directory should be removed");
    }

    #[test]
    fn resolve_startup_directory_rejects_missing_path() {
        let missing = temp_path("missing");

        let error =
            resolve_startup_directory(missing.to_str().expect("temp path should be valid utf-8"))
                .expect_err("missing path should return an error");

        assert_eq!(
            error.to_string(),
            format!(
                "Cannot open \"{}\": no such file or directory",
                missing.display()
            )
        );
    }

    #[test]
    fn resolve_startup_directory_rejects_files() {
        let root = temp_path("file");
        fs::create_dir_all(&root).expect("temp directory should be created");
        let file = root.join("notes.txt");
        fs::write(&file, "hello").expect("temp file should be created");

        let error =
            resolve_startup_directory(file.to_str().expect("temp path should be valid utf-8"))
                .expect_err("file path should return an error");

        assert_eq!(
            error.to_string(),
            format!("Cannot open \"{}\": not a directory", file.display())
        );

        fs::remove_dir_all(root).expect("temp directory should be removed");
    }
}