Skip to main content

chrome_cli/chrome/
platform.rs

1use std::path::PathBuf;
2
3use super::ChromeError;
4
5/// Chrome release channel.
6#[derive(Debug, Clone, Copy)]
7pub enum Channel {
8    Stable,
9    Canary,
10    Beta,
11    Dev,
12}
13
14/// Find a Chrome executable for the given release channel.
15///
16/// Checks the `CHROME_PATH` environment variable first, then falls back
17/// to platform-specific well-known paths.
18///
19/// # Errors
20///
21/// Returns `ChromeError::NotFound` if no Chrome executable can be located.
22pub fn find_chrome_executable(channel: Channel) -> Result<PathBuf, ChromeError> {
23    let env_override = std::env::var("CHROME_PATH").ok().map(PathBuf::from);
24    find_chrome_from(channel, env_override.as_deref())
25}
26
27/// Find a Chrome executable, optionally checking an explicit override path first.
28///
29/// This is the testable core of [`find_chrome_executable`]: it accepts the
30/// environment override as a parameter instead of reading `CHROME_PATH` directly.
31fn find_chrome_from(
32    channel: Channel,
33    env_override: Option<&std::path::Path>,
34) -> Result<PathBuf, ChromeError> {
35    if let Some(p) = env_override {
36        if p.exists() {
37            return Ok(p.to_path_buf());
38        }
39    }
40
41    for candidate in chrome_candidates(channel) {
42        if candidate.exists() {
43            return Ok(candidate);
44        }
45    }
46
47    Err(ChromeError::NotFound(format!(
48        "could not find Chrome ({channel:?} channel). Use --chrome-path to specify the executable"
49    )))
50}
51
52/// Returns the default Chrome user data directory for the current platform.
53#[must_use]
54pub fn default_user_data_dir() -> Option<PathBuf> {
55    #[cfg(target_os = "macos")]
56    {
57        home_dir().map(|h| h.join("Library/Application Support/Google/Chrome"))
58    }
59
60    #[cfg(target_os = "linux")]
61    {
62        home_dir().map(|h| h.join(".config/google-chrome"))
63    }
64
65    #[cfg(target_os = "windows")]
66    {
67        std::env::var("LOCALAPPDATA").ok().map(|d| {
68            PathBuf::from(d)
69                .join("Google")
70                .join("Chrome")
71                .join("User Data")
72        })
73    }
74
75    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
76    {
77        None
78    }
79}
80
81#[cfg(any(target_os = "macos", target_os = "linux"))]
82fn home_dir() -> Option<PathBuf> {
83    std::env::var("HOME").ok().map(PathBuf::from)
84}
85
86/// Returns all candidate executable paths for the given channel on the current platform.
87fn chrome_candidates(channel: Channel) -> Vec<PathBuf> {
88    #[cfg(target_os = "macos")]
89    {
90        macos_candidates(channel)
91    }
92
93    #[cfg(target_os = "linux")]
94    {
95        linux_candidates(channel)
96    }
97
98    #[cfg(target_os = "windows")]
99    {
100        windows_candidates(channel)
101    }
102
103    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
104    {
105        let _ = channel;
106        vec![]
107    }
108}
109
110#[cfg(target_os = "macos")]
111fn macos_candidates(channel: Channel) -> Vec<PathBuf> {
112    match channel {
113        Channel::Stable => vec![
114            PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
115            PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
116        ],
117        Channel::Canary => vec![PathBuf::from(
118            "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
119        )],
120        Channel::Beta => vec![PathBuf::from(
121            "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
122        )],
123        Channel::Dev => vec![PathBuf::from(
124            "/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
125        )],
126    }
127}
128
129#[cfg(target_os = "linux")]
130fn linux_candidates(channel: Channel) -> Vec<PathBuf> {
131    let path_dirs: Vec<PathBuf> = std::env::var("PATH")
132        .unwrap_or_default()
133        .split(':')
134        .map(PathBuf::from)
135        .collect();
136
137    let names: &[&str] = match channel {
138        Channel::Stable => &[
139            "google-chrome",
140            "google-chrome-stable",
141            "chromium-browser",
142            "chromium",
143        ],
144        Channel::Canary => &["google-chrome-canary"],
145        Channel::Beta => &["google-chrome-beta"],
146        Channel::Dev => &["google-chrome-unstable"],
147    };
148
149    let mut candidates = Vec::new();
150    for name in names {
151        for dir in &path_dirs {
152            candidates.push(dir.join(name));
153        }
154    }
155    candidates
156}
157
158#[cfg(target_os = "windows")]
159fn windows_candidates(channel: Channel) -> Vec<PathBuf> {
160    let program_files = std::env::var("ProgramFiles").unwrap_or_default();
161    let program_files_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_default();
162    let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default();
163
164    match channel {
165        Channel::Stable => vec![
166            PathBuf::from(&program_files).join("Google/Chrome/Application/chrome.exe"),
167            PathBuf::from(&program_files_x86).join("Google/Chrome/Application/chrome.exe"),
168        ],
169        Channel::Canary => {
170            vec![PathBuf::from(&local_app_data).join("Google/Chrome SxS/Application/chrome.exe")]
171        }
172        Channel::Beta => vec![
173            PathBuf::from(&program_files).join("Google/Chrome Beta/Application/chrome.exe"),
174            PathBuf::from(&program_files_x86).join("Google/Chrome Beta/Application/chrome.exe"),
175        ],
176        Channel::Dev => vec![
177            PathBuf::from(&program_files).join("Google/Chrome Dev/Application/chrome.exe"),
178            PathBuf::from(&program_files_x86).join("Google/Chrome Dev/Application/chrome.exe"),
179        ],
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn default_user_data_dir_returns_some() {
189        // On CI or dev machines, home dir should exist
190        let dir = default_user_data_dir();
191        assert!(dir.is_some(), "Expected a default user data directory");
192    }
193
194    #[test]
195    fn chrome_candidates_is_not_empty() {
196        let candidates = chrome_candidates(Channel::Stable);
197        assert!(
198            !candidates.is_empty(),
199            "Expected at least one candidate path"
200        );
201    }
202
203    #[test]
204    fn chrome_path_override_existing_file() {
205        // Use the test binary itself as a known-existing file
206        let exe = std::env::current_exe().unwrap();
207        let result = find_chrome_from(Channel::Stable, Some(&exe));
208        assert_eq!(result.unwrap(), exe);
209    }
210
211    #[test]
212    fn chrome_path_override_nonexistent_is_skipped() {
213        let fake = std::path::Path::new("/nonexistent/chrome-test-binary");
214        let result = find_chrome_from(Channel::Stable, Some(fake));
215        // Should fall through to candidates (which may or may not find Chrome)
216        // — the point is that the nonexistent override is skipped, not returned.
217        if let Ok(path) = &result {
218            assert_ne!(path.as_path(), fake);
219        }
220    }
221}