diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-10 17:11:50 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-10 17:28:31 +0300 |
| commit | 3e8f9df14e6dd08a8d80d920b923870a80649f0b (patch) | |
| tree | ea854ca617d1ca21a09d1e7aa9246ffc5f6e842b /src | |
| parent | 66be0b655fe922867f1eed100a41d67038b6ffd6 (diff) | |
fix: improve config diagnostic delivery
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/mod.rs | 63 | ||||
| -rw-r--r-- | src/cli.rs | 28 | ||||
| -rw-r--r-- | src/cli/spec.rs | 1 | ||||
| -rw-r--r-- | src/config.rs | 12 | ||||
| -rw-r--r-- | src/config/io.rs | 66 | ||||
| -rw-r--r-- | src/ui.rs | 26 | ||||
| -rw-r--r-- | src/ui/status.rs | 2 |
7 files changed, 143 insertions, 55 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 51bdd33e..99181361 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1338,16 +1338,8 @@ impl App { // On `min > max`, treat the entire `[ui]` section as invalid: keep // the previous settings and skip the section so the re-clamp below // — and every subsequent render/drag — can never panic. - if crate::config::validated_sidebar_bounds( - config.ui.sidebar_min_width, - config.ui.sidebar_max_width, - ) - .is_none() - { - diagnostics.push(format!( - "ui.sidebar_min_width ({}) is greater than sidebar_max_width ({}); keeping previous [ui] settings", - config.ui.sidebar_min_width, config.ui.sidebar_max_width, - )); + if let Some(diagnostic) = config.invalid_sidebar_bounds_diagnostic() { + diagnostics.push(format!("{diagnostic}; keeping previous [ui] settings")); } else { diagnostics.extend(config.ui.sound.diagnostics()); @@ -2786,21 +2778,21 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.contains("sidebar_min_width") + && diagnostic.contains("sidebar_max_width") + && diagnostic.contains("greater") + })); assert_eq!(app.state.sidebar_min_width, original_min); assert_eq!(app.state.sidebar_max_width, original_max); assert_eq!( app.state.mouse_capture, original_mouse_capture, "[ui] is treated as invalid on bad bounds; mouse_capture must not apply" ); - assert!(app - .state - .config_diagnostic - .as_deref() - .is_some_and(|message| { - message.contains("sidebar_min_width") - && message.contains("sidebar_max_width") - && message.contains("greater") - })); + assert_eq!( + app.state.config_diagnostic.as_deref(), + Some("config.toml; herdr config check") + ); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); @@ -2823,6 +2815,9 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.contains("keys.new_workspace") && diagnostic.contains("disabling binding") + })); assert_eq!( (app.state.prefix_code, app.state.prefix_mods), original_prefix @@ -2832,14 +2827,6 @@ mod tests { app.state.toast_config.delivery, crate::config::ToastDelivery::Terminal ); - assert!(app - .state - .config_diagnostic - .as_deref() - .is_some_and(|message| { - message.contains("keys.new_workspace") && message.contains("disabling binding") - })); - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } @@ -2891,6 +2878,10 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("invalid ui config"))); assert!(app .state .keybinds @@ -2900,12 +2891,6 @@ mod tests { app.state.toast_config.delivery, crate::config::ToastDelivery::Herdr ); - assert!(app - .state - .config_diagnostic - .as_deref() - .is_some_and(|message| message.contains("invalid ui config"))); - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } @@ -2929,6 +2914,10 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("invalid terminal config"))); assert_eq!(app.state.default_shell, original_default_shell); assert_eq!(app.state.shell_mode, original_shell_mode); assert_eq!(app.state.new_terminal_cwd, original_new_cwd); @@ -2936,12 +2925,6 @@ mod tests { app.state.toast_config.delivery, crate::config::ToastDelivery::Terminal ); - assert!(app - .state - .config_diagnostic - .as_deref() - .is_some_and(|message| message.contains("invalid terminal config"))); - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } @@ -3047,7 +3030,7 @@ mod tests { .config_diagnostic .as_deref() .is_some_and(|message| { - message.contains("config parse error") && message.contains("keeping current config") + message.contains("config.toml:1:6") && message.contains("herdr config check") })); assert!(app.state.toast.is_none()); @@ -224,6 +224,7 @@ fn run_config_command(args: &[String]) -> std::io::Result<i32> { }; match subcommand { + "check" => config_check(&args[1..]), "reset-keys" => config_reset_keys(&args[1..]), "help" | "--help" | "-h" => { print_config_help(); @@ -236,6 +237,32 @@ fn run_config_command(args: &[String]) -> std::io::Result<i32> { } } +fn config_check(args: &[String]) -> std::io::Result<i32> { + match args { + [] => {} + [flag] if matches!(flag.as_str(), "help" | "--help" | "-h") => { + eprintln!("usage: herdr config check"); + return Ok(0); + } + _ => { + eprintln!("usage: herdr config check"); + return Ok(2); + } + } + + let diagnostics = crate::config::Config::load().diagnostics; + if diagnostics.is_empty() { + println!("config: ok"); + } else { + println!("config: issues found"); + for diagnostic in &diagnostics { + println!("{diagnostic}"); + } + } + + Ok(i32::from(!diagnostics.is_empty())) +} + fn config_reset_keys(args: &[String]) -> std::io::Result<i32> { if !args.is_empty() { eprintln!("usage: herdr config reset-keys"); @@ -1119,6 +1146,7 @@ fn print_session_error(code: &str, message: &str) { fn print_config_help() { eprintln!("herdr config commands:"); + eprintln!(" herdr config check validate config.toml and print diagnostics"); eprintln!(" herdr config reset-keys back up config.toml and remove custom keybindings"); } diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 088bc15e..4c68e3a5 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -88,6 +88,7 @@ fn status_command() -> Command { fn config_command() -> Command { Command::new("config") .about("Manage local configuration") + .subcommand(Command::new("check").about("Validate config.toml and print diagnostics")) .subcommand(Command::new("reset-keys").about("Reset custom keybindings")) } diff --git a/src/config.rs b/src/config.rs index f3dc9dbb..a2d3f89f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,9 +67,21 @@ impl Config { .chain(keybind_diags) .chain(self.remote_image_paste_key().err()) .chain(self.ui.sound.diagnostics()) + .chain(self.invalid_sidebar_bounds_diagnostic()) .collect() } + pub(crate) fn invalid_sidebar_bounds_diagnostic(&self) -> Option<String> { + validated_sidebar_bounds(self.ui.sidebar_min_width, self.ui.sidebar_max_width) + .is_none() + .then(|| { + format!( + "ui.sidebar_min_width ({}) is greater than sidebar_max_width ({})", + self.ui.sidebar_min_width, self.ui.sidebar_max_width + ) + }) + } + pub(crate) fn remote_image_paste_key(&self) -> Result<Option<(KeyCode, KeyModifiers)>, String> { let raw = self.keys.remote_image_paste.trim(); if raw.is_empty() { diff --git a/src/config/io.rs b/src/config/io.rs index f8c2eb90..6a4e83ce 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -154,22 +154,36 @@ pub fn config_path() -> PathBuf { } pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option<String> { - const MAX_VISIBLE_DIAGNOSTICS: usize = 4; - if diagnostics.is_empty() { return None; } - let mut lines: Vec<String> = diagnostics + let target = config_path() + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("config.toml") + .to_string(); + let location = diagnostics .iter() - .take(MAX_VISIBLE_DIAGNOSTICS) - .map(|diagnostic| diagnostic.split_whitespace().collect::<Vec<_>>().join(" ")) - .collect(); - let hidden = diagnostics.len().saturating_sub(MAX_VISIBLE_DIAGNOSTICS); - if hidden > 0 { - lines.push(format!("and {hidden} more config warnings")); - } - Some(lines.join("\n")) + .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")) +} + +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)) } pub fn load_live_config() -> Result<LoadedConfig, Vec<String>> { @@ -576,7 +590,7 @@ mod tests { } #[test] - fn config_diagnostic_summary_keeps_multiple_warnings_visible() { + fn config_diagnostic_summary_uses_compact_actionable_banner() { let diagnostics = vec![ "one".to_string(), "two".to_string(), @@ -587,7 +601,33 @@ mod tests { assert_eq!( config_diagnostic_summary(&diagnostics).as_deref(), - Some("one\ntwo\nthree\nfour\nand 1 more config warnings") + Some("config.toml; herdr config check") + ); + } + + #[test] + fn config_diagnostic_summary_includes_toml_error_location() { + 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(), + ]; + + assert_eq!( + config_diagnostic_summary(&diagnostics).as_deref(), + Some("config.toml:33:8; herdr config check") + ); + } + + #[test] + fn config_diagnostic_summary_finds_location_after_warning() { + let diagnostics = vec![ + "unknown config section [toast]; ignoring section".to_string(), + "config parse error: TOML parse error at line 7, column 4".to_string(), + ]; + + assert_eq!( + config_diagnostic_summary(&diagnostics).as_deref(), + Some("config.toml:7:4; herdr config check") ); } @@ -458,7 +458,12 @@ pub fn render_with_runtime_registry( fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect) { let has_config_diagnostic = app.config_diagnostic.is_some(); if let Some(message) = &app.config_diagnostic { - render_config_diagnostic(frame, terminal_area, message, &app.palette); + let diagnostic_area = if app.view.layout == ViewLayout::Mobile { + terminal_area + } else { + frame.area() + }; + render_config_diagnostic(frame, diagnostic_area, message, &app.palette); } let mut copy_feedback_offset = u16::from(has_config_diagnostic); let mut toast_rect = None; @@ -681,6 +686,25 @@ mod tests { } #[test] + fn mobile_config_diagnostic_keeps_command_visible() { + let mut app = crate::app::state::AppState::test_new(); + app.workspaces = vec![Workspace::test_new("one")]; + app.active = Some(0); + app.selected = 0; + app.mode = Mode::Terminal; + app.config_diagnostic = Some("config.toml:100:10; herdr config check".into()); + + let area = Rect::new(0, 0, 44, 20); + compute_view(&mut app, area); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + terminal.draw(|frame| render(&app, frame)).unwrap(); + let row = buffer_row_text(terminal.backend().buffer(), area, app.view.terminal_area.y); + + assert!(row.contains("config.toml:100:10"), "{row}"); + assert!(row.contains("herdr config check"), "{row}"); + } + + #[test] fn desktop_toast_hit_area_uses_full_frame_not_terminal_area() { let mut app = crate::app::state::AppState::test_new(); app.workspaces = vec![Workspace::test_new("one")]; diff --git a/src/ui/status.rs b/src/ui/status.rs index a49a53f6..ede38b85 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -179,7 +179,7 @@ pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: & .take(area.height as usize) .enumerate() { - let text = format!(" config warning: {line} "); + let text = format!(" {line} "); let width = (text.len() as u16).min(area.width); let notif_area = Rect::new( area.x + area.width.saturating_sub(width), |
