aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-10 17:11:50 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-10 17:28:31 +0300
commit3e8f9df14e6dd08a8d80d920b923870a80649f0b (patch)
treeea854ca617d1ca21a09d1e7aa9246ffc5f6e842b /src
parent66be0b655fe922867f1eed100a41d67038b6ffd6 (diff)
fix: improve config diagnostic delivery
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs63
-rw-r--r--src/cli.rs28
-rw-r--r--src/cli/spec.rs1
-rw-r--r--src/config.rs12
-rw-r--r--src/config/io.rs66
-rw-r--r--src/ui.rs26
-rw-r--r--src/ui/status.rs2
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());
diff --git a/src/cli.rs b/src/cli.rs
index 58bd3a54..494ed14e 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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")
);
}
diff --git a/src/ui.rs b/src/ui.rs
index 129b4e67..dec8d7a9 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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),