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    /// Tracing filter level when flags are quiet/default (`error`/`info`/`debug`).
171    #[serde(default)]
172    pub log_level: Option<String>,
173    /// Absolute path to Chrome/Chromium binary (XDG only; never product env).
174    #[serde(default)]
175    pub chrome_path: Option<String>,
176    /// Absolute path to lighthouse CLI (optional).
177    #[serde(default)]
178    pub lighthouse_path: Option<String>,
179    /// Optional LLM provider API key for extract --llm (stored in XDG config mode 0600).
180    #[serde(default)]
181    pub openrouter_api_key: Option<String>,
182    /// OpenAI-compatible API base URL (no trailing slash).
183    #[serde(default)]
184    pub llm_base_url: Option<String>,
185    /// Default model id for extract --llm.
186    #[serde(default)]
187    pub llm_model: Option<String>,
188}
189
190/// Load config from XDG path; missing file yields defaults.
191pub fn load_config() -> Result<ProductConfig, CliError> {
192    let path = config_file()?;
193    if !path.exists() {
194        return Ok(ProductConfig::default());
195    }
196    let raw = fs::read_to_string(&path).map_err(|e| {
197        CliError::new(
198            ErrorKind::Io,
199            format!("read config {}: {e}", path.display()),
200        )
201    })?;
202    // Minimal TOML subset via serde if we add toml; otherwise JSON fallback.
203    // Prefer JSON if file ends with .json — primary is TOML via line parse for keys we need.
204    if path.extension().and_then(|e| e.to_str()) == Some("json") {
205        return serde_json::from_str(&raw)
206            .map_err(|e| CliError::new(ErrorKind::Data, format!("invalid config JSON: {e}")));
207    }
208    parse_simple_toml(&raw)
209}
210
211fn parse_simple_toml(raw: &str) -> Result<ProductConfig, CliError> {
212    let mut cfg = ProductConfig::default();
213    for line in raw.lines() {
214        let line = line.trim();
215        if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
216            continue;
217        }
218        let Some((k, v)) = line.split_once('=') else {
219            continue;
220        };
221        let k = k.trim();
222        let v = v.trim().trim_matches('"').trim_matches('\'');
223        match k {
224            "lang" => cfg.lang = Some(v.to_string()),
225            "timeout" => cfg.timeout = v.parse().ok(),
226            "artifacts_dir" => cfg.artifacts_dir = Some(v.to_string()),
227            "ignore_robots" => cfg.ignore_robots = Some(v == "true" || v == "1"),
228            "namespace" => cfg.namespace = Some(v.to_string()),
229            "encryption_key" => cfg.encryption_key = Some(v.to_string()),
230            "color" => cfg.color = Some(v == "true" || v == "1"),
231            "log_level" => cfg.log_level = Some(v.to_string()),
232            "chrome_path" => cfg.chrome_path = Some(v.to_string()),
233            "lighthouse_path" => cfg.lighthouse_path = Some(v.to_string()),
234            "openrouter_api_key" => cfg.openrouter_api_key = Some(v.to_string()),
235            "llm_base_url" => cfg.llm_base_url = Some(v.to_string()),
236            "llm_model" => cfg.llm_model = Some(v.to_string()),
237            _ => {}
238        }
239    }
240    Ok(cfg)
241}
242
243/// Write config atomically (temp + rename).
244pub fn write_config(cfg: &ProductConfig) -> Result<PathBuf, CliError> {
245    let dir = config_dir()?;
246    ensure_dir(&dir)?;
247    let path = config_file()?;
248    let body = format!(
249        "# browser-automation-cli XDG config (no .env at runtime)\n\
250         # Managed by: browser-automation-cli config set|init\n\
251         lang = \"{lang}\"\n\
252         timeout = {timeout}\n\
253         artifacts_dir = \"{artifacts}\"\n\
254         ignore_robots = {ignore}\n\
255         namespace = \"{ns}\"\n\
256         encryption_key = \"{enc}\"\n\
257         color = {color}\n\
258         log_level = \"{log_level}\"\n\
259         chrome_path = \"{chrome_path}\"\n\
260         lighthouse_path = \"{lighthouse_path}\"\n\
261         openrouter_api_key = \"{openrouter_api_key}\"\n\
262         llm_base_url = \"{llm_base_url}\"\n\
263         llm_model = \"{llm_model}\"\n",
264        lang = cfg.lang.as_deref().unwrap_or(""),
265        timeout = cfg.timeout.unwrap_or(0),
266        artifacts = cfg.artifacts_dir.as_deref().unwrap_or(""),
267        ignore = cfg.ignore_robots.unwrap_or(false),
268        ns = cfg.namespace.as_deref().unwrap_or(""),
269        enc = cfg.encryption_key.as_deref().unwrap_or(""),
270        color = cfg.color.unwrap_or(false),
271        log_level = cfg.log_level.as_deref().unwrap_or(""),
272        chrome_path = cfg.chrome_path.as_deref().unwrap_or(""),
273        lighthouse_path = cfg.lighthouse_path.as_deref().unwrap_or(""),
274        openrouter_api_key = cfg.openrouter_api_key.as_deref().unwrap_or(""),
275        llm_base_url = cfg.llm_base_url.as_deref().unwrap_or(""),
276        llm_model = cfg.llm_model.as_deref().unwrap_or(""),
277    );
278    let tmp = path.with_extension("toml.tmp");
279    {
280        let mut f = fs::File::create(&tmp)
281            .map_err(|e| CliError::new(ErrorKind::Io, format!("create temp config: {e}")))?;
282        f.write_all(body.as_bytes())
283            .map_err(|e| CliError::new(ErrorKind::Io, format!("write temp config: {e}")))?;
284        f.sync_all()
285            .map_err(|e| CliError::new(ErrorKind::Io, format!("fsync temp config: {e}")))?;
286    }
287    fs::rename(&tmp, &path)
288        .map_err(|e| CliError::new(ErrorKind::Io, format!("rename config into place: {e}")))?;
289    #[cfg(unix)]
290    {
291        use std::os::unix::fs::PermissionsExt;
292        let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
293    }
294    Ok(path)
295}
296
297/// Set one string key in config and persist.
298pub fn config_set(key: &str, value: &str) -> Result<Value, CliError> {
299    let mut cfg = load_config()?;
300    match key {
301        "lang" => cfg.lang = Some(value.to_string()),
302        "timeout" => {
303            cfg.timeout = Some(value.parse().map_err(|_| {
304                CliError::new(ErrorKind::Usage, "timeout must be an integer seconds")
305            })?);
306        }
307        "artifacts_dir" => cfg.artifacts_dir = Some(value.to_string()),
308        "ignore_robots" => {
309            cfg.ignore_robots = Some(matches!(value, "true" | "1" | "yes"));
310        }
311        "namespace" => cfg.namespace = Some(value.to_string()),
312        "encryption_key" => cfg.encryption_key = Some(value.to_string()),
313        "color" => {
314            cfg.color = Some(matches!(value, "true" | "1" | "yes"));
315        }
316        "log_level" => cfg.log_level = Some(value.to_string()),
317        "chrome_path" => cfg.chrome_path = Some(value.to_string()),
318        "lighthouse_path" => cfg.lighthouse_path = Some(value.to_string()),
319        "openrouter_api_key" => cfg.openrouter_api_key = Some(value.to_string()),
320        "llm_base_url" => cfg.llm_base_url = Some(value.to_string()),
321        "llm_model" => cfg.llm_model = Some(value.to_string()),
322        other => {
323            return Err(CliError::with_suggestion(
324                ErrorKind::Usage,
325                format!("unknown config key: {other}"),
326                "Supported keys: lang, timeout, artifacts_dir, ignore_robots, namespace, encryption_key, color, log_level, chrome_path, lighthouse_path, openrouter_api_key, llm_base_url, llm_model",
327            ));
328        }
329    }
330    let path = write_config(&cfg)?;
331    Ok(json!({
332        "key": key,
333        "value": value,
334        "path": path.display().to_string(),
335    }))
336}
337
338/// Get one config key (or full dump when key is empty).
339pub fn config_get(key: Option<&str>) -> Result<Value, CliError> {
340    let cfg = load_config()?;
341    match key {
342        None | Some("") => Ok(json!({
343            "lang": cfg.lang,
344            "timeout": cfg.timeout,
345            "artifacts_dir": cfg.artifacts_dir,
346            "ignore_robots": cfg.ignore_robots,
347            "namespace": cfg.namespace,
348            "path": config_file()?.display().to_string(),
349        })),
350        Some("lang") => Ok(json!({ "key": "lang", "value": cfg.lang })),
351        Some("timeout") => Ok(json!({ "key": "timeout", "value": cfg.timeout })),
352        Some("artifacts_dir") => Ok(json!({ "key": "artifacts_dir", "value": cfg.artifacts_dir })),
353        Some("ignore_robots") => Ok(json!({ "key": "ignore_robots", "value": cfg.ignore_robots })),
354        Some("namespace") => Ok(json!({ "key": "namespace", "value": cfg.namespace })),
355        Some("log_level") => Ok(json!({ "key": "log_level", "value": cfg.log_level })),
356        Some("chrome_path") => Ok(json!({ "key": "chrome_path", "value": cfg.chrome_path })),
357        Some("lighthouse_path") => {
358            Ok(json!({ "key": "lighthouse_path", "value": cfg.lighthouse_path }))
359        }
360        Some("openrouter_api_key") => Ok(json!({
361            "key": "openrouter_api_key",
362            "value": if cfg.openrouter_api_key.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
363                "[set]"
364            } else {
365                ""
366            }
367        })),
368        Some("llm_base_url") => Ok(json!({ "key": "llm_base_url", "value": cfg.llm_base_url })),
369        Some("llm_model") => Ok(json!({ "key": "llm_model", "value": cfg.llm_model })),
370        Some(other) => Err(CliError::new(
371            ErrorKind::Usage,
372            format!("unknown config key: {other}"),
373        )),
374    }
375}
376
377/// Load optional encryption key from XDG config only (never product env vars).
378pub fn encryption_key() -> Option<String> {
379    load_config()
380        .ok()
381        .and_then(|c| c.encryption_key)
382        .filter(|s| !s.is_empty())
383}
384
385/// Chrome/Chromium binary from XDG config only.
386pub fn chrome_path_from_config() -> Option<String> {
387    load_config()
388        .ok()
389        .and_then(|c| c.chrome_path)
390        .filter(|s| !s.is_empty())
391}
392
393/// Lighthouse binary path from XDG config only.
394pub fn lighthouse_path_from_config() -> Option<String> {
395    load_config()
396        .ok()
397        .and_then(|c| c.lighthouse_path)
398        .filter(|s| !s.is_empty())
399}
400
401/// Optional LLM API key from XDG config only (never product env vars).
402pub fn openrouter_api_key() -> Option<String> {
403    load_config()
404        .ok()
405        .and_then(|c| c.openrouter_api_key)
406        .filter(|s| !s.is_empty())
407}
408
409/// Optional LLM base URL from XDG config only.
410pub fn llm_base_url() -> Option<String> {
411    load_config()
412        .ok()
413        .and_then(|c| c.llm_base_url)
414        .filter(|s| !s.is_empty())
415}
416
417/// Optional LLM model id from XDG config only.
418pub fn llm_model() -> Option<String> {
419    load_config()
420        .ok()
421        .and_then(|c| c.llm_model)
422        .filter(|s| !s.is_empty())
423}
424
425/// JSON snapshot of all resolved paths (for `config path` / doctor).
426pub fn paths_snapshot() -> Result<Value, CliError> {
427    let home = BaseDirs::new().map(|b| b.home_dir().display().to_string());
428    let user_dirs = UserDirs::new().map(|u| u.home_dir().display().to_string());
429    Ok(json!({
430        "config_dir": config_dir()?.display().to_string(),
431        "data_dir": data_dir()?.display().to_string(),
432        "cache_dir": cache_dir()?.display().to_string(),
433        "state_dir": state_dir()?.display().to_string(),
434        "config_file": config_file()?.display().to_string(),
435        "browsers_dir": browsers_dir()?.display().to_string(),
436        "sessions_dir": sessions_dir()?.display().to_string(),
437        "workflow_dir": workflow_dir()?.display().to_string(),
438        "mitm_ca_dir": mitm_ca_dir()?.display().to_string(),
439        "mitm_capture_dir": mitm_capture_dir()?.display().to_string(),
440        "home_dir": home.or(user_dirs),
441        "layout": "xdg",
442    }))
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn project_dirs_resolve() {
451        assert!(project_dirs().is_ok());
452        assert!(config_dir().unwrap().components().count() > 1);
453    }
454}