1use 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
22const QUALIFIER: &str = "cli";
24const ORGANIZATION: &str = "browser-automation";
26const APPLICATION: &str = "browser-automation-cli";
28
29pub 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
40pub fn config_dir() -> Result<PathBuf, CliError> {
42 Ok(project_dirs()?.config_dir().to_path_buf())
43}
44
45pub fn data_dir() -> Result<PathBuf, CliError> {
47 Ok(project_dirs()?.data_dir().to_path_buf())
48}
49
50pub fn cache_dir() -> Result<PathBuf, CliError> {
52 Ok(project_dirs()?.cache_dir().to_path_buf())
53}
54
55pub fn state_dir() -> Result<PathBuf, CliError> {
57 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
67pub fn browsers_dir() -> Result<PathBuf, CliError> {
69 Ok(cache_dir()?.join("browsers"))
70}
71
72pub fn sessions_dir() -> Result<PathBuf, CliError> {
74 Ok(state_dir()?.join("sessions"))
75}
76
77pub fn workflow_dir() -> Result<PathBuf, CliError> {
79 Ok(state_dir()?.join("workflows"))
80}
81
82pub fn mitm_ca_dir() -> Result<PathBuf, CliError> {
84 Ok(data_dir()?.join("mitm").join("ca"))
85}
86
87pub fn mitm_capture_dir() -> Result<PathBuf, CliError> {
89 Ok(state_dir()?.join("mitm"))
90}
91
92pub fn config_file() -> Result<PathBuf, CliError> {
94 Ok(config_dir()?.join("config.toml"))
95}
96
97pub 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
113pub 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
148pub struct ProductConfig {
149 #[serde(default)]
151 pub lang: Option<String>,
152 #[serde(default)]
154 pub timeout: Option<u64>,
155 #[serde(default)]
157 pub artifacts_dir: Option<String>,
158 #[serde(default)]
160 pub ignore_robots: Option<bool>,
161 #[serde(default)]
163 pub namespace: Option<String>,
164 #[serde(default)]
166 pub encryption_key: Option<String>,
167 #[serde(default)]
169 pub color: Option<bool>,
170 #[serde(default)]
172 pub log_level: Option<String>,
173 #[serde(default)]
175 pub chrome_path: Option<String>,
176 #[serde(default)]
178 pub lighthouse_path: Option<String>,
179 #[serde(default)]
181 pub openrouter_api_key: Option<String>,
182 #[serde(default)]
184 pub llm_base_url: Option<String>,
185 #[serde(default)]
187 pub llm_model: Option<String>,
188 #[serde(default)]
190 pub log_to_file: Option<bool>,
191 #[serde(default)]
193 pub cache_backend: Option<String>,
194 #[serde(default)]
196 pub cache_redis_url: Option<String>,
197}
198
199pub 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 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
255pub 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
315pub 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
361pub 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
410pub 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
435pub 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
443pub 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
451pub 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
459pub 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
467pub 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
475pub 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
483pub 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}