railwayapp 4.61.1

Interact with Railway via CLI
use std::{env, fmt, path::Path};

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

use anyhow::{Context, Result, bail};
use is_terminal::IsTerminal;

use crate::{config::Configs, util::prompt::prompt_select};

pub fn resolve_editor_command(editor_override: Option<&str>) -> Result<String> {
    if let Some(editor) = editor_override
        .map(str::trim)
        .filter(|editor| !editor.is_empty())
    {
        return Ok(editor.to_string());
    }

    let mut configs = Configs::new()?;
    if let Some(editor) = configs
        .root_config
        .editor
        .as_deref()
        .map(str::trim)
        .filter(|editor| !editor.is_empty())
    {
        return Ok(editor.to_string());
    }

    if !std::io::stdout().is_terminal() {
        bail!("No editor configured. Re-run interactively or pass --editor.");
    }

    let choices = editor_choices(command_exists);
    if choices.is_empty() {
        bail!(
            "No supported editor found on PATH. Install VS Code, Cursor, Zed, Nano, or Vim, or pass --editor."
        );
    }

    let choice = prompt_select("Select an editor", choices)?;
    let editor = choice.command().to_string();

    configs.root_config.editor = Some(editor.clone());
    configs
        .write()
        .context("Failed to save editor preference")?;

    Ok(editor)
}

fn editor_choices(command_exists: impl Fn(&str) -> bool) -> Vec<EditorChoice> {
    let mut choices = Vec::new();

    if cfg!(target_os = "windows") {
        if let Some(choice) = EditorChoice::from_command_if_available(
            "Notepad",
            "notepad",
            "notepad",
            &command_exists,
        ) {
            choices.push(choice);
        }
    }

    choices.extend(
        [
            EditorChoice::from_command_if_available(
                "VS Code",
                "code",
                "code --wait",
                &command_exists,
            ),
            EditorChoice::from_command_if_available(
                "Cursor",
                "cursor",
                "cursor --wait",
                &command_exists,
            ),
            EditorChoice::from_command_if_available("Zed", "zed", "zed --wait", &command_exists),
            EditorChoice::from_command_if_available("Nano", "nano", "nano", &command_exists),
            EditorChoice::from_command_if_available("Vim", "vim", "vim", &command_exists),
        ]
        .into_iter()
        .flatten(),
    );

    choices
}

#[derive(Clone)]
enum EditorChoice {
    Command {
        label: &'static str,
        command: &'static str,
    },
}

impl EditorChoice {
    fn from_command_if_available(
        label: &'static str,
        executable: &str,
        command: &'static str,
        command_exists: impl Fn(&str) -> bool,
    ) -> Option<Self> {
        command_exists(executable).then_some(Self::Command { label, command })
    }

    fn command(&self) -> &'static str {
        match self {
            Self::Command { command, .. } => command,
        }
    }
}

impl fmt::Display for EditorChoice {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            EditorChoice::Command { label, .. } => write!(f, "{label}"),
        }
    }
}

fn command_exists(command: &str) -> bool {
    let command_path = Path::new(command);
    if command_path.components().count() > 1 {
        return is_executable(command_path);
    }

    let Some(paths) = env::var_os("PATH") else {
        return false;
    };

    env::split_paths(&paths)
        .any(|path| candidate_names(command).any(|candidate| is_executable(&path.join(candidate))))
}

#[cfg(windows)]
fn candidate_names(command: &str) -> Box<dyn Iterator<Item = String> + '_> {
    if Path::new(command).extension().is_some() {
        return Box::new(std::iter::once(command.to_string()));
    }

    let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
    let candidates = pathext
        .split(';')
        .filter(|ext| !ext.is_empty())
        .map(|ext| format!("{command}{ext}"))
        .collect::<Vec<_>>();

    Box::new(candidates.into_iter())
}

#[cfg(not(windows))]
fn candidate_names(command: &str) -> Box<dyn Iterator<Item = String> + '_> {
    Box::new(std::iter::once(command.to_string()))
}

#[cfg(windows)]
fn is_executable(path: &Path) -> bool {
    path.is_file()
}

#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
    path.is_file()
        && path
            .metadata()
            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
            .unwrap_or(false)
}

#[cfg(not(any(unix, windows)))]
fn is_executable(path: &Path) -> bool {
    path.is_file()
}