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    /// When true, also write rotated local logs under XDG state (never remote telemetry).
189    #[serde(default)]
190    pub log_to_file: Option<bool>,
191    /// Cache backend: `sqlite` (default layered) | `memory` | `redis`.
192    #[serde(default)]
193    pub cache_backend: Option<String>,
194    /// Redis URL when cache_backend=redis (XDG only; never env).
195    #[serde(default)]
196    pub cache_redis_url: Option<String>,
197}
198
199/// Load config from XDG path; missing file yields defaults.
200pub fn load_config() -> Result<ProductConfig, CliError> {
201    let path = config_file()?;
202    if !path.exists() {
203        return Ok(ProductConfig::default());
204    }
205    let raw = fs::read_to_string(&path).map_err(|e| {
206        CliError::new(
207            ErrorKind::Io,
208            format!("read config {}: {e}", path.display()),
209        )
210    })?;
211    // Minimal TOML subset via serde if we add toml; otherwise JSON fallback.
212    // Prefer JSON if file ends with .json — primary is TOML via line parse for keys we need.
213    if path.extension().and_then(|e| e.to_str()) == Some("json") {
214        return serde_json::from_str(&raw)
215            .map_err(|e| CliError::new(ErrorKind::Data, format!("invalid config JSON: {e}")));
216    }
217    parse_simple_toml(&raw)
218}
219
220fn parse_simple_toml(raw: &str) -> Result<ProductConfig, CliError> {
221    let mut cfg = ProductConfig::default();
222    for line in raw.lines() {
223        let line = line.trim();
224        if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
225            continue;
226        }
227        let Some((k, v)) = line.split_once('=') else {
228            continue;
229        };
230        let k = k.trim();
231        let v = v.trim().trim_matches('"').trim_matches('\'');
232        match k {
233            "lang" => cfg.lang = Some(v.to_string()),
234            "timeout" => cfg.timeout = v.parse().ok(),
235            "artifacts_dir" => cfg.artifacts_dir = Some(v.to_string()),
236            "ignore_robots" => cfg.ignore_robots = Some(v == "true" || v == "1"),
237            "namespace" => cfg.namespace = Some(v.to_string()),
238            "encryption_key" => cfg.encryption_key = Some(v.to_string()),
239            "color" => cfg.color = Some(v == "true" || v == "1"),
240            "log_level" => cfg.log_level = Some(v.to_string()),
241            "chrome_path" => cfg.chrome_path = Some(v.to_string()),
242            "lighthouse_path" => cfg.lighthouse_path = Some(v.to_string()),
243            "openrouter_api_key" => cfg.openrouter_api_key = Some(v.to_string()),
244            "llm_base_url" => cfg.llm_base_url = Some(v.to_string()),
245            "llm_model" => cfg.llm_model = Some(v.to_string()),
246            "log_to_file" => cfg.log_to_file = Some(v == "true" || v == "1"),
247            "cache_backend" => cfg.cache_backend = Some(v.to_string()),
248            "cache_redis_url" => cfg.cache_redis_url = Some(v.to_string()),
249            _ => {}
250        }
251    }
252    Ok(cfg)
253}
254
255/// Write config atomically (temp + rename).
256pub fn write_config(cfg: &ProductConfig) -> Result<PathBuf, CliError> {
257    let dir = config_dir()?;
258    ensure_dir(&dir)?;
259    let path = config_file()?;
260    let body = format!(
261        "# browser-automation-cli XDG config (no .env at runtime)\n\
262         # Managed by: browser-automation-cli config set|init\n\
263         lang = \"{lang}\"\n\
264         timeout = {timeout}\n\
265         artifacts_dir = \"{artifacts}\"\n\
266         ignore_robots = {ignore}\n\
267         namespace = \"{ns}\"\n\
268         encryption_key = \"{enc}\"\n\
269         color = {color}\n\
270         log_level = \"{log_level}\"\n\
271         log_to_file = {log_to_file}\n\
272         chrome_path = \"{chrome_path}\"\n\
273         lighthouse_path = \"{lighthouse_path}\"\n\
274         openrouter_api_key = \"{openrouter_api_key}\"\n\
275         llm_base_url = \"{llm_base_url}\"\n\
276         llm_model = \"{llm_model}\"\n\
277         cache_backend = \"{cache_backend}\"\n\
278         cache_redis_url = \"{cache_redis_url}\"\n",
279        lang = cfg.lang.as_deref().unwrap_or(""),
280        timeout = cfg.timeout.unwrap_or(0),
281        artifacts = cfg.artifacts_dir.as_deref().unwrap_or(""),
282        ignore = cfg.ignore_robots.unwrap_or(false),
283        ns = cfg.namespace.as_deref().unwrap_or(""),
284        enc = cfg.encryption_key.as_deref().unwrap_or(""),
285        color = cfg.color.unwrap_or(false),
286        log_level = cfg.log_level.as_deref().unwrap_or(""),
287        log_to_file = cfg.log_to_file.unwrap_or(false),
288        chrome_path = cfg.chrome_path.as_deref().unwrap_or(""),
289        lighthouse_path = cfg.lighthouse_path.as_deref().unwrap_or(""),
290        openrouter_api_key = cfg.openrouter_api_key.as_deref().unwrap_or(""),
291        llm_base_url = cfg.llm_base_url.as_deref().unwrap_or(""),
292        llm_model = cfg.llm_model.as_deref().unwrap_or(""),
293        cache_backend = cfg.cache_backend.as_deref().unwrap_or("sqlite"),
294        cache_redis_url = cfg.cache_redis_url.as_deref().unwrap_or(""),
295    );
296    let tmp = path.with_extension("toml.tmp");
297    {
298        let mut f = fs::File::create(&tmp)
299            .map_err(|e| CliError::new(ErrorKind::Io, format!("create temp config: {e}")))?;
300        f.write_all(body.as_bytes())
301            .map_err(|e| CliError::new(ErrorKind::Io, format!("write temp config: {e}")))?;
302        f.sync_all()
303            .map_err(|e| CliError::new(ErrorKind::Io, format!("fsync temp config: {e}")))?;
304    }
305    fs::rename(&tmp, &path)
306        .map_err(|e| CliError::new(ErrorKind::Io, format!("rename config into place: {e}")))?;
307    #[cfg(unix)]
308    {
309        use std::os::unix::fs::PermissionsExt;
310        let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
311    }
312    Ok(path)
313}
314
315/// Set one string key in config and persist.
316pub fn config_set(key: &str, value: &str) -> Result<Value, CliError> {
317    let mut cfg = load_config()?;
318    match key {
319        "lang" => cfg.lang = Some(value.to_string()),
320        "timeout" => {
321            cfg.timeout = Some(value.parse().map_err(|_| {
322                CliError::new(ErrorKind::Usage, "timeout must be an integer seconds")
323            })?);
324        }
325        "artifacts_dir" => cfg.artifacts_dir = Some(value.to_string()),
326        "ignore_robots" => {
327            cfg.ignore_robots = Some(matches!(value, "true" | "1" | "yes"));
328        }
329        "namespace" => cfg.namespace = Some(value.to_string()),
330        "encryption_key" => cfg.encryption_key = Some(value.to_string()),
331        "color" => {
332            cfg.color = Some(matches!(value, "true" | "1" | "yes"));
333        }
334        "log_level" => cfg.log_level = Some(value.to_string()),
335        "chrome_path" => cfg.chrome_path = Some(value.to_string()),
336        "lighthouse_path" => cfg.lighthouse_path = Some(value.to_string()),
337        "openrouter_api_key" => cfg.openrouter_api_key = Some(value.to_string()),
338        "llm_base_url" => cfg.llm_base_url = Some(value.to_string()),
339        "llm_model" => cfg.llm_model = Some(value.to_string()),
340        "log_to_file" => {
341            cfg.log_to_file = Some(matches!(value, "true" | "1" | "yes"));
342        }
343        "cache_backend" => cfg.cache_backend = Some(value.to_string()),
344        "cache_redis_url" => cfg.cache_redis_url = Some(value.to_string()),
345        other => {
346            return Err(CliError::with_suggestion(
347                ErrorKind::Usage,
348                format!("unknown config key: {other}"),
349                "Run: browser-automation-cli config list-keys",
350            ));
351        }
352    }
353    let path = write_config(&cfg)?;
354    Ok(json!({
355        "key": key,
356        "value": value,
357        "path": path.display().to_string(),
358    }))
359}
360
361/// Get one config key (or full dump when key is empty).
362pub fn config_get(key: Option<&str>) -> Result<Value, CliError> {
363    let cfg = load_config()?;
364    match key {
365        None | Some("") => Ok(json!({
366            "lang": cfg.lang,
367            "timeout": cfg.timeout,
368            "artifacts_dir": cfg.artifacts_dir,
369            "ignore_robots": cfg.ignore_robots,
370            "namespace": cfg.namespace,
371            "path": config_file()?.display().to_string(),
372        })),
373        Some("lang") => Ok(json!({ "key": "lang", "value": cfg.lang })),
374        Some("timeout") => Ok(json!({ "key": "timeout", "value": cfg.timeout })),
375        Some("artifacts_dir") => Ok(json!({ "key": "artifacts_dir", "value": cfg.artifacts_dir })),
376        Some("ignore_robots") => Ok(json!({ "key": "ignore_robots", "value": cfg.ignore_robots })),
377        Some("namespace") => Ok(json!({ "key": "namespace", "value": cfg.namespace })),
378        Some("log_level") => Ok(json!({ "key": "log_level", "value": cfg.log_level })),
379        Some("chrome_path") => Ok(json!({ "key": "chrome_path", "value": cfg.chrome_path })),
380        Some("lighthouse_path") => {
381            Ok(json!({ "key": "lighthouse_path", "value": cfg.lighthouse_path }))
382        }
383        Some("openrouter_api_key") => Ok(json!({
384            "key": "openrouter_api_key",
385            "value": if cfg.openrouter_api_key.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
386                "[set]"
387            } else {
388                ""
389            }
390        })),
391        Some("llm_base_url") => Ok(json!({ "key": "llm_base_url", "value": cfg.llm_base_url })),
392        Some("llm_model") => Ok(json!({ "key": "llm_model", "value": cfg.llm_model })),
393        Some("log_to_file") => Ok(json!({ "key": "log_to_file", "value": cfg.log_to_file })),
394        Some("cache_backend") => Ok(json!({ "key": "cache_backend", "value": cfg.cache_backend })),
395        Some("cache_redis_url") => Ok(json!({
396            "key": "cache_redis_url",
397            "value": if cfg.cache_redis_url.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
398                "[set]"
399            } else {
400                ""
401            }
402        })),
403        Some(other) => Err(CliError::new(
404            ErrorKind::Usage,
405            format!("unknown config key: {other}"),
406        )),
407    }
408}
409
410/// List supported XDG config keys (GAP-018).
411pub fn config_list_keys() -> Result<Value, CliError> {
412    Ok(json!({
413        "keys": [
414            {"key": "lang", "default": null, "description": "Message locale override (en|pt-BR)"},
415            {"key": "timeout", "default": 0, "description": "Global timeout seconds"},
416            {"key": "artifacts_dir", "default": null, "description": "Artifacts output directory"},
417            {"key": "ignore_robots", "default": false, "description": "Default robots ignore (flags still required)"},
418            {"key": "namespace", "default": null, "description": "Isolated state namespace"},
419            {"key": "encryption_key", "default": null, "description": "Session encryption key material"},
420            {"key": "color", "default": null, "description": "ANSI colors on human stderr"},
421            {"key": "log_level", "default": "error", "description": "Tracing filter when flags quiet"},
422            {"key": "log_to_file", "default": false, "description": "Rotated logs under XDG state"},
423            {"key": "chrome_path", "default": null, "description": "Absolute Chrome/Chromium path"},
424            {"key": "lighthouse_path", "default": null, "description": "Absolute lighthouse CLI path"},
425            {"key": "openrouter_api_key", "default": null, "description": "LLM API key (stored 0600)"},
426            {"key": "llm_base_url", "default": null, "description": "OpenAI-compatible base URL"},
427            {"key": "llm_model", "default": null, "description": "Default LLM model id"},
428            {"key": "cache_backend", "default": "sqlite", "description": "sqlite|memory|redis"},
429            {"key": "cache_redis_url", "default": null, "description": "Redis URL when backend=redis"},
430        ],
431        "path": config_file()?.display().to_string(),
432    }))
433}
434
435/// Load optional encryption key from XDG config only (never product env vars).
436pub fn encryption_key() -> Option<String> {
437    load_config()
438        .ok()
439        .and_then(|c| c.encryption_key)
440        .filter(|s| !s.is_empty())
441}
442
443/// Chrome/Chromium binary from XDG config only.
444pub fn chrome_path_from_config() -> Option<String> {
445    load_config()
446        .ok()
447        .and_then(|c| c.chrome_path)
448        .filter(|s| !s.is_empty())
449}
450
451/// Lighthouse binary path from XDG config only.
452pub fn lighthouse_path_from_config() -> Option<String> {
453    load_config()
454        .ok()
455        .and_then(|c| c.lighthouse_path)
456        .filter(|s| !s.is_empty())
457}
458
459/// Optional LLM API key from XDG config only (never product env vars).
460pub fn openrouter_api_key() -> Option<String> {
461    load_config()
462        .ok()
463        .and_then(|c| c.openrouter_api_key)
464        .filter(|s| !s.is_empty())
465}
466
467/// Optional LLM base URL from XDG config only.
468pub fn llm_base_url() -> Option<String> {
469    load_config()
470        .ok()
471        .and_then(|c| c.llm_base_url)
472        .filter(|s| !s.is_empty())
473}
474
475/// Optional LLM model id from XDG config only.
476pub fn llm_model() -> Option<String> {
477    load_config()
478        .ok()
479        .and_then(|c| c.llm_model)
480        .filter(|s| !s.is_empty())
481}
482
483/// JSON snapshot of all resolved paths (for `config path` / doctor).
484pub fn paths_snapshot() -> Result<Value, CliError> {
485    let home = BaseDirs::new().map(|b| b.home_dir().display().to_string());
486    let user_dirs = UserDirs::new().map(|u| u.home_dir().display().to_string());
487    Ok(json!({
488        "config_dir": config_dir()?.display().to_string(),
489        "data_dir": data_dir()?.display().to_string(),
490        "cache_dir": cache_dir()?.display().to_string(),
491        "state_dir": state_dir()?.display().to_string(),
492        "config_file": config_file()?.display().to_string(),
493        "browsers_dir": browsers_dir()?.display().to_string(),
494        "sessions_dir": sessions_dir()?.display().to_string(),
495        "workflow_dir": workflow_dir()?.display().to_string(),
496        "mitm_ca_dir": mitm_ca_dir()?.display().to_string(),
497        "mitm_capture_dir": mitm_capture_dir()?.display().to_string(),
498        "home_dir": home.or(user_dirs),
499        "layout": "xdg",
500    }))
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn project_dirs_resolve() {
509        assert!(project_dirs().is_ok());
510        assert!(config_dir().unwrap().components().count() > 1);
511    }
512}