1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6const DEFAULT_CONFIG_TEMPLATE: &str = r#"# chrome-cli configuration file
8# See: https://github.com/Nunley-Media-Group/chrome-cli
9
10# Connection defaults
11# [connection]
12# host = "127.0.0.1"
13# port = 9222
14# timeout_ms = 30000
15
16# Chrome launch defaults
17# [launch]
18# executable = "/path/to/chrome"
19# channel = "stable" # stable, beta, dev, canary
20# headless = false
21# extra_args = ["--disable-gpu"]
22
23# Output defaults
24# [output]
25# format = "json" # json, pretty, plain
26
27# Default tab behavior
28# [tabs]
29# auto_activate = true
30# filter_internal = true
31"#;
32
33#[derive(Debug, Default, Clone, Deserialize, Serialize)]
39#[serde(default)]
40pub struct ConfigFile {
41 pub connection: ConnectionConfig,
42 pub launch: LaunchConfig,
43 pub output: OutputConfig,
44 pub tabs: TabsConfig,
45}
46
47#[derive(Debug, Default, Clone, Deserialize, Serialize)]
48#[serde(default)]
49pub struct ConnectionConfig {
50 pub host: Option<String>,
51 pub port: Option<u16>,
52 pub timeout_ms: Option<u64>,
53}
54
55#[derive(Debug, Default, Clone, Deserialize, Serialize)]
56#[serde(default)]
57pub struct LaunchConfig {
58 pub executable: Option<String>,
59 pub channel: Option<String>,
60 pub headless: Option<bool>,
61 pub extra_args: Option<Vec<String>>,
62}
63
64#[derive(Debug, Default, Clone, Deserialize, Serialize)]
65#[serde(default)]
66pub struct OutputConfig {
67 pub format: Option<String>,
68}
69
70#[derive(Debug, Default, Clone, Deserialize, Serialize)]
71#[serde(default)]
72pub struct TabsConfig {
73 pub auto_activate: Option<bool>,
74 pub filter_internal: Option<bool>,
75}
76
77#[derive(Debug, Serialize)]
83pub struct ResolvedConfig {
84 pub config_path: Option<PathBuf>,
85 pub connection: ResolvedConnection,
86 pub launch: ResolvedLaunch,
87 pub output: ResolvedOutput,
88 pub tabs: ResolvedTabs,
89}
90
91#[derive(Debug, Serialize)]
92pub struct ResolvedConnection {
93 pub host: String,
94 pub port: u16,
95 pub timeout_ms: u64,
96}
97
98#[derive(Debug, Serialize)]
99pub struct ResolvedLaunch {
100 pub executable: Option<String>,
101 pub channel: String,
102 pub headless: bool,
103 pub extra_args: Vec<String>,
104}
105
106#[derive(Debug, Serialize)]
107pub struct ResolvedOutput {
108 pub format: String,
109}
110
111#[derive(Debug, Serialize)]
112pub struct ResolvedTabs {
113 pub auto_activate: bool,
114 pub filter_internal: bool,
115}
116
117#[derive(Debug)]
122pub enum ConfigError {
123 Io(std::io::Error),
125 AlreadyExists(PathBuf),
127 NoConfigDir,
129}
130
131impl fmt::Display for ConfigError {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 match self {
134 Self::Io(e) => write!(f, "config file error: {e}"),
135 Self::AlreadyExists(p) => {
136 write!(f, "Config file already exists: {}", p.display())
137 }
138 Self::NoConfigDir => write!(f, "could not determine config directory"),
139 }
140 }
141}
142
143impl std::error::Error for ConfigError {
144 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
145 match self {
146 Self::Io(e) => Some(e),
147 _ => None,
148 }
149 }
150}
151
152impl From<std::io::Error> for ConfigError {
153 fn from(e: std::io::Error) -> Self {
154 Self::Io(e)
155 }
156}
157
158impl From<ConfigError> for crate::error::AppError {
159 fn from(e: ConfigError) -> Self {
160 use crate::error::ExitCode;
161 Self {
162 message: e.to_string(),
163 code: ExitCode::GeneralError,
164 custom_json: None,
165 }
166 }
167}
168
169#[must_use]
182pub fn find_config_file(explicit_path: Option<&Path>) -> Option<PathBuf> {
183 find_config_file_with(explicit_path, std::env::var("CHROME_CLI_CONFIG").ok())
184}
185
186#[must_use]
188pub fn find_config_file_with(
189 explicit_path: Option<&Path>,
190 env_config: Option<String>,
191) -> Option<PathBuf> {
192 if let Some(p) = explicit_path {
194 if p.exists() {
195 return Some(p.to_path_buf());
196 }
197 }
198
199 if let Some(env_path) = env_config {
201 let p = PathBuf::from(env_path);
202 if p.exists() {
203 return Some(p);
204 }
205 }
206
207 let local = PathBuf::from(".chrome-cli.toml");
209 if local.exists() {
210 return Some(local);
211 }
212
213 if let Some(config_dir) = dirs::config_dir() {
215 let xdg = config_dir.join("chrome-cli").join("config.toml");
216 if xdg.exists() {
217 return Some(xdg);
218 }
219 }
220
221 if let Some(home) = dirs::home_dir() {
223 let home_config = home.join(".chrome-cli.toml");
224 if home_config.exists() {
225 return Some(home_config);
226 }
227 }
228
229 None
230}
231
232#[must_use]
240pub fn load_config(explicit_path: Option<&Path>) -> (Option<PathBuf>, ConfigFile) {
241 let path = find_config_file(explicit_path);
242 match &path {
243 Some(p) => {
244 let config = load_config_from(p);
245 (path, config)
246 }
247 None => (None, ConfigFile::default()),
248 }
249}
250
251#[must_use]
255pub fn load_config_from(path: &Path) -> ConfigFile {
256 let contents = match std::fs::read_to_string(path) {
257 Ok(c) => c,
258 Err(e) => {
259 eprintln!(
260 "warning: could not read config file {}: {e}",
261 path.display()
262 );
263 return ConfigFile::default();
264 }
265 };
266
267 parse_config(&contents, path)
268}
269
270#[must_use]
275pub fn parse_config(contents: &str, path: &Path) -> ConfigFile {
276 match toml::from_str::<StrictConfigFile>(contents) {
278 Ok(strict) => strict.into(),
279 Err(strict_err) => {
280 match toml::from_str::<ConfigFile>(contents) {
282 Ok(config) => {
283 eprintln!(
285 "warning: unknown keys in config file {}: {strict_err}",
286 path.display()
287 );
288 config
289 }
290 Err(parse_err) => {
291 eprintln!(
293 "warning: could not parse config file {}: {parse_err}",
294 path.display()
295 );
296 ConfigFile::default()
297 }
298 }
299 }
300 }
301}
302
303#[derive(Deserialize)]
305#[serde(deny_unknown_fields)]
306struct StrictConfigFile {
307 #[serde(default)]
308 connection: StrictConnectionConfig,
309 #[serde(default)]
310 launch: StrictLaunchConfig,
311 #[serde(default)]
312 output: StrictOutputConfig,
313 #[serde(default)]
314 tabs: StrictTabsConfig,
315}
316
317#[derive(Default, Deserialize)]
318#[serde(deny_unknown_fields)]
319struct StrictConnectionConfig {
320 host: Option<String>,
321 port: Option<u16>,
322 timeout_ms: Option<u64>,
323}
324
325#[derive(Default, Deserialize)]
326#[serde(deny_unknown_fields)]
327struct StrictLaunchConfig {
328 executable: Option<String>,
329 channel: Option<String>,
330 headless: Option<bool>,
331 extra_args: Option<Vec<String>>,
332}
333
334#[derive(Default, Deserialize)]
335#[serde(deny_unknown_fields)]
336struct StrictOutputConfig {
337 format: Option<String>,
338}
339
340#[derive(Default, Deserialize)]
341#[serde(deny_unknown_fields)]
342struct StrictTabsConfig {
343 auto_activate: Option<bool>,
344 filter_internal: Option<bool>,
345}
346
347impl From<StrictConfigFile> for ConfigFile {
348 fn from(s: StrictConfigFile) -> Self {
349 Self {
350 connection: ConnectionConfig {
351 host: s.connection.host,
352 port: s.connection.port,
353 timeout_ms: s.connection.timeout_ms,
354 },
355 launch: LaunchConfig {
356 executable: s.launch.executable,
357 channel: s.launch.channel,
358 headless: s.launch.headless,
359 extra_args: s.launch.extra_args,
360 },
361 output: OutputConfig {
362 format: s.output.format,
363 },
364 tabs: TabsConfig {
365 auto_activate: s.tabs.auto_activate,
366 filter_internal: s.tabs.filter_internal,
367 },
368 }
369 }
370}
371
372const DEFAULT_PORT: u16 = 9222;
378const DEFAULT_TIMEOUT_MS: u64 = 30_000;
380
381#[must_use]
383pub fn resolve_config(file: &ConfigFile, config_path: Option<PathBuf>) -> ResolvedConfig {
384 let port = file.connection.port.unwrap_or(DEFAULT_PORT);
385 let port = if port == 0 { DEFAULT_PORT } else { port };
386
387 ResolvedConfig {
388 config_path,
389 connection: ResolvedConnection {
390 host: file
391 .connection
392 .host
393 .clone()
394 .unwrap_or_else(|| "127.0.0.1".to_string()),
395 port,
396 timeout_ms: file.connection.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS),
397 },
398 launch: ResolvedLaunch {
399 executable: file.launch.executable.clone(),
400 channel: file
401 .launch
402 .channel
403 .clone()
404 .unwrap_or_else(|| "stable".to_string()),
405 headless: file.launch.headless.unwrap_or(false),
406 extra_args: file.launch.extra_args.clone().unwrap_or_default(),
407 },
408 output: ResolvedOutput {
409 format: file
410 .output
411 .format
412 .clone()
413 .unwrap_or_else(|| "json".to_string()),
414 },
415 tabs: ResolvedTabs {
416 auto_activate: file.tabs.auto_activate.unwrap_or(true),
417 filter_internal: file.tabs.filter_internal.unwrap_or(true),
418 },
419 }
420}
421
422pub fn default_init_path() -> Result<PathBuf, ConfigError> {
432 dirs::config_dir()
433 .map(|d| d.join("chrome-cli").join("config.toml"))
434 .ok_or(ConfigError::NoConfigDir)
435}
436
437pub fn init_config(target_path: Option<&Path>) -> Result<PathBuf, ConfigError> {
445 let path = match target_path {
446 Some(p) => p.to_path_buf(),
447 None => default_init_path()?,
448 };
449
450 init_config_to(&path)
451}
452
453pub fn init_config_to(path: &Path) -> Result<PathBuf, ConfigError> {
460 if path.exists() {
461 return Err(ConfigError::AlreadyExists(path.to_path_buf()));
462 }
463
464 if let Some(parent) = path.parent() {
465 std::fs::create_dir_all(parent)?;
466 }
467
468 std::fs::write(path, DEFAULT_CONFIG_TEMPLATE)?;
469
470 #[cfg(unix)]
471 {
472 use std::os::unix::fs::PermissionsExt;
473 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
474 }
475
476 Ok(path.to_path_buf())
477}
478
479#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn parse_valid_full_config() {
489 let toml = r#"
490[connection]
491host = "10.0.0.1"
492port = 9333
493timeout_ms = 60000
494
495[launch]
496executable = "/usr/bin/chromium"
497channel = "beta"
498headless = true
499extra_args = ["--disable-gpu", "--no-sandbox"]
500
501[output]
502format = "pretty"
503
504[tabs]
505auto_activate = false
506filter_internal = false
507"#;
508 let config = parse_config(toml, Path::new("test.toml"));
509 assert_eq!(config.connection.host.as_deref(), Some("10.0.0.1"));
510 assert_eq!(config.connection.port, Some(9333));
511 assert_eq!(config.connection.timeout_ms, Some(60000));
512 assert_eq!(
513 config.launch.executable.as_deref(),
514 Some("/usr/bin/chromium")
515 );
516 assert_eq!(config.launch.channel.as_deref(), Some("beta"));
517 assert_eq!(config.launch.headless, Some(true));
518 assert_eq!(
519 config.launch.extra_args.as_deref(),
520 Some(&["--disable-gpu".to_string(), "--no-sandbox".to_string()][..])
521 );
522 assert_eq!(config.output.format.as_deref(), Some("pretty"));
523 assert_eq!(config.tabs.auto_activate, Some(false));
524 assert_eq!(config.tabs.filter_internal, Some(false));
525 }
526
527 #[test]
528 fn parse_empty_config() {
529 let config = parse_config("", Path::new("test.toml"));
530 assert!(config.connection.host.is_none());
531 assert!(config.connection.port.is_none());
532 assert!(config.launch.executable.is_none());
533 assert!(config.output.format.is_none());
534 assert!(config.tabs.auto_activate.is_none());
535 }
536
537 #[test]
538 fn parse_partial_config() {
539 let toml = "[connection]\nport = 9333\n";
540 let config = parse_config(toml, Path::new("test.toml"));
541 assert_eq!(config.connection.port, Some(9333));
542 assert!(config.connection.host.is_none());
543 assert!(config.launch.executable.is_none());
544 }
545
546 #[test]
547 fn parse_invalid_toml_returns_default() {
548 let config = parse_config("this is not valid toml [[[", Path::new("test.toml"));
549 assert!(config.connection.host.is_none());
550 assert!(config.connection.port.is_none());
551 }
552
553 #[test]
554 fn parse_unknown_keys_warns_but_keeps_known() {
555 let toml = r#"
556[connection]
557port = 9333
558unknown_key = "hello"
559"#;
560 let config = parse_config(toml, Path::new("test.toml"));
561 assert_eq!(config.connection.port, Some(9333));
562 }
563
564 #[test]
565 fn resolve_defaults() {
566 let config = ConfigFile::default();
567 let resolved = resolve_config(&config, None);
568 assert_eq!(resolved.connection.host, "127.0.0.1");
569 assert_eq!(resolved.connection.port, DEFAULT_PORT);
570 assert_eq!(resolved.connection.timeout_ms, DEFAULT_TIMEOUT_MS);
571 assert_eq!(resolved.launch.channel, "stable");
572 assert!(!resolved.launch.headless);
573 assert!(resolved.launch.extra_args.is_empty());
574 assert_eq!(resolved.output.format, "json");
575 assert!(resolved.tabs.auto_activate);
576 assert!(resolved.tabs.filter_internal);
577 assert!(resolved.config_path.is_none());
578 }
579
580 #[test]
581 fn resolve_overrides() {
582 let config = ConfigFile {
583 connection: ConnectionConfig {
584 host: Some("10.0.0.1".into()),
585 port: Some(9444),
586 timeout_ms: Some(5000),
587 },
588 launch: LaunchConfig {
589 executable: Some("/usr/bin/chromium".into()),
590 channel: Some("canary".into()),
591 headless: Some(true),
592 extra_args: Some(vec!["--no-sandbox".into()]),
593 },
594 output: OutputConfig {
595 format: Some("pretty".into()),
596 },
597 tabs: TabsConfig {
598 auto_activate: Some(false),
599 filter_internal: Some(false),
600 },
601 };
602 let path = PathBuf::from("/tmp/test.toml");
603 let resolved = resolve_config(&config, Some(path.clone()));
604 assert_eq!(resolved.connection.host, "10.0.0.1");
605 assert_eq!(resolved.connection.port, 9444);
606 assert_eq!(resolved.connection.timeout_ms, 5000);
607 assert_eq!(
608 resolved.launch.executable.as_deref(),
609 Some("/usr/bin/chromium")
610 );
611 assert_eq!(resolved.launch.channel, "canary");
612 assert!(resolved.launch.headless);
613 assert_eq!(resolved.launch.extra_args, vec!["--no-sandbox"]);
614 assert_eq!(resolved.output.format, "pretty");
615 assert!(!resolved.tabs.auto_activate);
616 assert!(!resolved.tabs.filter_internal);
617 assert_eq!(resolved.config_path, Some(path));
618 }
619
620 #[test]
621 fn resolve_port_zero_uses_default() {
622 let config = ConfigFile {
623 connection: ConnectionConfig {
624 port: Some(0),
625 ..ConnectionConfig::default()
626 },
627 ..ConfigFile::default()
628 };
629 let resolved = resolve_config(&config, None);
630 assert_eq!(resolved.connection.port, DEFAULT_PORT);
631 }
632
633 #[test]
634 fn init_config_creates_file() {
635 let dir = std::env::temp_dir().join("chrome-cli-test-config-init");
636 let _ = std::fs::remove_dir_all(&dir);
637 let path = dir.join("config.toml");
638
639 let result = init_config_to(&path);
640 assert!(result.is_ok());
641 assert!(path.exists());
642
643 let contents = std::fs::read_to_string(&path).unwrap();
644 assert!(contents.contains("[connection]"));
645 assert!(contents.contains("port = 9222"));
646
647 let _ = std::fs::remove_dir_all(&dir);
648 }
649
650 #[test]
651 fn init_config_refuses_overwrite() {
652 let dir = std::env::temp_dir().join("chrome-cli-test-config-overwrite");
653 let _ = std::fs::remove_dir_all(&dir);
654 std::fs::create_dir_all(&dir).unwrap();
655 let path = dir.join("config.toml");
656 std::fs::write(&path, "existing").unwrap();
657
658 let result = init_config_to(&path);
659 assert!(matches!(result, Err(ConfigError::AlreadyExists(_))));
660
661 let contents = std::fs::read_to_string(&path).unwrap();
663 assert_eq!(contents, "existing");
664
665 let _ = std::fs::remove_dir_all(&dir);
666 }
667
668 #[test]
669 fn find_config_with_explicit_path() {
670 let dir = std::env::temp_dir().join("chrome-cli-test-find-explicit");
671 let _ = std::fs::remove_dir_all(&dir);
672 std::fs::create_dir_all(&dir).unwrap();
673 let path = dir.join("my-config.toml");
674 std::fs::write(&path, "").unwrap();
675
676 let found = find_config_file_with(Some(&path), None);
677 assert_eq!(found, Some(path.clone()));
678
679 let _ = std::fs::remove_dir_all(&dir);
680 }
681
682 #[test]
683 fn find_config_with_env_var() {
684 let dir = std::env::temp_dir().join("chrome-cli-test-find-env");
685 let _ = std::fs::remove_dir_all(&dir);
686 std::fs::create_dir_all(&dir).unwrap();
687 let path = dir.join("env-config.toml");
688 std::fs::write(&path, "").unwrap();
689
690 let found = find_config_file_with(None, Some(path.to_string_lossy().into_owned()));
691 assert_eq!(found, Some(path.clone()));
692
693 let _ = std::fs::remove_dir_all(&dir);
694 }
695
696 #[test]
697 fn find_config_explicit_takes_priority_over_env() {
698 let dir = std::env::temp_dir().join("chrome-cli-test-find-priority");
699 let _ = std::fs::remove_dir_all(&dir);
700 std::fs::create_dir_all(&dir).unwrap();
701 let explicit = dir.join("explicit.toml");
702 let env = dir.join("env.toml");
703 std::fs::write(&explicit, "").unwrap();
704 std::fs::write(&env, "").unwrap();
705
706 let found =
707 find_config_file_with(Some(&explicit), Some(env.to_string_lossy().into_owned()));
708 assert_eq!(found, Some(explicit.clone()));
709
710 let _ = std::fs::remove_dir_all(&dir);
711 }
712
713 #[test]
714 fn find_config_nonexistent_returns_none() {
715 let found = find_config_file_with(
716 Some(Path::new("/nonexistent/path.toml")),
717 Some("/also/nonexistent.toml".into()),
718 );
719 if let Some(ref p) = found {
723 assert_ne!(p, &PathBuf::from("/nonexistent/path.toml"));
724 assert_ne!(p, &PathBuf::from("/also/nonexistent.toml"));
725 }
726 }
727
728 #[test]
729 fn load_config_from_nonexistent_returns_default() {
730 let config = load_config_from(Path::new("/nonexistent/config.toml"));
731 assert!(config.connection.host.is_none());
732 }
733
734 #[test]
735 fn config_error_display() {
736 assert!(
737 ConfigError::NoConfigDir
738 .to_string()
739 .contains("config directory")
740 );
741
742 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
743 assert!(ConfigError::Io(io_err).to_string().contains("denied"));
744
745 let path = PathBuf::from("/tmp/test.toml");
746 let msg = ConfigError::AlreadyExists(path).to_string();
747 assert!(msg.contains("already exists"));
748 assert!(msg.contains("/tmp/test.toml"));
749 }
750
751 #[test]
752 fn config_serializes_to_json() {
753 let config = ConfigFile::default();
754 let resolved = resolve_config(&config, None);
755 let json = serde_json::to_string(&resolved).unwrap();
756 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
757 assert_eq!(parsed["connection"]["port"], 9222);
758 assert_eq!(parsed["connection"]["host"], "127.0.0.1");
759 assert_eq!(parsed["output"]["format"], "json");
760 }
761}