Skip to main content

browser_automation_cli/
xdg.rs

1//! XDG Base Directory layout for browser-automation-cli (no `.env` at runtime).
2//!
3//! Canonical product paths use the `directories` crate:
4//! - config: `$XDG_CONFIG_HOME/browser-automation-cli` (Linux)
5//! - data:   `$XDG_DATA_HOME/browser-automation-cli`
6//! - cache:  `$XDG_CACHE_HOME/browser-automation-cli`
7//! - state:  `$XDG_STATE_HOME/browser-automation-cli` (when available) or data/state
8//!
9//! Flags on the CLI override file config. Environment variables are **not** used for
10//! product settings; system paths (`PATH`, locale) remain OS concerns.
11
12use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use directories::{BaseDirs, ProjectDirs, UserDirs};
17use serde::{Deserialize, Serialize};
18use serde_json::{json, Value};
19
20use crate::error::{CliError, ErrorKind};
21
22/// Product qualifier for `ProjectDirs` (reversed DNS style, cross-platform).
23const QUALIFIER: &str = "cli";
24/// Organization segment.
25const ORGANIZATION: &str = "browser-automation";
26/// Application name (matches binary).
27const APPLICATION: &str = "browser-automation-cli";
28
29/// Resolve platform project directories or a deterministic fallback under the system temp dir.
30pub fn project_dirs() -> Result<ProjectDirs, CliError> {
31    ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION).ok_or_else(|| {
32        CliError::with_suggestion(
33            ErrorKind::Io,
34            "cannot resolve XDG project directories",
35            "Ensure the home directory is available for this user",
36        )
37    })
38}
39
40/// Config directory (`…/browser-automation-cli`).
41pub fn config_dir() -> Result<PathBuf, CliError> {
42    Ok(project_dirs()?.config_dir().to_path_buf())
43}
44
45/// Data directory (sessions, journals, durable artifacts).
46pub fn data_dir() -> Result<PathBuf, CliError> {
47    Ok(project_dirs()?.data_dir().to_path_buf())
48}
49
50/// Cache directory (lighthouse reports, HTTP scrape cache, browsers cache).
51pub fn cache_dir() -> Result<PathBuf, CliError> {
52    Ok(project_dirs()?.cache_dir().to_path_buf())
53}
54
55/// State directory (runtime state, workflow journal default).
56pub fn state_dir() -> Result<PathBuf, CliError> {
57    // `directories` 5.x exposes state_dir on Unix via ProjectDirs when available.
58    let pd = project_dirs()?;
59    #[allow(deprecated)]
60    let state = pd
61        .state_dir()
62        .map(|p| p.to_path_buf())
63        .unwrap_or_else(|| pd.data_dir().join("state"));
64    Ok(state)
65}
66
67/// Default browsers cache under XDG cache.
68pub fn browsers_dir() -> Result<PathBuf, CliError> {
69    Ok(cache_dir()?.join("browsers"))
70}
71
72/// Default sessions directory under XDG state.
73pub fn sessions_dir() -> Result<PathBuf, CliError> {
74    Ok(state_dir()?.join("sessions"))
75}
76
77/// Default workflow journal directory.
78pub fn workflow_dir() -> Result<PathBuf, CliError> {
79    Ok(state_dir()?.join("workflows"))
80}
81
82/// Default MITM CA directory.
83pub fn mitm_ca_dir() -> Result<PathBuf, CliError> {
84    Ok(data_dir()?.join("mitm").join("ca"))
85}
86
87/// Default MITM capture directory for the invocation artifacts.
88pub fn mitm_capture_dir() -> Result<PathBuf, CliError> {
89    Ok(state_dir()?.join("mitm"))
90}
91
92/// Path to the TOML config file.
93pub fn config_file() -> Result<PathBuf, CliError> {
94    Ok(config_dir()?.join("config.toml"))
95}
96
97/// Ensure a directory exists with restrictive permissions when possible.
98pub fn ensure_dir(path: &Path) -> Result<(), CliError> {
99    fs::create_dir_all(path).map_err(|e| {
100        CliError::new(
101            ErrorKind::Io,
102            format!("create directory {}: {e}", path.display()),
103        )
104    })?;
105    #[cfg(unix)]
106    {
107        use std::os::unix::fs::PermissionsExt;
108        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
109    }
110    Ok(())
111}
112
113/// Create all standard XDG product directories.
114pub fn init_layout() -> Result<Value, CliError> {
115    let cfg = config_dir()?;
116    let data = data_dir()?;
117    let cache = cache_dir()?;
118    let state = state_dir()?;
119    ensure_dir(&cfg)?;
120    ensure_dir(&data)?;
121    ensure_dir(&cache)?;
122    ensure_dir(&state)?;
123    ensure_dir(&browsers_dir()?)?;
124    ensure_dir(&sessions_dir()?)?;
125    ensure_dir(&workflow_dir()?)?;
126    ensure_dir(&mitm_ca_dir()?)?;
127    ensure_dir(&mitm_capture_dir()?)?;
128    let cfg_file = config_file()?;
129    if !cfg_file.exists() {
130        let default = ProductConfig::default();
131        write_config(&default)?;
132    }
133    Ok(json!({
134        "config_dir": cfg.display().to_string(),
135        "data_dir": data.display().to_string(),
136        "cache_dir": cache.display().to_string(),
137        "state_dir": state.display().to_string(),
138        "config_file": cfg_file.display().to_string(),
139        "browsers_dir": browsers_dir()?.display().to_string(),
140        "sessions_dir": sessions_dir()?.display().to_string(),
141        "workflow_dir": workflow_dir()?.display().to_string(),
142        "mitm_ca_dir": mitm_ca_dir()?.display().to_string(),
143    }))
144}
145
146/// On-disk product configuration (TOML). Flags override these at parse time.
147#[derive(Debug, Clone, Serialize, Deserialize, Default)]
148pub struct ProductConfig {
149    /// Default language override (`en` / `pt-BR`).
150    #[serde(default)]
151    pub lang: Option<String>,
152    /// Default global timeout seconds (0 = none).
153    #[serde(default)]
154    pub timeout: Option<u64>,
155    /// Default artifacts directory.
156    #[serde(default)]
157    pub artifacts_dir: Option<String>,
158    /// Default ignore-robots (requires explicit risk acceptance in flags still).
159    #[serde(default)]
160    pub ignore_robots: Option<bool>,
161    /// Namespace for isolated state trees (optional).
162    #[serde(default)]
163    pub namespace: Option<String>,
164    /// Optional AES key material for encrypted session state (stored in XDG config, mode 0600).
165    #[serde(default)]
166    pub encryption_key: Option<String>,
167    /// Enable ANSI colors on human stderr paths when true.
168    #[serde(default)]
169    pub color: Option<bool>,
170}
171
172/// Load config from XDG path; missing file yields defaults.
173pub fn load_config() -> Result<ProductConfig, CliError> {
174    let path = config_file()?;
175    if !path.exists() {
176        return Ok(ProductConfig::default());
177    }
178    let raw = fs::read_to_string(&path).map_err(|e| {
179        CliError::new(
180            ErrorKind::Io,
181            format!("read config {}: {e}", path.display()),
182        )
183    })?;
184    // Minimal TOML subset via serde if we add toml; otherwise JSON fallback.
185    // Prefer JSON if file ends with .json — primary is TOML via line parse for keys we need.
186    if path.extension().and_then(|e| e.to_str()) == Some("json") {
187        return serde_json::from_str(&raw).map_err(|e| {
188            CliError::new(ErrorKind::Data, format!("invalid config JSON: {e}"))
189        });
190    }
191    parse_simple_toml(&raw)
192}
193
194fn parse_simple_toml(raw: &str) -> Result<ProductConfig, CliError> {
195    let mut cfg = ProductConfig::default();
196    for line in raw.lines() {
197        let line = line.trim();
198        if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
199            continue;
200        }
201        let Some((k, v)) = line.split_once('=') else {
202            continue;
203        };
204        let k = k.trim();
205        let v = v.trim().trim_matches('"').trim_matches('\'');
206        match k {
207            "lang" => cfg.lang = Some(v.to_string()),
208            "timeout" => cfg.timeout = v.parse().ok(),
209            "artifacts_dir" => cfg.artifacts_dir = Some(v.to_string()),
210            "ignore_robots" => cfg.ignore_robots = Some(v == "true" || v == "1"),
211            "namespace" => cfg.namespace = Some(v.to_string()),
212            "encryption_key" => cfg.encryption_key = Some(v.to_string()),
213            "color" => cfg.color = Some(v == "true" || v == "1"),
214            _ => {}
215        }
216    }
217    Ok(cfg)
218}
219
220/// Write config atomically (temp + rename).
221pub fn write_config(cfg: &ProductConfig) -> Result<PathBuf, CliError> {
222    let dir = config_dir()?;
223    ensure_dir(&dir)?;
224    let path = config_file()?;
225    let body = format!(
226        "# browser-automation-cli XDG config (no .env at runtime)\n\
227         # Managed by: browser-automation-cli config set|init\n\
228         lang = \"{lang}\"\n\
229         timeout = {timeout}\n\
230         artifacts_dir = \"{artifacts}\"\n\
231         ignore_robots = {ignore}\n\
232         namespace = \"{ns}\"\n\
233         encryption_key = \"{enc}\"\n\
234         color = {color}\n",
235        lang = cfg.lang.as_deref().unwrap_or(""),
236        timeout = cfg.timeout.unwrap_or(0),
237        artifacts = cfg.artifacts_dir.as_deref().unwrap_or(""),
238        ignore = cfg.ignore_robots.unwrap_or(false),
239        ns = cfg.namespace.as_deref().unwrap_or(""),
240        enc = cfg.encryption_key.as_deref().unwrap_or(""),
241        color = cfg.color.unwrap_or(false),
242    );
243    let tmp = path.with_extension("toml.tmp");
244    {
245        let mut f = fs::File::create(&tmp).map_err(|e| {
246            CliError::new(ErrorKind::Io, format!("create temp config: {e}"))
247        })?;
248        f.write_all(body.as_bytes())
249            .map_err(|e| CliError::new(ErrorKind::Io, format!("write temp config: {e}")))?;
250        f.sync_all()
251            .map_err(|e| CliError::new(ErrorKind::Io, format!("fsync temp config: {e}")))?;
252    }
253    fs::rename(&tmp, &path).map_err(|e| {
254        CliError::new(
255            ErrorKind::Io,
256            format!("rename config into place: {e}"),
257        )
258    })?;
259    #[cfg(unix)]
260    {
261        use std::os::unix::fs::PermissionsExt;
262        let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
263    }
264    Ok(path)
265}
266
267/// Set one string key in config and persist.
268pub fn config_set(key: &str, value: &str) -> Result<Value, CliError> {
269    let mut cfg = load_config()?;
270    match key {
271        "lang" => cfg.lang = Some(value.to_string()),
272        "timeout" => {
273            cfg.timeout = Some(value.parse().map_err(|_| {
274                CliError::new(ErrorKind::Usage, "timeout must be an integer seconds")
275            })?);
276        }
277        "artifacts_dir" => cfg.artifacts_dir = Some(value.to_string()),
278        "ignore_robots" => {
279            cfg.ignore_robots = Some(matches!(value, "true" | "1" | "yes"));
280        }
281        "namespace" => cfg.namespace = Some(value.to_string()),
282        "encryption_key" => cfg.encryption_key = Some(value.to_string()),
283        "color" => {
284            cfg.color = Some(matches!(value, "true" | "1" | "yes"));
285        }
286        other => {
287            return Err(CliError::with_suggestion(
288                ErrorKind::Usage,
289                format!("unknown config key: {other}"),
290                "Supported keys: lang, timeout, artifacts_dir, ignore_robots, namespace, encryption_key, color",
291            ));
292        }
293    }
294    let path = write_config(&cfg)?;
295    Ok(json!({
296        "key": key,
297        "value": value,
298        "path": path.display().to_string(),
299    }))
300}
301
302/// Get one config key (or full dump when key is empty).
303pub fn config_get(key: Option<&str>) -> Result<Value, CliError> {
304    let cfg = load_config()?;
305    match key {
306        None | Some("") => Ok(json!({
307            "lang": cfg.lang,
308            "timeout": cfg.timeout,
309            "artifacts_dir": cfg.artifacts_dir,
310            "ignore_robots": cfg.ignore_robots,
311            "namespace": cfg.namespace,
312            "path": config_file()?.display().to_string(),
313        })),
314        Some("lang") => Ok(json!({ "key": "lang", "value": cfg.lang })),
315        Some("timeout") => Ok(json!({ "key": "timeout", "value": cfg.timeout })),
316        Some("artifacts_dir") => Ok(json!({ "key": "artifacts_dir", "value": cfg.artifacts_dir })),
317        Some("ignore_robots") => Ok(json!({ "key": "ignore_robots", "value": cfg.ignore_robots })),
318        Some("namespace") => Ok(json!({ "key": "namespace", "value": cfg.namespace })),
319        Some(other) => Err(CliError::new(
320            ErrorKind::Usage,
321            format!("unknown config key: {other}"),
322        )),
323    }
324}
325
326/// Load optional encryption key from XDG config only (never product env vars).
327pub fn encryption_key() -> Option<String> {
328    load_config()
329        .ok()
330        .and_then(|c| c.encryption_key)
331        .filter(|s| !s.is_empty())
332}
333
334/// JSON snapshot of all resolved paths (for `config path` / doctor).
335pub fn paths_snapshot() -> Result<Value, CliError> {
336    let home = BaseDirs::new().map(|b| b.home_dir().display().to_string());
337    let user_dirs = UserDirs::new().map(|u| u.home_dir().display().to_string());
338    Ok(json!({
339        "config_dir": config_dir()?.display().to_string(),
340        "data_dir": data_dir()?.display().to_string(),
341        "cache_dir": cache_dir()?.display().to_string(),
342        "state_dir": state_dir()?.display().to_string(),
343        "config_file": config_file()?.display().to_string(),
344        "browsers_dir": browsers_dir()?.display().to_string(),
345        "sessions_dir": sessions_dir()?.display().to_string(),
346        "workflow_dir": workflow_dir()?.display().to_string(),
347        "mitm_ca_dir": mitm_ca_dir()?.display().to_string(),
348        "mitm_capture_dir": mitm_capture_dir()?.display().to_string(),
349        "home_dir": home.or(user_dirs),
350        "layout": "xdg",
351    }))
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn project_dirs_resolve() {
360        assert!(project_dirs().is_ok());
361        assert!(config_dir().unwrap().components().count() > 1);
362    }
363}