cdp-browser-lite 0.1.0

Control total del ciclo de vida de instancias de Chrome y acceso CDP
Documentation
use std::path::{Path, PathBuf};

use crate::error::BrowserError;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum Os {
    Linux,
    Mac,
    Windows,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Candidate {
    Path(PathBuf),
    InPath(String),
}

pub(crate) trait EnvProvider {
    fn var(&self, name: &str) -> Option<String>;
    fn current_os(&self) -> Os;
}

pub(crate) trait FsProbe {
    fn is_executable(&self, path: &Path) -> bool;
    fn which(&self, binary: &str) -> Option<PathBuf>;
}

pub(crate) struct RealEnvProvider;

impl EnvProvider for RealEnvProvider {
    fn var(&self, name: &str) -> Option<String> {
        std::env::var(name).ok()
    }

    fn current_os(&self) -> Os {
        if cfg!(target_os = "linux") {
            Os::Linux
        } else if cfg!(target_os = "macos") {
            Os::Mac
        } else if cfg!(target_os = "windows") {
            Os::Windows
        } else {
            Os::Linux
        }
    }
}

pub(crate) struct RealFsProbe;

impl RealFsProbe {
    fn is_executable_impl(path: &Path) -> bool {
        if !path.is_file() {
            return false;
        }
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::metadata(path)
                .map(|m| m.permissions().mode() & 0o111 != 0)
                .unwrap_or(false)
        }
        #[cfg(not(unix))]
        {
            true
        }
    }

    fn which_impl(binary: &str) -> Option<PathBuf> {
        let path_var = std::env::var("PATH").ok()?;
        for dir in std::env::split_paths(&path_var) {
            let candidate = dir.join(binary);
            if Self::is_executable_impl(&candidate) {
                return Some(candidate);
            }
        }
        None
    }
}

impl FsProbe for RealFsProbe {
    fn is_executable(&self, path: &Path) -> bool {
        Self::is_executable_impl(path)
    }

    fn which(&self, binary: &str) -> Option<PathBuf> {
        Self::which_impl(binary)
    }
}

pub(crate) fn candidate_paths(os: Os, env: &dyn EnvProvider) -> Vec<Candidate> {
    if let Some(chrome_path) = env.var("CHROME_PATH") {
        return vec![Candidate::Path(PathBuf::from(chrome_path))];
    }

    match os {
        Os::Linux => linux_candidates(env),
        Os::Mac => macos_candidates(env),
        Os::Windows => windows_candidates(env),
    }
}

fn linux_candidates(_env: &dyn EnvProvider) -> Vec<Candidate> {
    let mut v = Vec::new();
    for bin in &[
        "google-chrome",
        "google-chrome-stable",
        "chromium",
        "chromium-browser",
    ] {
        v.push(Candidate::InPath((*bin).to_string()));
    }
    v.push(Candidate::Path(PathBuf::from("/usr/bin/google-chrome")));
    v.push(Candidate::Path(PathBuf::from("/opt/google/chrome/chrome")));
    v.push(Candidate::Path(PathBuf::from("/snap/bin/chromium")));
    v
}

fn macos_candidates(env: &dyn EnvProvider) -> Vec<Candidate> {
    let home = env.var("HOME");
    let apps = ["Google Chrome", "Chromium", "Google Chrome Canary"];
    let mut v = Vec::new();

    for app in &apps {
        let rel = format!("/Applications/{app}.app/Contents/MacOS/{app}");
        v.push(Candidate::Path(PathBuf::from(&rel)));
        if let Some(ref h) = home {
            v.push(Candidate::Path(PathBuf::from(format!("{h}{rel}"))));
        }
    }

    v
}

fn windows_candidates(env: &dyn EnvProvider) -> Vec<Candidate> {
    let mut v = Vec::new();
    let suffix = r"Google\Chrome\Application\chrome.exe";

    for var_name in &["ProgramFiles", "ProgramFiles(x86)", "LocalAppData"] {
        if let Some(base) = env.var(var_name) {
            v.push(Candidate::Path(PathBuf::from(format!("{base}\\{suffix}"))));
        }
    }

    v.push(Candidate::InPath("chrome".to_string()));
    v
}

pub(crate) fn discover(env: &dyn EnvProvider, fs: &dyn FsProbe) -> Result<PathBuf, BrowserError> {
    let os = env.current_os();
    let candidates = candidate_paths(os, env);
    let mut searched = Vec::new();

    for candidate in &candidates {
        match candidate {
            Candidate::Path(path) => {
                if fs.is_executable(path) {
                    return Ok(path.clone());
                }
                searched.push(path.clone());
            }
            Candidate::InPath(binary) => {
                if let Some(found) = fs.which(binary) {
                    return Ok(found);
                }
                searched.push(PathBuf::from(binary));
            }
        }
    }

    Err(BrowserError::ExecutableNotFound { searched })
}

pub fn discover_default() -> Result<PathBuf, BrowserError> {
    discover(&RealEnvProvider, &RealFsProbe)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::{HashMap, HashSet};

    struct MockEnv {
        vars: HashMap<String, String>,
        os: Os,
    }

    impl MockEnv {
        fn new(os: Os, vars: &[(&str, &str)]) -> Self {
            Self {
                vars: vars
                    .iter()
                    .map(|(k, v)| (k.to_string(), v.to_string()))
                    .collect(),
                os,
            }
        }
    }

    impl EnvProvider for MockEnv {
        fn var(&self, name: &str) -> Option<String> {
            self.vars.get(name).cloned()
        }

        fn current_os(&self) -> Os {
            self.os
        }
    }

    struct MockFs {
        executables: HashSet<PathBuf>,
        which_map: HashMap<String, PathBuf>,
    }

    impl MockFs {
        fn new(executables: &[&str], which_map: &[(&str, &str)]) -> Self {
            Self {
                executables: executables.iter().map(PathBuf::from).collect(),
                which_map: which_map
                    .iter()
                    .map(|(k, v)| (k.to_string(), PathBuf::from(v)))
                    .collect(),
            }
        }
    }

    impl FsProbe for MockFs {
        fn is_executable(&self, path: &Path) -> bool {
            self.executables.contains(path)
        }

        fn which(&self, binary: &str) -> Option<PathBuf> {
            self.which_map.get(binary).cloned()
        }
    }

    #[test]
    fn given_chrome_path_env_when_discovering_then_it_wins() {
        let env = MockEnv::new(Os::Linux, &[("CHROME_PATH", "/custom/chrome")]);
        let fs = MockFs::new(&["/custom/chrome"], &[]);

        let result = discover(&env, &fs).expect("should find chrome via CHROME_PATH");
        assert_eq!(result, PathBuf::from("/custom/chrome"));
    }

    #[test]
    fn given_invalid_chrome_path_env_when_discovering_then_error_without_fallback() {
        let env = MockEnv::new(Os::Linux, &[("CHROME_PATH", "/no/existe")]);
        let fs = MockFs::new(&[], &[]);

        let err = discover(&env, &fs).expect_err("should fail for non-existent CHROME_PATH");
        match err {
            BrowserError::ExecutableNotFound { searched } => {
                assert_eq!(searched.len(), 1);
                assert_eq!(searched[0], PathBuf::from("/no/existe"));
            }
            other => panic!("expected ExecutableNotFound, got {other:?}"),
        }
    }

    #[test]
    fn given_linux_with_only_chromium_when_discovering_then_finds_chromium() {
        let env = MockEnv::new(Os::Linux, &[]);
        let fs = MockFs::new(&[], &[("chromium", "/usr/bin/chromium")]);

        let result = discover(&env, &fs).expect("should find chromium");
        assert_eq!(result, PathBuf::from("/usr/bin/chromium"));
    }

    #[test]
    fn given_macos_default_install_when_discovering_then_finds_app_bundle() {
        let env = MockEnv::new(Os::Mac, &[("HOME", "/Users/test")]);
        let chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
        let fs = MockFs::new(&[chrome_path], &[]);

        let result = discover(&env, &fs).expect("should find Google Chrome app bundle");
        assert_eq!(result, PathBuf::from(chrome_path));
    }

    #[test]
    fn given_windows_program_files_install_when_discovering_then_finds_exe() {
        let env = MockEnv::new(
            Os::Windows,
            &[
                ("ProgramFiles", r"C:\Program Files"),
                ("ProgramFiles(x86)", r"C:\Program Files (x86)"),
                ("LocalAppData", r"C:\Users\test\AppData\Local"),
            ],
        );
        let expected = r"C:\Program Files\Google\Chrome\Application\chrome.exe";
        let fs = MockFs::new(&[expected], &[]);

        let result = discover(&env, &fs).expect("should find chrome in Program Files");
        assert_eq!(result, PathBuf::from(expected));
    }

    #[test]
    fn given_nothing_installed_when_discovering_then_error_lists_all_candidates() {
        let env = MockEnv::new(Os::Linux, &[]);
        let fs = MockFs::new(&[], &[]);

        let err = discover(&env, &fs).expect_err("should fail with nothing installed");
        match err {
            BrowserError::ExecutableNotFound { searched } => {
                assert_eq!(searched.len(), 7);
                assert_eq!(searched[0], PathBuf::from("google-chrome"));
                assert_eq!(searched[1], PathBuf::from("google-chrome-stable"));
                assert_eq!(searched[2], PathBuf::from("chromium"));
                assert_eq!(searched[3], PathBuf::from("chromium-browser"));
                assert_eq!(searched[4], PathBuf::from("/usr/bin/google-chrome"));
                assert_eq!(searched[5], PathBuf::from("/opt/google/chrome/chrome"));
                assert_eq!(searched[6], PathBuf::from("/snap/bin/chromium"));
            }
            other => panic!("expected ExecutableNotFound, got {other:?}"),
        }
    }

    #[test]
    fn test_candidate_order_is_stable_on_linux() {
        let empty_env = MockEnv::new(Os::Linux, &[]);

        let linux = candidate_paths(Os::Linux, &empty_env);

        let expected = vec![
            Candidate::InPath("google-chrome".to_string()),
            Candidate::InPath("google-chrome-stable".to_string()),
            Candidate::InPath("chromium".to_string()),
            Candidate::InPath("chromium-browser".to_string()),
            Candidate::Path(PathBuf::from("/usr/bin/google-chrome")),
            Candidate::Path(PathBuf::from("/opt/google/chrome/chrome")),
            Candidate::Path(PathBuf::from("/snap/bin/chromium")),
        ];
        assert_eq!(linux, expected);
    }

    #[test]
    fn test_candidate_order_is_stable_on_macos() {
        let mac_env = MockEnv::new(Os::Mac, &[("HOME", "/Users/t")]);
        let macos = candidate_paths(Os::Mac, &mac_env);
        assert_eq!(macos.len(), 6);
        assert_eq!(
            macos[0],
            Candidate::Path(PathBuf::from(
                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
            ))
        );
        assert_eq!(
            macos[1],
            Candidate::Path(PathBuf::from(
                "/Users/t/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
            ))
        );
    }

    #[test]
    fn test_candidate_order_is_stable_on_windows() {
        let win_env = MockEnv::new(
            Os::Windows,
            &[("ProgramFiles", r"C:\PF"), ("LocalAppData", r"C:\Local")],
        );
        let windows = candidate_paths(Os::Windows, &win_env);
        assert_eq!(windows.len(), 3);
        assert_eq!(
            windows[0],
            Candidate::Path(PathBuf::from(r"C:\PF\Google\Chrome\Application\chrome.exe"))
        );
        assert!(matches!(&windows[2], Candidate::InPath(s) if s == "chrome"));
    }
}