Skip to main content

browser_automation_cli/
xdg.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! XDG Base Directory layout for browser-automation-cli (no `.env` at runtime).
3//!
4//! Canonical product paths use the `directories` crate:
5//! - config: `$XDG_CONFIG_HOME/browser-automation-cli` (Linux)
6//! - data:   `$XDG_DATA_HOME/browser-automation-cli`
7//! - cache:  `$XDG_CACHE_HOME/browser-automation-cli`
8//! - state:  `$XDG_STATE_HOME/browser-automation-cli` (when available) or data/state
9//!
10//! Flags on the CLI override file config. Environment variables are **not** used for
11//! product settings; system paths (`PATH`, locale) remain OS concerns.
12
13use std::fs;
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use directories::{BaseDirs, ProjectDirs, UserDirs};
18use serde::{Deserialize, Serialize};
19use serde_json::{json, Value};
20
21use crate::error::{CliError, ErrorKind};
22
23/// Product qualifier for `ProjectDirs` (reversed DNS style, cross-platform).
24const QUALIFIER: &str = "cli";
25/// Organization segment.
26const ORGANIZATION: &str = "browser-automation";
27/// Application name (matches binary).
28const APPLICATION: &str = "browser-automation-cli";
29
30/// Resolve platform project directories or a deterministic fallback under the system temp dir.
31pub fn project_dirs() -> Result<ProjectDirs, CliError> {
32    ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION).ok_or_else(|| {
33        CliError::with_suggestion(
34            ErrorKind::Io,
35            "cannot resolve XDG project directories",
36            "Ensure the home directory is available for this user",
37        )
38    })
39}
40
41/// Config directory (`…/browser-automation-cli`).
42pub fn config_dir() -> Result<PathBuf, CliError> {
43    Ok(project_dirs()?.config_dir().to_path_buf())
44}
45
46/// Data directory (sessions, journals, durable artifacts).
47pub fn data_dir() -> Result<PathBuf, CliError> {
48    Ok(project_dirs()?.data_dir().to_path_buf())
49}
50
51/// Cache directory (lighthouse reports, HTTP scrape cache, browsers cache).
52pub fn cache_dir() -> Result<PathBuf, CliError> {
53    Ok(project_dirs()?.cache_dir().to_path_buf())
54}
55
56/// State directory (runtime state, workflow journal default).
57pub fn state_dir() -> Result<PathBuf, CliError> {
58    // `directories` 5.x exposes state_dir on Unix via ProjectDirs when available.
59    let pd = project_dirs()?;
60    #[allow(deprecated)]
61    let state = pd
62        .state_dir()
63        .map(|p| p.to_path_buf())
64        .unwrap_or_else(|| pd.data_dir().join("state"));
65    Ok(state)
66}
67
68/// Default browsers cache under XDG cache.
69pub fn browsers_dir() -> Result<PathBuf, CliError> {
70    Ok(cache_dir()?.join("browsers"))
71}
72
73/// Default sessions directory under XDG state.
74pub fn sessions_dir() -> Result<PathBuf, CliError> {
75    Ok(state_dir()?.join("sessions"))
76}
77
78/// Default workflow journal directory.
79pub fn workflow_dir() -> Result<PathBuf, CliError> {
80    Ok(state_dir()?.join("workflows"))
81}
82
83/// Default MITM CA directory.
84pub fn mitm_ca_dir() -> Result<PathBuf, CliError> {
85    Ok(data_dir()?.join("mitm").join("ca"))
86}
87
88/// Default MITM capture directory for the invocation artifacts.
89pub fn mitm_capture_dir() -> Result<PathBuf, CliError> {
90    Ok(state_dir()?.join("mitm"))
91}
92
93/// Path to the TOML config file.
94pub fn config_file() -> Result<PathBuf, CliError> {
95    Ok(config_dir()?.join("config.toml"))
96}
97
98/// Ensure a directory exists with restrictive permissions when possible.
99pub fn ensure_dir(path: &Path) -> Result<(), CliError> {
100    fs::create_dir_all(path).map_err(|e| {
101        CliError::new(
102            ErrorKind::Io,
103            format!("create directory {}: {e}", path.display()),
104        )
105    })?;
106    #[cfg(unix)]
107    {
108        use std::os::unix::fs::PermissionsExt;
109        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
110    }
111    Ok(())
112}
113
114/// Create all standard XDG product directories.
115pub fn init_layout() -> Result<Value, CliError> {
116    let cfg = config_dir()?;
117    let data = data_dir()?;
118    let cache = cache_dir()?;
119    let state = state_dir()?;
120    ensure_dir(&cfg)?;
121    ensure_dir(&data)?;
122    ensure_dir(&cache)?;
123    ensure_dir(&state)?;
124    ensure_dir(&browsers_dir()?)?;
125    ensure_dir(&sessions_dir()?)?;
126    ensure_dir(&workflow_dir()?)?;
127    ensure_dir(&mitm_ca_dir()?)?;
128    ensure_dir(&mitm_capture_dir()?)?;
129    let cfg_file = config_file()?;
130    if !cfg_file.exists() {
131        let default = ProductConfig::default();
132        write_config(&default)?;
133    }
134    Ok(json!({
135        "config_dir": cfg.display().to_string(),
136        "data_dir": data.display().to_string(),
137        "cache_dir": cache.display().to_string(),
138        "state_dir": state.display().to_string(),
139        "config_file": cfg_file.display().to_string(),
140        "browsers_dir": browsers_dir()?.display().to_string(),
141        "sessions_dir": sessions_dir()?.display().to_string(),
142        "workflow_dir": workflow_dir()?.display().to_string(),
143        "mitm_ca_dir": mitm_ca_dir()?.display().to_string(),
144    }))
145}
146
147/// On-disk product configuration (TOML). Flags override these at parse time.
148#[derive(Debug, Clone, Serialize, Deserialize, Default)]
149pub struct ProductConfig {
150    /// Default language override (`en` / `pt-BR`).
151    #[serde(default)]
152    pub lang: Option<String>,
153    /// Default global timeout seconds (0 = none).
154    #[serde(default)]
155    pub timeout: Option<u64>,
156    /// Default artifacts directory.
157    #[serde(default)]
158    pub artifacts_dir: Option<String>,
159    /// Default ignore-robots (requires explicit risk acceptance in flags still).
160    #[serde(default)]
161    pub ignore_robots: Option<bool>,
162    /// Namespace for isolated state trees (optional).
163    #[serde(default)]
164    pub namespace: Option<String>,
165    /// Optional AES key material for encrypted session state (stored in XDG config, mode 0600).
166    #[serde(default)]
167    pub encryption_key: Option<String>,
168    /// Enable ANSI colors on human stderr paths when true.
169    #[serde(default)]
170    pub color: Option<bool>,
171    /// Tracing filter level when flags are quiet/default (`error`/`info`/`debug`).
172    #[serde(default)]
173    pub log_level: Option<String>,
174    /// Absolute path to Chrome/Chromium binary (XDG only; never product env).
175    #[serde(default)]
176    pub chrome_path: Option<String>,
177    /// Absolute path to lighthouse CLI (optional).
178    #[serde(default)]
179    pub lighthouse_path: Option<String>,
180    /// Optional LLM provider API key for extract --llm (stored in XDG config mode 0600).
181    #[serde(default)]
182    pub openrouter_api_key: Option<String>,
183    /// OpenAI-compatible API base URL (no trailing slash).
184    #[serde(default)]
185    pub llm_base_url: Option<String>,
186    /// Default model id for extract --llm.
187    #[serde(default)]
188    pub llm_model: Option<String>,
189    /// When true, also write rotated local logs under XDG state (never remote telemetry).
190    #[serde(default)]
191    pub log_to_file: Option<bool>,
192    /// Cache backend: `sqlite` (default layered) | `memory` | `redis`.
193    #[serde(default)]
194    pub cache_backend: Option<String>,
195    /// Redis URL when cache_backend=redis (XDG only; never env).
196    #[serde(default)]
197    pub cache_redis_url: Option<String>,
198}
199
200/// Load config from XDG path; missing file yields defaults.
201pub fn load_config() -> Result<ProductConfig, CliError> {
202    let path = config_file()?;
203    if !path.exists() {
204        return Ok(ProductConfig::default());
205    }
206    // Minimal TOML subset via serde if we add toml; otherwise JSON fallback.
207    // Prefer JSON if file ends with .json — primary is TOML via line parse for keys we need.
208    // JSON path: BOM-aware + size-limited (RFC 8259 strict; no JSON5 on machine path).
209    if path.extension().and_then(|e| e.to_str()) == Some("json") {
210        return crate::json_util::read_json_file(&path, crate::json_util::MAX_JSON_FILE_BYTES)
211            .map_err(|e| {
212                CliError::new(
213                    ErrorKind::Data,
214                    format!("invalid config JSON: {}", e.message()),
215                )
216            });
217    }
218    let raw = fs::read_to_string(&path).map_err(|e| {
219        CliError::new(
220            ErrorKind::Io,
221            format!("read config {}: {e}", path.display()),
222        )
223    })?;
224    parse_simple_toml(&raw)
225}
226
227fn parse_simple_toml(raw: &str) -> Result<ProductConfig, CliError> {
228    let mut cfg = ProductConfig::default();
229    for line in raw.lines() {
230        let line = line.trim();
231        if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
232            continue;
233        }
234        let Some((k, v)) = line.split_once('=') else {
235            continue;
236        };
237        let k = k.trim();
238        let v = v.trim().trim_matches('"').trim_matches('\'');
239        match k {
240            "lang" => cfg.lang = Some(v.to_string()),
241            "timeout" => cfg.timeout = v.parse().ok(),
242            "artifacts_dir" => cfg.artifacts_dir = Some(v.to_string()),
243            "ignore_robots" => cfg.ignore_robots = Some(v == "true" || v == "1"),
244            "namespace" => cfg.namespace = Some(v.to_string()),
245            "encryption_key" => cfg.encryption_key = Some(v.to_string()),
246            "color" => cfg.color = Some(v == "true" || v == "1"),
247            "log_level" => cfg.log_level = Some(v.to_string()),
248            "chrome_path" => cfg.chrome_path = Some(v.to_string()),
249            "lighthouse_path" => cfg.lighthouse_path = Some(v.to_string()),
250            "openrouter_api_key" => cfg.openrouter_api_key = Some(v.to_string()),
251            "llm_base_url" => cfg.llm_base_url = Some(v.to_string()),
252            "llm_model" => cfg.llm_model = Some(v.to_string()),
253            "log_to_file" => cfg.log_to_file = Some(v == "true" || v == "1"),
254            "cache_backend" => cfg.cache_backend = Some(v.to_string()),
255            "cache_redis_url" => cfg.cache_redis_url = Some(v.to_string()),
256            _ => {}
257        }
258    }
259    Ok(cfg)
260}
261
262/// Write config atomically (temp + rename).
263pub fn write_config(cfg: &ProductConfig) -> Result<PathBuf, CliError> {
264    let dir = config_dir()?;
265    ensure_dir(&dir)?;
266    let path = config_file()?;
267    let body = format!(
268        "# browser-automation-cli XDG config (no .env at runtime)\n\
269         # Managed by: browser-automation-cli config set|init\n\
270         lang = \"{lang}\"\n\
271         timeout = {timeout}\n\
272         artifacts_dir = \"{artifacts}\"\n\
273         ignore_robots = {ignore}\n\
274         namespace = \"{ns}\"\n\
275         encryption_key = \"{enc}\"\n\
276         color = {color}\n\
277         log_level = \"{log_level}\"\n\
278         log_to_file = {log_to_file}\n\
279         chrome_path = \"{chrome_path}\"\n\
280         lighthouse_path = \"{lighthouse_path}\"\n\
281         openrouter_api_key = \"{openrouter_api_key}\"\n\
282         llm_base_url = \"{llm_base_url}\"\n\
283         llm_model = \"{llm_model}\"\n\
284         cache_backend = \"{cache_backend}\"\n\
285         cache_redis_url = \"{cache_redis_url}\"\n",
286        lang = cfg.lang.as_deref().unwrap_or(""),
287        timeout = cfg.timeout.unwrap_or(0),
288        artifacts = cfg.artifacts_dir.as_deref().unwrap_or(""),
289        ignore = cfg.ignore_robots.unwrap_or(false),
290        ns = cfg.namespace.as_deref().unwrap_or(""),
291        enc = cfg.encryption_key.as_deref().unwrap_or(""),
292        color = cfg.color.unwrap_or(false),
293        log_level = cfg.log_level.as_deref().unwrap_or(""),
294        log_to_file = cfg.log_to_file.unwrap_or(false),
295        chrome_path = cfg.chrome_path.as_deref().unwrap_or(""),
296        lighthouse_path = cfg.lighthouse_path.as_deref().unwrap_or(""),
297        openrouter_api_key = cfg.openrouter_api_key.as_deref().unwrap_or(""),
298        llm_base_url = cfg.llm_base_url.as_deref().unwrap_or(""),
299        llm_model = cfg.llm_model.as_deref().unwrap_or(""),
300        cache_backend = cfg.cache_backend.as_deref().unwrap_or("sqlite"),
301        cache_redis_url = cfg.cache_redis_url.as_deref().unwrap_or(""),
302    );
303    let tmp = path.with_extension("toml.tmp");
304    {
305        let mut f = fs::File::create(&tmp)
306            .map_err(|e| CliError::new(ErrorKind::Io, format!("create temp config: {e}")))?;
307        f.write_all(body.as_bytes())
308            .map_err(|e| CliError::new(ErrorKind::Io, format!("write temp config: {e}")))?;
309        f.sync_all()
310            .map_err(|e| CliError::new(ErrorKind::Io, format!("fsync temp config: {e}")))?;
311    }
312    fs::rename(&tmp, &path)
313        .map_err(|e| CliError::new(ErrorKind::Io, format!("rename config into place: {e}")))?;
314    #[cfg(unix)]
315    {
316        use std::os::unix::fs::PermissionsExt;
317        let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
318    }
319    Ok(path)
320}
321
322/// Set one string key in config and persist.
323pub fn config_set(key: &str, value: &str) -> Result<Value, CliError> {
324    let mut cfg = load_config()?;
325    match key {
326        "lang" => cfg.lang = Some(value.to_string()),
327        "timeout" => {
328            cfg.timeout = Some(value.parse().map_err(|_| {
329                CliError::new(ErrorKind::Usage, "timeout must be an integer seconds")
330            })?);
331        }
332        "artifacts_dir" => cfg.artifacts_dir = Some(value.to_string()),
333        "ignore_robots" => {
334            cfg.ignore_robots = Some(matches!(value, "true" | "1" | "yes"));
335        }
336        "namespace" => cfg.namespace = Some(value.to_string()),
337        "encryption_key" => cfg.encryption_key = Some(value.to_string()),
338        "color" => {
339            cfg.color = Some(matches!(value, "true" | "1" | "yes"));
340        }
341        "log_level" => cfg.log_level = Some(value.to_string()),
342        "chrome_path" => cfg.chrome_path = Some(value.to_string()),
343        "lighthouse_path" => cfg.lighthouse_path = Some(value.to_string()),
344        "openrouter_api_key" => cfg.openrouter_api_key = Some(value.to_string()),
345        "llm_base_url" => cfg.llm_base_url = Some(value.to_string()),
346        "llm_model" => cfg.llm_model = Some(value.to_string()),
347        "log_to_file" => {
348            cfg.log_to_file = Some(matches!(value, "true" | "1" | "yes"));
349        }
350        "cache_backend" => cfg.cache_backend = Some(value.to_string()),
351        "cache_redis_url" => cfg.cache_redis_url = Some(value.to_string()),
352        other => {
353            return Err(CliError::with_suggestion(
354                ErrorKind::Usage,
355                format!("unknown config key: {other}"),
356                "Run: browser-automation-cli config list-keys",
357            ));
358        }
359    }
360    let path = write_config(&cfg)?;
361    Ok(json!({
362        "key": key,
363        "value": value,
364        "path": path.display().to_string(),
365    }))
366}
367
368/// Get one config key (or full dump when key is empty).
369pub fn config_get(key: Option<&str>) -> Result<Value, CliError> {
370    let cfg = load_config()?;
371    match key {
372        None | Some("") => Ok(json!({
373            "lang": cfg.lang,
374            "timeout": cfg.timeout,
375            "artifacts_dir": cfg.artifacts_dir,
376            "ignore_robots": cfg.ignore_robots,
377            "namespace": cfg.namespace,
378            "path": config_file()?.display().to_string(),
379        })),
380        Some("lang") => Ok(json!({ "key": "lang", "value": cfg.lang })),
381        Some("timeout") => Ok(json!({ "key": "timeout", "value": cfg.timeout })),
382        Some("artifacts_dir") => Ok(json!({ "key": "artifacts_dir", "value": cfg.artifacts_dir })),
383        Some("ignore_robots") => Ok(json!({ "key": "ignore_robots", "value": cfg.ignore_robots })),
384        Some("namespace") => Ok(json!({ "key": "namespace", "value": cfg.namespace })),
385        Some("log_level") => Ok(json!({ "key": "log_level", "value": cfg.log_level })),
386        Some("chrome_path") => Ok(json!({ "key": "chrome_path", "value": cfg.chrome_path })),
387        Some("lighthouse_path") => {
388            Ok(json!({ "key": "lighthouse_path", "value": cfg.lighthouse_path }))
389        }
390        Some("openrouter_api_key") => Ok(json!({
391            "key": "openrouter_api_key",
392            "value": if cfg.openrouter_api_key.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
393                "[set]"
394            } else {
395                ""
396            }
397        })),
398        Some("llm_base_url") => Ok(json!({ "key": "llm_base_url", "value": cfg.llm_base_url })),
399        Some("llm_model") => Ok(json!({ "key": "llm_model", "value": cfg.llm_model })),
400        Some("log_to_file") => Ok(json!({ "key": "log_to_file", "value": cfg.log_to_file })),
401        Some("cache_backend") => Ok(json!({ "key": "cache_backend", "value": cfg.cache_backend })),
402        Some("cache_redis_url") => Ok(json!({
403            "key": "cache_redis_url",
404            "value": if cfg.cache_redis_url.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
405                "[set]"
406            } else {
407                ""
408            }
409        })),
410        Some(other) => Err(CliError::new(
411            ErrorKind::Usage,
412            format!("unknown config key: {other}"),
413        )),
414    }
415}
416
417/// List supported XDG config keys (GAP-018).
418pub fn config_list_keys() -> Result<Value, CliError> {
419    Ok(json!({
420        "keys": [
421            {"key": "lang", "default": null, "description": "Message locale override (en|pt-BR)"},
422            {"key": "timeout", "default": 0, "description": "Global timeout seconds"},
423            {"key": "artifacts_dir", "default": null, "description": "Artifacts output directory"},
424            {"key": "ignore_robots", "default": false, "description": "Default robots ignore (flags still required)"},
425            {"key": "namespace", "default": null, "description": "Isolated state namespace"},
426            {"key": "encryption_key", "default": null, "description": "Session encryption key material"},
427            {"key": "color", "default": null, "description": "ANSI colors on human stderr"},
428            {"key": "log_level", "default": "error", "description": "Tracing filter when flags quiet"},
429            {"key": "log_to_file", "default": false, "description": "Rotated logs under XDG state"},
430            {"key": "chrome_path", "default": null, "description": "Absolute Chrome/Chromium path"},
431            {"key": "lighthouse_path", "default": null, "description": "Absolute lighthouse CLI path"},
432            {"key": "openrouter_api_key", "default": null, "description": "LLM API key (stored 0600)"},
433            {"key": "llm_base_url", "default": null, "description": "OpenAI-compatible base URL"},
434            {"key": "llm_model", "default": null, "description": "Default LLM model id"},
435            {"key": "cache_backend", "default": "sqlite", "description": "sqlite|memory|redis"},
436            {"key": "cache_redis_url", "default": null, "description": "Redis URL when backend=redis"},
437        ],
438        "path": config_file()?.display().to_string(),
439    }))
440}
441
442/// Load optional encryption key from XDG config only (never product env vars).
443pub fn encryption_key() -> Option<String> {
444    load_config()
445        .ok()
446        .and_then(|c| c.encryption_key)
447        .filter(|s| !s.is_empty())
448}
449
450/// Chrome/Chromium binary from XDG config only.
451pub fn chrome_path_from_config() -> Option<String> {
452    load_config()
453        .ok()
454        .and_then(|c| c.chrome_path)
455        .filter(|s| !s.is_empty())
456}
457
458/// Lighthouse binary path from XDG config only.
459pub fn lighthouse_path_from_config() -> Option<String> {
460    load_config()
461        .ok()
462        .and_then(|c| c.lighthouse_path)
463        .filter(|s| !s.is_empty())
464}
465
466/// Optional LLM API key from XDG config only (never product env vars).
467pub fn openrouter_api_key() -> Option<String> {
468    load_config()
469        .ok()
470        .and_then(|c| c.openrouter_api_key)
471        .filter(|s| !s.is_empty())
472}
473
474/// Optional LLM base URL from XDG config only.
475pub fn llm_base_url() -> Option<String> {
476    load_config()
477        .ok()
478        .and_then(|c| c.llm_base_url)
479        .filter(|s| !s.is_empty())
480}
481
482/// Optional LLM model id from XDG config only.
483pub fn llm_model() -> Option<String> {
484    load_config()
485        .ok()
486        .and_then(|c| c.llm_model)
487        .filter(|s| !s.is_empty())
488}
489
490/// JSON snapshot of all resolved paths (for `config path` / doctor).
491pub fn paths_snapshot() -> Result<Value, CliError> {
492    let home = BaseDirs::new().map(|b| b.home_dir().display().to_string());
493    let user_dirs = UserDirs::new().map(|u| u.home_dir().display().to_string());
494    Ok(json!({
495        "config_dir": config_dir()?.display().to_string(),
496        "data_dir": data_dir()?.display().to_string(),
497        "cache_dir": cache_dir()?.display().to_string(),
498        "state_dir": state_dir()?.display().to_string(),
499        "config_file": config_file()?.display().to_string(),
500        "browsers_dir": browsers_dir()?.display().to_string(),
501        "sessions_dir": sessions_dir()?.display().to_string(),
502        "workflow_dir": workflow_dir()?.display().to_string(),
503        "mitm_ca_dir": mitm_ca_dir()?.display().to_string(),
504        "mitm_capture_dir": mitm_capture_dir()?.display().to_string(),
505        "home_dir": home.or(user_dirs),
506        "layout": "xdg",
507    }))
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn project_dirs_resolve() {
516        assert!(project_dirs().is_ok());
517        assert!(config_dir().unwrap().components().count() > 1);
518    }
519}