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}
189
190pub 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 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
243pub 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
297pub 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
338pub 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
377pub 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
385pub 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
393pub 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
401pub 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
409pub 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
417pub 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
425pub 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}