Skip to main content

chrome_cli/
config.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6/// Default config file template with comments, used by `config init`.
7const 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// ---------------------------------------------------------------------------
34// Config structs (parsed from TOML)
35// ---------------------------------------------------------------------------
36
37/// Represents the parsed TOML config file. All fields optional.
38#[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// ---------------------------------------------------------------------------
78// Resolved config (all defaults filled in)
79// ---------------------------------------------------------------------------
80
81/// Fully resolved configuration with all defaults filled in.
82#[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// ---------------------------------------------------------------------------
118// Error type
119// ---------------------------------------------------------------------------
120
121#[derive(Debug)]
122pub enum ConfigError {
123    /// I/O error reading/writing config file.
124    Io(std::io::Error),
125    /// Config file already exists (for `config init`).
126    AlreadyExists(PathBuf),
127    /// Could not determine config directory.
128    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// ---------------------------------------------------------------------------
170// Config file search
171// ---------------------------------------------------------------------------
172
173/// Find the first config file that exists, checking locations in priority order.
174///
175/// Search order:
176/// 1. `explicit_path` (from `--config` flag)
177/// 2. `$CHROME_CLI_CONFIG` environment variable
178/// 3. `./.chrome-cli.toml` (project-local)
179/// 4. `<config_dir>/chrome-cli/config.toml` (XDG / platform config dir)
180/// 5. `~/.chrome-cli.toml` (home directory fallback)
181#[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/// Testable variant of [`find_config_file`] that accepts an explicit env value.
187#[must_use]
188pub fn find_config_file_with(
189    explicit_path: Option<&Path>,
190    env_config: Option<String>,
191) -> Option<PathBuf> {
192    // 1. Explicit --config path
193    if let Some(p) = explicit_path {
194        if p.exists() {
195            return Some(p.to_path_buf());
196        }
197    }
198
199    // 2. $CHROME_CLI_CONFIG
200    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    // 3. ./.chrome-cli.toml (project-local)
208    let local = PathBuf::from(".chrome-cli.toml");
209    if local.exists() {
210        return Some(local);
211    }
212
213    // 4. XDG / platform config dir
214    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    // 5. ~/.chrome-cli.toml
222    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// ---------------------------------------------------------------------------
233// Config loading
234// ---------------------------------------------------------------------------
235
236/// Load and parse a config file. Returns the file path (if found) and the parsed config.
237///
238/// On parse errors, prints a warning to stderr and returns `ConfigFile::default()`.
239#[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/// Load and parse a config file from a specific path.
252///
253/// On parse errors, prints a warning to stderr and returns `ConfigFile::default()`.
254#[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/// Parse TOML content into a `ConfigFile`.
271///
272/// Uses a two-pass strategy: first tries strict parsing (to detect unknown keys),
273/// then falls back to lenient parsing if strict fails due to unknown fields.
274#[must_use]
275pub fn parse_config(contents: &str, path: &Path) -> ConfigFile {
276    // First pass: strict (deny_unknown_fields via a wrapper)
277    match toml::from_str::<StrictConfigFile>(contents) {
278        Ok(strict) => strict.into(),
279        Err(strict_err) => {
280            // Second pass: lenient
281            match toml::from_str::<ConfigFile>(contents) {
282                Ok(config) => {
283                    // Strict failed but lenient succeeded → unknown keys
284                    eprintln!(
285                        "warning: unknown keys in config file {}: {strict_err}",
286                        path.display()
287                    );
288                    config
289                }
290                Err(parse_err) => {
291                    // Both failed → invalid TOML
292                    eprintln!(
293                        "warning: could not parse config file {}: {parse_err}",
294                        path.display()
295                    );
296                    ConfigFile::default()
297                }
298            }
299        }
300    }
301}
302
303/// Strict variant used for the first-pass parse to detect unknown keys.
304#[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
372// ---------------------------------------------------------------------------
373// Config resolution
374// ---------------------------------------------------------------------------
375
376/// Default port for CDP connections.
377const DEFAULT_PORT: u16 = 9222;
378/// Default timeout for commands in milliseconds.
379const DEFAULT_TIMEOUT_MS: u64 = 30_000;
380
381/// Resolve a config file into a fully-populated `ResolvedConfig` with all defaults.
382#[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
422// ---------------------------------------------------------------------------
423// Config init
424// ---------------------------------------------------------------------------
425
426/// Default path for `config init`: `<config_dir>/chrome-cli/config.toml`.
427///
428/// # Errors
429///
430/// Returns `ConfigError::NoConfigDir` if the platform config directory cannot be determined.
431pub 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
437/// Create a default config file at the given path (or the default XDG path).
438///
439/// # Errors
440///
441/// - `ConfigError::AlreadyExists` if the file already exists
442/// - `ConfigError::Io` on I/O failure
443/// - `ConfigError::NoConfigDir` if no target path and platform config dir unknown
444pub 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
453/// Testable variant of [`init_config`] that writes to an explicit path.
454///
455/// # Errors
456///
457/// - `ConfigError::AlreadyExists` if the file already exists
458/// - `ConfigError::Io` on I/O failure
459pub 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// ---------------------------------------------------------------------------
480// Unit tests
481// ---------------------------------------------------------------------------
482
483#[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        // Verify original content not overwritten
662        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        // May or may not find a config from project-local / home — but explicit and env should fail.
720        // We can't guarantee None here due to project-local or home checks, so just verify
721        // the explicit and env paths didn't match.
722        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}