diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-12 03:58:17 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-12 03:58:27 +0300 |
| commit | a6905364b69ff997c40d22c25e489d2e49b9c272 (patch) | |
| tree | 2c788574160087dcc1024b8617d1ddba23e72b3a /src/config | |
| parent | 749e85e0fb5443488c5a96752afa7dd6cb040143 (diff) | |
fix: clarify config fallback diagnostics
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/io.rs | 198 |
1 files changed, 129 insertions, 69 deletions
diff --git a/src/config/io.rs b/src/config/io.rs index 6a4e83ce..c81b6e08 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -92,45 +92,54 @@ fn platform_state_dir() -> PathBuf { } } +fn read_optional_config(path: &Path) -> std::io::Result<Option<String>> { + match std::fs::read_to_string(path) { + Ok(content) => Ok(Some(content)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + impl Config { pub fn load() -> LoadedConfig { let path = config_path(); - if path.exists() { - match std::fs::read_to_string(&path) { - Ok(content) => match toml::from_str::<Config>(&content) { - Ok(config) => { - let mut diagnostics = - unknown_top_level_section_diagnostics_from_str(&content); - diagnostics.extend(config.collect_diagnostics()); - return LoadedConfig { - config, - diagnostics, - invalid_sections: Vec::new(), - }; - } - Err(err) => { - warn!(err = %err, "config parse error, using defaults"); - return LoadedConfig { - config: Self::default(), - diagnostics: vec![format!("config parse error: {err}; using defaults")], - invalid_sections: Vec::new(), - }; - } - }, - Err(err) => { - warn!(err = %err, "config read error, using defaults"); - return LoadedConfig { - config: Self::default(), - diagnostics: vec![format!("config read error: {err}; using defaults")], - invalid_sections: Vec::new(), - }; + let content = match read_optional_config(&path) { + Ok(Some(content)) => content, + Ok(None) => { + return LoadedConfig { + config: Self::default(), + diagnostics: Vec::new(), + invalid_sections: Vec::new(), + }; + } + Err(err) => { + warn!(err = %err, "config read error, using defaults"); + return LoadedConfig { + config: Self::default(), + diagnostics: vec![format!("config read error: {err}; using defaults")], + invalid_sections: Vec::new(), + }; + } + }; + + match toml::from_str::<Config>(&content) { + Ok(config) => { + let mut diagnostics = unknown_top_level_section_diagnostics_from_str(&content); + diagnostics.extend(config.collect_diagnostics()); + LoadedConfig { + config, + diagnostics, + invalid_sections: Vec::new(), + } + } + Err(err) => { + warn!(err = %err, "config parse error, using defaults"); + LoadedConfig { + config: Self::default(), + diagnostics: vec![format!("config parse error: {err}; using defaults")], + invalid_sections: Vec::new(), } } - } - LoadedConfig { - config: Self::default(), - diagnostics: Vec::new(), - invalid_sections: Vec::new(), } } } @@ -163,41 +172,51 @@ pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option<String> { .and_then(|name| name.to_str()) .unwrap_or("config.toml") .to_string(); - let location = diagnostics + let read_error = diagnostics .iter() - .find_map(|diagnostic| toml_error_location(diagnostic)) - .map(|(line, column)| format!("{target}:{line}:{column}")) - .unwrap_or_else(|| target.to_string()); - - Some(format!("{location}; herdr config check")) -} + .any(|diagnostic| diagnostic.starts_with("config read error:")); + let impact = if diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("using defaults")) + { + if read_error { + " unreadable; using defaults" + } else { + " invalid; using defaults" + } + } else if diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("keeping current config")) + { + if read_error { + " unreadable; keeping current config" + } else { + " invalid; keeping current config" + } + } else { + "" + }; -fn toml_error_location(diagnostic: &str) -> Option<(usize, usize)> { - let marker = "TOML parse error at line "; - let start = diagnostic.find(marker)? + marker.len(); - let rest = &diagnostic[start..]; - let (line, rest) = rest.split_once(", column ")?; - let column = rest - .split(|c: char| !c.is_ascii_digit()) - .next() - .unwrap_or_default(); - let line = line.parse().ok()?; - let column = column.parse().ok()?; - Some((line, column)) + Some(format!("{target}{impact}; herdr config check")) } pub fn load_live_config() -> Result<LoadedConfig, Vec<String>> { let path = config_path(); - if !path.exists() { - return Ok(LoadedConfig { - config: Config::default(), - diagnostics: Vec::new(), - invalid_sections: Vec::new(), - }); - } - - let content = std::fs::read_to_string(&path) - .map_err(|err| vec![format!("config read error: {err}; keeping current config")])?; + let content = match read_optional_config(&path) { + Ok(Some(content)) => content, + Ok(None) => { + return Ok(LoadedConfig { + config: Config::default(), + diagnostics: Vec::new(), + invalid_sections: Vec::new(), + }); + } + Err(err) => { + return Err(vec![format!( + "config read error: {err}; keeping current config" + )]); + } + }; load_live_config_from_str(&content) } @@ -606,7 +625,7 @@ mod tests { } #[test] - fn config_diagnostic_summary_includes_toml_error_location() { + fn config_diagnostic_summary_reports_default_fallback() { let diagnostics = vec![ "config parse error: TOML parse error at line 33, column 8\n |\n33 | type = \"popup\"\n | ^^^^^^^\nunknown variant `popup`; using defaults" .to_string(), @@ -614,24 +633,65 @@ mod tests { assert_eq!( config_diagnostic_summary(&diagnostics).as_deref(), - Some("config.toml:33:8; herdr config check") + Some("config.toml invalid; using defaults; herdr config check") + ); + } + + #[test] + fn config_diagnostic_summary_reports_unreadable_config_impact() { + let startup = vec!["config read error: permission denied; using defaults".to_string()]; + assert_eq!( + config_diagnostic_summary(&startup).as_deref(), + Some("config.toml unreadable; using defaults; herdr config check") + ); + + let reload = + vec!["config read error: permission denied; keeping current config".to_string()]; + assert_eq!( + config_diagnostic_summary(&reload).as_deref(), + Some("config.toml unreadable; keeping current config; herdr config check") ); } #[test] - fn config_diagnostic_summary_finds_location_after_warning() { + fn config_diagnostic_summary_reports_retained_live_config() { let diagnostics = vec![ - "unknown config section [toast]; ignoring section".to_string(), - "config parse error: TOML parse error at line 7, column 4".to_string(), + "config parse error: TOML parse error at line 7, column 4; keeping current config" + .to_string(), ]; assert_eq!( config_diagnostic_summary(&diagnostics).as_deref(), - Some("config.toml:7:4; herdr config check") + Some("config.toml invalid; keeping current config; herdr config check") ); } #[test] + fn config_loaders_report_unreadable_path() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let path = + std::env::temp_dir().join(format!("herdr-config-unreadable-{}", std::process::id())); + std::fs::create_dir_all(&path).unwrap(); + std::env::set_var(CONFIG_PATH_ENV_VAR, &path); + + let startup = Config::load(); + assert!(startup + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("config read error") + && diagnostic.contains("using defaults"))); + + let reload = load_live_config().unwrap_err(); + assert!(reload.iter().any(|diagnostic| { + diagnostic.contains("config read error") + && diagnostic.contains("keeping current config") + })); + + std::env::remove_var(CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path); + } + + #[test] fn load_live_config_parses_session_section() { let loaded = load_live_config_from_str( r#" |
