Skip to main content

browser_control/
paths.rs

1//! OS app-data directory resolution.
2
3use anyhow::{anyhow, Context, Result};
4use std::path::PathBuf;
5
6use crate::detect::Kind;
7
8const ENV_OVERRIDE: &str = "BROWSER_CONTROL_DATA_DIR";
9const CONFIG_ENV_OVERRIDE: &str = "BROWSER_CONTROL_CONFIG_DIR";
10
11/// Root data directory for browser-control. Used for the registry DB and profile dirs.
12///
13/// macOS:   `~/Library/Application Support/browser-control`
14/// Linux:   `$XDG_DATA_HOME/browser-control` (or `~/.local/share/browser-control`)
15/// Windows: `%APPDATA%/browser-control`
16///
17/// Override with env var `BROWSER_CONTROL_DATA_DIR` (absolute path).
18pub fn data_dir() -> Result<PathBuf> {
19    if let Some(v) = std::env::var_os(ENV_OVERRIDE) {
20        let p = PathBuf::from(v);
21        if p.as_os_str().is_empty() {
22            return Err(anyhow!("{} is set but empty", ENV_OVERRIDE));
23        }
24        if !p.is_absolute() {
25            return Err(anyhow!(
26                "{} must be an absolute path (got {})",
27                ENV_OVERRIDE,
28                p.display()
29            ));
30        }
31        return Ok(p);
32    }
33
34    let pd = directories::ProjectDirs::from("", "", "browser-control")
35        .ok_or_else(|| anyhow!("could not determine user data directory (no home dir found)"))?;
36    Ok(pd.data_dir().to_path_buf())
37}
38
39/// Returns `<data_dir>/registry.db`. Ensures the parent directory exists.
40pub fn registry_db_path() -> Result<PathBuf> {
41    let dir = data_dir()?;
42    std::fs::create_dir_all(&dir)
43        .with_context(|| format!("creating data dir {}", dir.display()))?;
44    Ok(dir.join("registry.db"))
45}
46
47/// Root config directory for browser-control.
48///
49/// macOS:   `~/Library/Application Support/browser-control` (same as `data_dir`)
50/// Linux:   `$XDG_CONFIG_HOME/browser-control` (or `~/.config/browser-control`)
51/// Windows: `%APPDATA%/browser-control` (same as `data_dir`)
52///
53/// Override with env var `BROWSER_CONTROL_CONFIG_DIR` (absolute path).
54pub fn config_dir() -> Result<PathBuf> {
55    if let Some(v) = std::env::var_os(CONFIG_ENV_OVERRIDE) {
56        let p = PathBuf::from(v);
57        if p.as_os_str().is_empty() {
58            return Err(anyhow!("{} is set but empty", CONFIG_ENV_OVERRIDE));
59        }
60        if !p.is_absolute() {
61            return Err(anyhow!(
62                "{} must be an absolute path (got {})",
63                CONFIG_ENV_OVERRIDE,
64                p.display()
65            ));
66        }
67        return Ok(p);
68    }
69
70    let pd = directories::ProjectDirs::from("", "", "browser-control")
71        .ok_or_else(|| anyhow!("could not determine user config directory (no home dir found)"))?;
72    Ok(pd.config_dir().to_path_buf())
73}
74
75/// Returns `<config_dir>/config.toml`. Ensures the parent directory exists.
76pub fn config_file_path() -> Result<PathBuf> {
77    let dir = config_dir()?;
78    std::fs::create_dir_all(&dir)
79        .with_context(|| format!("creating config dir {}", dir.display()))?;
80    Ok(dir.join("config.toml"))
81}
82
83/// Returns `<data_dir>/profiles`. Ensures the directory exists.
84pub fn profiles_dir() -> Result<PathBuf> {
85    let dir = data_dir()?.join("profiles");
86    std::fs::create_dir_all(&dir)
87        .with_context(|| format!("creating profiles dir {}", dir.display()))?;
88    Ok(dir)
89}
90
91/// Returns the stable per-kind default profile directory used when
92/// `browser-control start` is invoked without `--profile`.
93///
94/// The directory is rooted under [`config_dir`] (so it tracks
95/// `BROWSER_CONTROL_CONFIG_DIR` for tests and user overrides):
96///
97/// * macOS:   `~/Library/Application Support/browser-control/profiles/<kind>/default/`
98/// * Linux:   `~/.config/browser-control/profiles/<kind>/default/`
99/// * Windows: `%APPDATA%/browser-control/profiles/<kind>/default/`
100///
101/// `<kind>` is the lowercase [`Kind`] variant name (`chrome`, `edge`,
102/// `chromium`, `brave`, `firefox`). The split-by-kind is intentional:
103/// Chromium and Firefox profile layouts are not interchangeable.
104///
105/// The directory is created lazily on first call.
106pub fn default_profile_dir(kind: Kind) -> Result<PathBuf> {
107    let dir = config_dir()?
108        .join("profiles")
109        .join(kind.as_str())
110        .join("default");
111    std::fs::create_dir_all(&dir)
112        .with_context(|| format!("creating default profile dir {}", dir.display()))?;
113    Ok(dir)
114}
115
116/// Staging directory for downloads from browsers browser-control launched
117/// (ADR-005): `<data_dir>/downloads/<kind>/`. Launch-time profile prefs and
118/// the MCP server's runtime download override both point here, so every
119/// download from a managed browser lands in one predictable place no matter
120/// which agent or CLI invocation triggered it. Created on demand.
121pub fn downloads_staging_dir(kind: Kind) -> Result<PathBuf> {
122    let dir = data_dir()?.join("downloads").join(kind.as_str());
123    std::fs::create_dir_all(&dir)
124        .with_context(|| format!("creating downloads dir {}", dir.display()))?;
125    Ok(dir)
126}
127
128/// Whether `profile_dir` is a profile browser-control owns (under
129/// `<config_dir>/profiles`). Only owned profiles get download prefs written
130/// and the runtime download override (ADR-005); a user's own browser is
131/// never redirected.
132pub fn is_owned_profile(profile_dir: &std::path::Path) -> bool {
133    let Ok(root) = config_dir().map(|d| d.join("profiles")) else {
134        return false;
135    };
136    let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
137    canon(profile_dir).starts_with(canon(&root))
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn paths_work() {
146        let _g = crate::test_support::ENV_LOCK
147            .lock()
148            .unwrap_or_else(|e| e.into_inner());
149        // Test 1: override via env var.
150        let tmp = tempfile::TempDir::new().unwrap();
151        let tmp_path = tmp.path().to_path_buf();
152        // Safety: tests in this module share process env; we run them sequentially
153        // within a single #[test] to avoid races with other tests.
154        std::env::set_var(ENV_OVERRIDE, &tmp_path);
155
156        assert_eq!(data_dir().unwrap(), tmp_path);
157
158        let db = registry_db_path().unwrap();
159        assert_eq!(db, tmp_path.join("registry.db"));
160        assert!(db.parent().unwrap().exists());
161        assert_eq!(db.file_name().unwrap(), "registry.db");
162
163        let pdir = profiles_dir().unwrap();
164        assert_eq!(pdir, tmp_path.join("profiles"));
165        assert!(pdir.is_dir());
166        assert_eq!(pdir.file_name().unwrap(), "profiles");
167
168        // Test 2: default path has the expected suffix.
169        std::env::remove_var(ENV_OVERRIDE);
170        let d = data_dir().unwrap();
171        let expected_suffix = if cfg!(windows) {
172            "browser-control\\data"
173        } else {
174            "browser-control"
175        };
176        assert!(
177            d.ends_with(expected_suffix),
178            "expected default data_dir to end with {expected_suffix:?}, got {}",
179            d.display()
180        );
181    }
182
183    #[test]
184    fn config_paths_work() {
185        let _g = crate::test_support::ENV_LOCK
186            .lock()
187            .unwrap_or_else(|e| e.into_inner());
188        let tmp = tempfile::TempDir::new().unwrap();
189        let tmp_path = tmp.path().to_path_buf();
190        std::env::set_var(CONFIG_ENV_OVERRIDE, &tmp_path);
191
192        assert_eq!(config_dir().unwrap(), tmp_path);
193
194        let cfg = config_file_path().unwrap();
195        assert_eq!(cfg, tmp_path.join("config.toml"));
196        assert!(cfg.parent().unwrap().exists());
197        assert_eq!(cfg.file_name().unwrap(), "config.toml");
198
199        std::env::remove_var(CONFIG_ENV_OVERRIDE);
200        let d = config_dir().unwrap();
201        let expected_suffix = if cfg!(windows) {
202            "browser-control\\config"
203        } else {
204            "browser-control"
205        };
206        assert!(
207            d.ends_with(expected_suffix),
208            "expected default config_dir to end with {expected_suffix:?}, got {}",
209            d.display()
210        );
211    }
212
213    #[test]
214    fn default_profile_dir_honours_config_env_override() {
215        let _g = crate::test_support::ENV_LOCK
216            .lock()
217            .unwrap_or_else(|e| e.into_inner());
218        let tmp = tempfile::TempDir::new().unwrap();
219        let tmp_path = tmp.path().to_path_buf();
220        std::env::set_var(CONFIG_ENV_OVERRIDE, &tmp_path);
221
222        for k in [
223            Kind::Chrome,
224            Kind::Edge,
225            Kind::Chromium,
226            Kind::Brave,
227            Kind::Firefox,
228        ] {
229            let p = default_profile_dir(k).unwrap();
230            assert_eq!(
231                p,
232                tmp_path.join("profiles").join(k.as_str()).join("default")
233            );
234            assert!(p.is_dir(), "expected {} to be created", p.display());
235        }
236
237        std::env::remove_var(CONFIG_ENV_OVERRIDE);
238    }
239}