diff --git a/src/linters/renovate_deps/mod.rs b/src/linters/renovate_deps/mod.rs index e7c171f..f358b91 100644 --- a/src/linters/renovate_deps/mod.rs +++ b/src/linters/renovate_deps/mod.rs @@ -577,12 +577,65 @@ async fn run_renovate( anyhow::bail!( "renovate exited with status {}: {}", out.status.code().unwrap_or(-1), - snippet.lines().take(20).collect::>().join("\n") + extract_failure_snippet(&snippet) ); } Ok(combined) } +/// Extracts the most informative slice of a Renovate log for an error message. +/// +/// Renovate emits one JSON log object per line with a numeric `level` field +/// (bunyan: 30=info, 40=warn, 50=error, 60=fatal). Startup is dominated by +/// debug/info lines, so taking the head of the log usually hides the actual +/// failure. Prefer level >= 40 entries; fall back to the tail of the log. +fn extract_failure_snippet(log: &str) -> String { + const MAX_LINES: usize = 20; + + let high_level: Vec = log + .lines() + .filter_map(|line| { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let level = value.get("level")?.as_u64()?; + if level < 40 { + return None; + } + let msg = value + .get("msg") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + let err = value + .get("err") + .and_then(|v| v.get("message")) + .and_then(|v| v.as_str()); + let mut out = format!("level={level}"); + if let Some(m) = msg { + out.push(' '); + out.push_str(m); + } + if let Some(e) = err { + out.push_str(if msg.is_some() { ": " } else { " " }); + out.push_str(e); + } + Some(out) + }) + .collect(); + + fn tail(items: I, n: usize) -> Vec { + let mut v: Vec = items.into_iter().collect(); + if v.len() > n { + v.drain(..v.len() - n); + } + v + } + + if high_level.is_empty() { + tail(log.lines(), MAX_LINES).join("\n") + } else { + tail(high_level.iter().map(String::as_str), MAX_LINES).join("\n") + } +} + fn resolve_renovate_config_path(project_root: &Path) -> anyhow::Result { RENOVATE_CONFIG_PATTERNS .iter() diff --git a/src/linters/renovate_deps/tests.rs b/src/linters/renovate_deps/tests.rs index 33ca0f8..aefbd8e 100644 --- a/src/linters/renovate_deps/tests.rs +++ b/src/linters/renovate_deps/tests.rs @@ -931,3 +931,37 @@ fn relevant_when_snapshot_is_unparsable() { dir.path() )); } + +#[test] +fn extract_failure_snippet_prefers_error_lines() { + let log = "\ +{\"level\":20,\"msg\":\"Parsing configs\"}\n\ +{\"level\":30,\"msg\":\"Renovate started\"}\n\ +{\"level\":50,\"msg\":\"Failed\",\"err\":{\"message\":\"boom\"}}\n\ +{\"level\":20,\"msg\":\"trailing debug\"}\n"; + let snippet = extract_failure_snippet(log); + assert_eq!(snippet, "level=50 Failed: boom"); +} + +#[test] +fn extract_failure_snippet_handles_missing_msg() { + let log = "\ +{\"level\":50,\"err\":{\"message\":\"boom\"}}\n\ +{\"level\":60,\"msg\":\"\",\"err\":{\"message\":\"fatal\"}}\n\ +{\"level\":40,\"msg\":\"warn only\"}\n"; + let snippet = extract_failure_snippet(log); + assert_eq!(snippet, "level=50 boom\nlevel=60 fatal\nlevel=40 warn only"); +} + +#[test] +fn extract_failure_snippet_falls_back_to_tail() { + let mut log = String::new(); + for i in 0..30 { + log.push_str(&format!("{{\"level\":20,\"msg\":\"line {i}\"}}\n")); + } + let snippet = extract_failure_snippet(&log); + let lines: Vec<&str> = snippet.lines().collect(); + assert_eq!(lines.len(), 20); + assert!(lines.last().unwrap().contains("line 29")); + assert!(lines.first().unwrap().contains("line 10")); +}