1use 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
23const QUALIFIER: &str = "cli";
25const ORGANIZATION: &str = "browser-automation";
27const APPLICATION: &str = "browser-automation-cli";
29
30pub 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
41pub fn config_dir() -> Result<PathBuf, CliError> {
43 Ok(project_dirs()?.config_dir().to_path_buf())
44}
45
46pub fn data_dir() -> Result<PathBuf, CliError> {
48 Ok(project_dirs()?.data_dir().to_path_buf())
49}
50
51pub fn cache_dir() -> Result<PathBuf, CliError> {
53 Ok(project_dirs()?.cache_dir().to_path_buf())
54}
55
56pub fn state_dir() -> Result<PathBuf, CliError> {
58 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
68pub fn browsers_dir() -> Result<PathBuf, CliError> {
70 Ok(cache_dir()?.join("browsers"))
71}
72
73pub fn sessions_dir() -> Result<PathBuf, CliError> {
75 Ok(state_dir()?.join("sessions"))
76}
77
78pub fn workflow_dir() -> Result<PathBuf, CliError> {
80 Ok(state_dir()?.join("workflows"))
81}
82
83pub fn mitm_ca_dir() -> Result<PathBuf, CliError> {
85 Ok(data_dir()?.join("mitm").join("ca"))
86}
87
88pub fn mitm_capture_dir() -> Result<PathBuf, CliError> {
90 Ok(state_dir()?.join("mitm"))
91}
92
93pub fn config_file() -> Result<PathBuf, CliError> {
95 Ok(config_dir()?.join("config.toml"))
96}
97
98pub 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
114pub 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
149pub struct ProductConfig {
150 #[serde(default)]
152 pub lang: Option<String>,
153 #[serde(default)]
155 pub timeout: Option<u64>,
156 #[serde(default)]
158 pub artifacts_dir: Option<String>,
159 #[serde(default)]
161 pub ignore_robots: Option<bool>,
162 #[serde(default)]
164 pub namespace: Option<String>,
165 #[serde(default)]
167 pub encryption_key: Option<String>,
168 #[serde(default)]
170 pub color: Option<bool>,
171 #[serde(default)]
173 pub log_level: Option<String>,
174 #[serde(default)]
176 pub chrome_path: Option<String>,
177 #[serde(default)]
179 pub lighthouse_path: Option<String>,
180 #[serde(default)]
182 pub openrouter_api_key: Option<String>,
183 #[serde(default)]
185 pub llm_base_url: Option<String>,
186 #[serde(default)]
188 pub llm_model: Option<String>,
189 #[serde(default)]
191 pub log_to_file: Option<bool>,
192 #[serde(default)]
194 pub cache_backend: Option<String>,
195 #[serde(default)]
197 pub cache_redis_url: Option<String>,
198}
199
200pub fn load_config() -> Result<ProductConfig, CliError> {
202 let path = config_file()?;
203 if !path.exists() {
204 return Ok(ProductConfig::default());
205 }
206 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
262pub 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
322pub 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
368pub 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
417pub 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
442pub 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
450pub 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
458pub 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
466pub 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
474pub 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
482pub 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
490pub 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}