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}
171
172pub fn load_config() -> Result<ProductConfig, CliError> {
174 let path = config_file()?;
175 if !path.exists() {
176 return Ok(ProductConfig::default());
177 }
178 let raw = fs::read_to_string(&path).map_err(|e| {
179 CliError::new(
180 ErrorKind::Io,
181 format!("read config {}: {e}", path.display()),
182 )
183 })?;
184 if path.extension().and_then(|e| e.to_str()) == Some("json") {
187 return serde_json::from_str(&raw).map_err(|e| {
188 CliError::new(ErrorKind::Data, format!("invalid config JSON: {e}"))
189 });
190 }
191 parse_simple_toml(&raw)
192}
193
194fn parse_simple_toml(raw: &str) -> Result<ProductConfig, CliError> {
195 let mut cfg = ProductConfig::default();
196 for line in raw.lines() {
197 let line = line.trim();
198 if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
199 continue;
200 }
201 let Some((k, v)) = line.split_once('=') else {
202 continue;
203 };
204 let k = k.trim();
205 let v = v.trim().trim_matches('"').trim_matches('\'');
206 match k {
207 "lang" => cfg.lang = Some(v.to_string()),
208 "timeout" => cfg.timeout = v.parse().ok(),
209 "artifacts_dir" => cfg.artifacts_dir = Some(v.to_string()),
210 "ignore_robots" => cfg.ignore_robots = Some(v == "true" || v == "1"),
211 "namespace" => cfg.namespace = Some(v.to_string()),
212 "encryption_key" => cfg.encryption_key = Some(v.to_string()),
213 "color" => cfg.color = Some(v == "true" || v == "1"),
214 _ => {}
215 }
216 }
217 Ok(cfg)
218}
219
220pub fn write_config(cfg: &ProductConfig) -> Result<PathBuf, CliError> {
222 let dir = config_dir()?;
223 ensure_dir(&dir)?;
224 let path = config_file()?;
225 let body = format!(
226 "# browser-automation-cli XDG config (no .env at runtime)\n\
227 # Managed by: browser-automation-cli config set|init\n\
228 lang = \"{lang}\"\n\
229 timeout = {timeout}\n\
230 artifacts_dir = \"{artifacts}\"\n\
231 ignore_robots = {ignore}\n\
232 namespace = \"{ns}\"\n\
233 encryption_key = \"{enc}\"\n\
234 color = {color}\n",
235 lang = cfg.lang.as_deref().unwrap_or(""),
236 timeout = cfg.timeout.unwrap_or(0),
237 artifacts = cfg.artifacts_dir.as_deref().unwrap_or(""),
238 ignore = cfg.ignore_robots.unwrap_or(false),
239 ns = cfg.namespace.as_deref().unwrap_or(""),
240 enc = cfg.encryption_key.as_deref().unwrap_or(""),
241 color = cfg.color.unwrap_or(false),
242 );
243 let tmp = path.with_extension("toml.tmp");
244 {
245 let mut f = fs::File::create(&tmp).map_err(|e| {
246 CliError::new(ErrorKind::Io, format!("create temp config: {e}"))
247 })?;
248 f.write_all(body.as_bytes())
249 .map_err(|e| CliError::new(ErrorKind::Io, format!("write temp config: {e}")))?;
250 f.sync_all()
251 .map_err(|e| CliError::new(ErrorKind::Io, format!("fsync temp config: {e}")))?;
252 }
253 fs::rename(&tmp, &path).map_err(|e| {
254 CliError::new(
255 ErrorKind::Io,
256 format!("rename config into place: {e}"),
257 )
258 })?;
259 #[cfg(unix)]
260 {
261 use std::os::unix::fs::PermissionsExt;
262 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
263 }
264 Ok(path)
265}
266
267pub fn config_set(key: &str, value: &str) -> Result<Value, CliError> {
269 let mut cfg = load_config()?;
270 match key {
271 "lang" => cfg.lang = Some(value.to_string()),
272 "timeout" => {
273 cfg.timeout = Some(value.parse().map_err(|_| {
274 CliError::new(ErrorKind::Usage, "timeout must be an integer seconds")
275 })?);
276 }
277 "artifacts_dir" => cfg.artifacts_dir = Some(value.to_string()),
278 "ignore_robots" => {
279 cfg.ignore_robots = Some(matches!(value, "true" | "1" | "yes"));
280 }
281 "namespace" => cfg.namespace = Some(value.to_string()),
282 "encryption_key" => cfg.encryption_key = Some(value.to_string()),
283 "color" => {
284 cfg.color = Some(matches!(value, "true" | "1" | "yes"));
285 }
286 other => {
287 return Err(CliError::with_suggestion(
288 ErrorKind::Usage,
289 format!("unknown config key: {other}"),
290 "Supported keys: lang, timeout, artifacts_dir, ignore_robots, namespace, encryption_key, color",
291 ));
292 }
293 }
294 let path = write_config(&cfg)?;
295 Ok(json!({
296 "key": key,
297 "value": value,
298 "path": path.display().to_string(),
299 }))
300}
301
302pub fn config_get(key: Option<&str>) -> Result<Value, CliError> {
304 let cfg = load_config()?;
305 match key {
306 None | Some("") => Ok(json!({
307 "lang": cfg.lang,
308 "timeout": cfg.timeout,
309 "artifacts_dir": cfg.artifacts_dir,
310 "ignore_robots": cfg.ignore_robots,
311 "namespace": cfg.namespace,
312 "path": config_file()?.display().to_string(),
313 })),
314 Some("lang") => Ok(json!({ "key": "lang", "value": cfg.lang })),
315 Some("timeout") => Ok(json!({ "key": "timeout", "value": cfg.timeout })),
316 Some("artifacts_dir") => Ok(json!({ "key": "artifacts_dir", "value": cfg.artifacts_dir })),
317 Some("ignore_robots") => Ok(json!({ "key": "ignore_robots", "value": cfg.ignore_robots })),
318 Some("namespace") => Ok(json!({ "key": "namespace", "value": cfg.namespace })),
319 Some(other) => Err(CliError::new(
320 ErrorKind::Usage,
321 format!("unknown config key: {other}"),
322 )),
323 }
324}
325
326pub fn encryption_key() -> Option<String> {
328 load_config()
329 .ok()
330 .and_then(|c| c.encryption_key)
331 .filter(|s| !s.is_empty())
332}
333
334pub fn paths_snapshot() -> Result<Value, CliError> {
336 let home = BaseDirs::new().map(|b| b.home_dir().display().to_string());
337 let user_dirs = UserDirs::new().map(|u| u.home_dir().display().to_string());
338 Ok(json!({
339 "config_dir": config_dir()?.display().to_string(),
340 "data_dir": data_dir()?.display().to_string(),
341 "cache_dir": cache_dir()?.display().to_string(),
342 "state_dir": state_dir()?.display().to_string(),
343 "config_file": config_file()?.display().to_string(),
344 "browsers_dir": browsers_dir()?.display().to_string(),
345 "sessions_dir": sessions_dir()?.display().to_string(),
346 "workflow_dir": workflow_dir()?.display().to_string(),
347 "mitm_ca_dir": mitm_ca_dir()?.display().to_string(),
348 "mitm_capture_dir": mitm_capture_dir()?.display().to_string(),
349 "home_dir": home.or(user_dirs),
350 "layout": "xdg",
351 }))
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn project_dirs_resolve() {
360 assert!(project_dirs().is_ok());
361 assert!(config_dir().unwrap().components().count() > 1);
362 }
363}