Files
MercuryToolbox/crates/diagpick/src/lib.rs
T

1187 lines
40 KiB
Rust

//! The `diagpick` command extracts diagnostics from logs.
use codeindex::{CodeIndexer, IndexedSymbol, detect_language, enclosing_symbol};
use std::collections::{HashMap, hash_map::Entry};
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, BufRead, Read};
use std::path::{Path, PathBuf};
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice,
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
should_read_stdin,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use serde::Serialize;
use serde_json::Value;
const HELP: &str = "\
Extract actionable diagnostics from noisy logs.
Usage:
diagpick [OPTIONS] [PATH...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--with-source Attach source context when the file exists
--with-snippet Alias for --with-source
--snip Attach source context when the file exists
--with-definition Alias for --def
--def Attach the enclosing definition when codeindex supports the file
--jsonl Alias for --input-format jsonl
--context <COUNT> Source context lines above and below the hit
--limit <COUNT> Optional maximum number of diagnostics to emit
--severity <FILTER> Restrict output to all, error, warning, or note
--allow-empty Exit 0 when filters leave no diagnostics
-h, --help Show this help text
-V, --version Show the command version
Examples:
diagpick .\\fixtures\\diag\\rust-errors.txt
bat --style=plain --paging=never .\\fixtures\\diag\\unity-errors.txt | diagpick --snip --json | ConvertFrom-Json
diagpick .\\fixtures\\diag\\rust-errors.txt --def
diagpick .\\fixtures\\diag\\unity-errors.txt --severity error
diagpick .\\fixtures\\diag\\unity-errors.txt --severity warning
diagpick .\\fixtures\\diag\\unity-errors.txt --severity note --allow-empty
Notes:
For Unity Player.log or BepInEx LogOutput.log incident grouping, prefer unitydiag; diagpick is best for compiler-style file:line diagnostics.
";
/// CLI arguments for the `diagpick` binary.
#[derive(Debug, Clone)]
struct Cli {
/// Shared output and stdin policy flags.
common: CommonArgs,
/// Attach source context when the referenced file exists.
with_snippet: bool,
/// Attach the enclosing definition when the file can be indexed.
with_definition: bool,
/// Source context lines above and below the diagnostic line.
context: usize,
/// Optional maximum number of diagnostics to emit.
limit: Option<usize>,
/// Restrict output to one severity.
severity: SeverityFilter,
/// Exit successfully when the filtered diagnostic set is empty.
allow_empty: bool,
/// Log files to read when stdin is empty.
paths: Vec<PathBuf>,
}
/// Diagnostic severity filter values.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SeverityFilter {
/// Return diagnostics of any severity.
All,
/// Return only errors.
Error,
/// Return only warnings.
Warning,
/// Return only notes.
Note,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum Severity {
Error,
Warning,
Note,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct SourceLine {
number: usize,
text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct SourceSnippet {
start_line: usize,
end_line: usize,
lines: Vec<SourceLine>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct Diagnostic {
path: String,
line: usize,
column: usize,
severity: Severity,
code: Option<String>,
message: String,
tool_hint: String,
source: Option<SourceSnippet>,
snippet: Option<SourceSnippet>,
enclosing_definition: Option<IndexedSymbol>,
context_warning: Option<String>,
}
/// Parses CLI arguments and returns a process exit code.
#[must_use]
pub fn main_entry() -> i32 {
match parse_cli_from(std::env::args_os()) {
Ok((ParseOutcome::Help, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("diagpick {}", env!("CARGO_PKG_VERSION"));
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
Ok(code) => code.as_i32(),
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let mut parser = lexopt::Parser::from_iter(args);
let mut cli = Cli {
common: CommonArgs::default(),
with_snippet: false,
with_definition: false,
context: 1,
limit: None,
severity: SeverityFilter::All,
allow_empty: false,
paths: Vec::new(),
};
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
Long("json") => cli.common.set_render_mode(RenderMode::Json),
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
cli.common.set_render_mode(parse_format_choice(&value)?);
}
Long("input-format") => {
let value = parser_value_string(&mut parser, "--input-format")?;
cli.common.input_format = parse_input_format(&value)?;
}
Long("color") => {
let value = parser_value_string(&mut parser, "--color")?;
cli.common.color = parse_color_choice(&value)?;
}
Long("quiet") => cli.common.quiet = true,
Long("with-source" | "with-snippet" | "snip") => cli.with_snippet = true,
Long("with-definition" | "def") => cli.with_definition = true,
Long("jsonl") => cli.common.input_format = InputFormat::Jsonl,
Long("context") => {
cli.context =
parse_usize_flag("--context", &parser_value_string(&mut parser, "--context")?)?;
}
Long("limit") => {
cli.limit = Some(parse_positive_usize_flag(
"--limit",
&parser_value_string(&mut parser, "--limit")?,
)?);
}
Long("severity") => {
cli.severity =
parse_severity_filter(&parser_value_string(&mut parser, "--severity")?)?;
}
Long("allow-empty") => cli.allow_empty = true,
ArgValue(path) => cli.paths.push(PathBuf::from(path)),
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Ok((ParseOutcome::Run, cli))
}
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
let value = parser
.value()
.map_err(|error| CliError::usage(error.to_string()))?;
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{flag} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
value
.parse::<usize>()
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
fn parse_positive_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
let parsed = parse_usize_flag(flag, value)?;
if parsed == 0 {
return Err(CliError::usage(format!("{flag} must be greater than 0")));
}
Ok(parsed)
}
fn parse_severity_filter(value: &str) -> Result<SeverityFilter, CliError> {
match value {
"all" => Ok(SeverityFilter::All),
"error" => Ok(SeverityFilter::Error),
"warning" => Ok(SeverityFilter::Warning),
"note" => Ok(SeverityFilter::Note),
other => Err(CliError::usage(format!(
"invalid --severity value '{other}'; expected all, error, warning, or note"
))),
}
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
let mut diagnostics = load_diagnostics(cli)?;
diagnostics.retain(|diagnostic| severity_matches(diagnostic.severity, cli.severity));
if let Some(limit) = cli.limit {
diagnostics.truncate(limit);
}
if cli.with_snippet || cli.with_definition {
diagnostics = attach_context(
diagnostics,
cli.context,
cli.with_snippet,
cli.with_definition,
)?;
}
match cli.common.render_mode() {
RenderMode::Json => print_json(&diagnostics)?,
RenderMode::Toon => print_structured(&diagnostics, RenderMode::Toon)?,
RenderMode::Text => {
if diagnostics.is_empty() {
if !cli.common.quiet {
println!("{}", empty_diagnostics_message(cli.allow_empty));
}
} else {
print!("{}", render_text(&diagnostics));
}
}
}
if diagnostics.is_empty() && cli.allow_empty {
Ok(ExitCode::Success)
} else {
Ok(map_result_count(diagnostics.len()))
}
}
fn load_diagnostics(cli: &Cli) -> Result<Vec<Diagnostic>, CliError> {
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
if !buffer.trim().is_empty() {
return parse_input(&buffer, cli.common.input_format);
}
}
if cli.paths.is_empty() {
return Err(CliError::usage(
"provide at least one log path or pipe log lines into stdin",
));
}
let mut diagnostics = Vec::new();
for path in &cli.paths {
let content = fs::read_to_string(path).map_err(|error| {
CliError::runtime(format!("failed to read {}: {error}", path.display()))
})?;
let mut parsed = parse_input(&content, cli.common.input_format)?;
resolve_relative_paths(&mut parsed, path);
diagnostics.extend(parsed);
}
Ok(diagnostics)
}
const fn empty_diagnostics_message(allow_empty: bool) -> &'static str {
if allow_empty {
"0 actionable diagnostics; if this looks like a generic log or transcript, try logshape or fileprobe first"
} else {
"0 actionable diagnostics; if this looks like a generic log or transcript, try logshape or fileprobe first (use --allow-empty to exit 0)"
}
}
fn resolve_relative_paths(diagnostics: &mut [Diagnostic], log_path: &Path) {
let resolution_roots = candidate_resolution_roots(log_path);
for diagnostic in diagnostics {
diagnostic.path = normalize_path_string(&diagnostic.path);
let raw_path = Path::new(&diagnostic.path);
if raw_path.is_absolute() {
continue;
}
if let Some(resolved) = resolve_path_against_roots(raw_path, &resolution_roots) {
diagnostic.path = normalize_path_string(&resolved.display().to_string());
}
}
}
fn candidate_resolution_roots(log_path: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
let mut current = log_path.parent();
while let Some(path) = current {
roots.push(path.to_path_buf());
current = path.parent();
}
roots
}
fn resolve_path_against_roots(path: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
roots
.iter()
.map(|root| root.join(path))
.find(|candidate| candidate.exists())
}
fn parse_input(content: &str, input_format: InputFormat) -> Result<Vec<Diagnostic>, CliError> {
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
match input_format {
InputFormat::Lines | InputFormat::Auto => Ok(parse_text_diagnostics(content)),
InputFormat::Jsonl => parse_jsonl_diagnostics(content),
}
}
fn parse_jsonl_diagnostics(content: &str) -> Result<Vec<Diagnostic>, CliError> {
let mut diagnostics = Vec::new();
for (index, line) in io::Cursor::new(content).lines().enumerate() {
let raw =
line.map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
let value = serde_json::from_str::<Value>(trimmed).map_err(|error| {
CliError::runtime(format!(
"invalid JSONL diagnostic at line {}: {error}",
index + 1
))
})?;
diagnostics.push(parse_json_diagnostic(&value, index + 1)?);
}
Ok(diagnostics)
}
fn parse_json_diagnostic(value: &Value, line_number: usize) -> Result<Diagnostic, CliError> {
let Value::Object(object) = value else {
return Err(CliError::runtime(format!(
"JSON diagnostic at line {line_number} must be an object"
)));
};
let path = object
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| {
CliError::runtime(format!(
"JSON diagnostic at line {line_number} missing path"
))
})?
.to_string();
let line = object
.get("line")
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.ok_or_else(|| {
CliError::runtime(format!(
"JSON diagnostic at line {line_number} missing line"
))
})?;
let column = object
.get("column")
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.ok_or_else(|| {
CliError::runtime(format!(
"JSON diagnostic at line {line_number} missing column"
))
})?;
let severity = object
.get("severity")
.and_then(Value::as_str)
.and_then(parse_severity)
.ok_or_else(|| {
CliError::runtime(format!(
"JSON diagnostic at line {line_number} missing severity"
))
})?;
let message = object
.get("message")
.and_then(Value::as_str)
.ok_or_else(|| {
CliError::runtime(format!(
"JSON diagnostic at line {line_number} missing message"
))
})?
.to_string();
let code = object
.get("code")
.and_then(Value::as_str)
.map(str::to_string);
let tool_hint = object
.get("tool_hint")
.and_then(Value::as_str)
.unwrap_or("jsonl")
.to_string();
Ok(Diagnostic {
path: normalize_path_string(&path),
line,
column,
severity,
code,
message,
tool_hint,
source: None,
snippet: None,
enclosing_definition: None,
context_warning: None,
})
}
fn parse_text_diagnostics(content: &str) -> Vec<Diagnostic> {
let lines = content.lines().collect::<Vec<_>>();
let mut diagnostics = Vec::new();
let mut index = 0_usize;
while index < lines.len() {
let line = lines[index];
if let Some(diagnostic) = parse_csharp_diagnostic(line) {
diagnostics.push(diagnostic);
index += 1;
continue;
}
if let Some((severity, code, message)) = parse_rust_header(line) {
if let Some(location_line) = lines.get(index + 1) {
if let Some((path, line, column)) = parse_rust_location(location_line) {
diagnostics.push(Diagnostic {
path,
line,
column,
severity,
code,
message,
tool_hint: "rustc".to_string(),
source: None,
snippet: None,
enclosing_definition: None,
context_warning: None,
});
index += 1;
continue;
}
}
let tool_hint = tool_hint_for_tool_diagnostic(line);
diagnostics.push(Diagnostic {
path: synthetic_tool_path(tool_hint),
line: 1,
column: 1,
severity,
code,
message,
tool_hint: tool_hint.to_string(),
source: None,
snippet: None,
enclosing_definition: None,
context_warning: None,
});
}
index += 1;
}
diagnostics
}
fn parse_rust_header(line: &str) -> Option<(Severity, Option<String>, String)> {
let (severity, trimmed) = [
(Severity::Error, "error"),
(Severity::Warning, "warning"),
(Severity::Note, "note"),
]
.into_iter()
.find_map(|(severity, prefix)| line.strip_prefix(prefix).map(|rest| (severity, rest)))?;
if let Some(code_and_rest) = trimmed.strip_prefix('[') {
let (code, message_with_colon) = code_and_rest.split_once("]:")?;
let message = message_with_colon.trim_start();
(!message.is_empty()).then(|| (severity, Some(code.to_string()), message.to_string()))
} else {
let message = trimmed.strip_prefix(':')?.trim_start();
(!message.is_empty()).then(|| (severity, None, message.to_string()))
}
}
fn parse_rust_location(line: &str) -> Option<(String, usize, usize)> {
let location = line.trim_start().strip_prefix("--> ")?;
parse_path_line_column(location)
}
fn parse_csharp_diagnostic(line: &str) -> Option<Diagnostic> {
let (severity, marker) = [
(Severity::Error, "): error "),
(Severity::Warning, "): warning "),
(Severity::Note, "): note "),
]
.into_iter()
.find_map(|(severity, marker)| line.find(marker).map(|index| (severity, (index, marker))))?;
let (marker_index, marker_text) = marker;
let location = &line[..marker_index];
let after_severity = &line[marker_index + marker_text.len()..];
let left_paren = location.rfind('(')?;
let path = normalize_path_string(&location[..left_paren]);
let coordinates = &location[left_paren + 1..];
let (line_number, column_number) = coordinates.split_once(',')?;
let line = parse_positive_usize(line_number)?;
let column = parse_positive_usize(column_number)?;
let (code, message) = after_severity.split_once(": ")?;
Some(Diagnostic {
path,
line,
column,
severity,
code: Some(code.to_string()),
message: message.to_string(),
tool_hint: "csharp".to_string(),
source: None,
snippet: None,
enclosing_definition: None,
context_warning: None,
})
}
fn parse_severity(raw: &str) -> Option<Severity> {
match raw {
"error" => Some(Severity::Error),
"warning" => Some(Severity::Warning),
"note" => Some(Severity::Note),
_ => None,
}
}
fn severity_matches(severity: Severity, filter: SeverityFilter) -> bool {
match filter {
SeverityFilter::All => true,
SeverityFilter::Error => severity == Severity::Error,
SeverityFilter::Warning => severity == Severity::Warning,
SeverityFilter::Note => severity == Severity::Note,
}
}
fn attach_context(
diagnostics: Vec<Diagnostic>,
context: usize,
with_snippet: bool,
with_definition: bool,
) -> Result<Vec<Diagnostic>, CliError> {
let mut cache = HashMap::<String, Option<String>>::new();
let mut definition_cache = HashMap::<String, Vec<IndexedSymbol>>::new();
let mut indexer = CodeIndexer::new();
diagnostics
.into_iter()
.map(|diagnostic| {
attach_source_cached(
diagnostic,
context,
with_snippet,
with_definition,
&mut cache,
&mut definition_cache,
&mut indexer,
)
})
.collect()
}
#[cfg(test)]
fn attach_source(mut diagnostic: Diagnostic, context: usize) -> Result<Diagnostic, CliError> {
let mut cache = HashMap::<String, Option<String>>::new();
let mut definition_cache = HashMap::<String, Vec<IndexedSymbol>>::new();
let mut indexer = CodeIndexer::new();
diagnostic = attach_source_cached(
diagnostic,
context,
true,
false,
&mut cache,
&mut definition_cache,
&mut indexer,
)?;
Ok(diagnostic)
}
fn attach_source_cached(
mut diagnostic: Diagnostic,
context: usize,
with_snippet: bool,
with_definition: bool,
cache: &mut HashMap<String, Option<String>>,
definition_cache: &mut HashMap<String, Vec<IndexedSymbol>>,
indexer: &mut CodeIndexer,
) -> Result<Diagnostic, CliError> {
if is_virtual_tool_path(&diagnostic.path) {
return Ok(diagnostic);
}
let source_text = match cache.entry(diagnostic.path.clone()) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
let path = Path::new(&diagnostic.path);
if path.exists() {
let source_text = fs::read_to_string(path).map_err(|error| {
CliError::runtime(format!("failed to read {}: {error}", diagnostic.path))
})?;
entry.insert(Some(source_text))
} else {
entry.insert(None)
}
}
};
if let Some(source_text) = source_text {
let lines = source_text.lines().collect::<Vec<_>>();
let snippet = build_source_snippet(&lines, diagnostic.line, context);
if with_snippet || with_definition {
diagnostic.source.clone_from(&snippet);
diagnostic.snippet = snippet;
}
if with_definition && detect_language(Path::new(&diagnostic.path)).is_some() {
if !definition_cache.contains_key(&diagnostic.path) {
let cached_file = indexer.index_file(Path::new(&diagnostic.path), source_text)?;
definition_cache.insert(diagnostic.path.clone(), cached_file.symbols().to_vec());
}
diagnostic.enclosing_definition = definition_cache
.get(&diagnostic.path)
.and_then(|symbols| {
enclosing_symbol(symbols, diagnostic.line, Some(diagnostic.column))
})
.cloned();
}
} else if with_snippet || with_definition {
diagnostic.context_warning = Some(format!(
"source lookup failed for {}; rerun from the repo root or feed absolute paths",
diagnostic.path
));
}
Ok(diagnostic)
}
fn build_source_snippet(lines: &[&str], line: usize, context: usize) -> Option<SourceSnippet> {
if lines.is_empty() || line == 0 {
return None;
}
let start_line = line.saturating_sub(context).max(1);
let end_line = (line + context).min(lines.len());
(start_line <= end_line).then(|| SourceSnippet {
start_line,
end_line,
lines: lines[start_line - 1..end_line]
.iter()
.enumerate()
.map(|(offset, text)| SourceLine {
number: start_line + offset,
text: (*text).to_owned(),
})
.collect::<Vec<_>>(),
})
}
fn parse_path_line_column(input: &str) -> Option<(String, usize, usize)> {
let mut parts = input.rsplitn(3, ':');
let column = parse_positive_usize(parts.next()?)?;
let line = parse_positive_usize(parts.next()?)?;
let path = normalize_path_string(parts.next()?);
Some((path, line, column))
}
fn parse_positive_usize(input: &str) -> Option<usize> {
let trimmed = input.trim();
(!trimmed.is_empty())
.then_some(trimmed)
.and_then(|value| value.parse::<usize>().ok())
}
fn normalize_path_string(path: &str) -> String {
let trimmed = path.strip_prefix(r"\\?\").unwrap_or(path);
if cfg!(windows) {
trimmed.replace('/', "\\")
} else {
trimmed.to_string()
}
}
fn tool_hint_for_tool_diagnostic(text: &str) -> &'static str {
if text.contains("package ID specification")
|| text.contains("did not match any packages")
|| text.contains("Cargo.toml")
|| text.contains("manifest path")
{
"cargo"
} else {
"tool"
}
}
fn synthetic_tool_path(tool_hint: &str) -> String {
format!("<{tool_hint}>")
}
fn is_virtual_tool_path(path: &str) -> bool {
path.starts_with('<') && path.ends_with('>')
}
fn render_text(diagnostics: &[Diagnostic]) -> String {
let mut rendered = String::new();
for (index, diagnostic) in diagnostics.iter().enumerate() {
if index > 0 {
rendered.push('\n');
}
writeln!(
rendered,
"severity={} code={} path={} line={} column={} tool={} message={}",
severity_label(diagnostic.severity),
diagnostic.code.as_deref().unwrap_or("-"),
diagnostic.path,
diagnostic.line,
diagnostic.column,
diagnostic.tool_hint,
diagnostic.message
)
.expect("writing to a String cannot fail");
if let Some(source) = diagnostic.snippet.as_ref().or(diagnostic.source.as_ref()) {
writeln!(
rendered,
"source_lines={}:{}",
source.start_line, source.end_line
)
.expect("writing to a String cannot fail");
for line in &source.lines {
writeln!(rendered, "{}: {}", line.number, line.text)
.expect("writing to a String cannot fail");
}
}
if let Some(definition) = &diagnostic.enclosing_definition {
writeln!(
rendered,
"definition={} lines={}:{}",
definition.qualified_name, definition.start_line, definition.end_line
)
.expect("writing to a String cannot fail");
for (offset, line) in definition.text.lines().enumerate() {
writeln!(rendered, "{}: {}", definition.start_line + offset, line)
.expect("writing to a String cannot fail");
}
}
if let Some(warning) = &diagnostic.context_warning {
writeln!(rendered, "context_warning={warning}")
.expect("writing to a String cannot fail");
}
}
rendered
}
const fn severity_label(severity: Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Note => "note",
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use common::ColorChoice;
use tempfile::tempdir;
use super::*;
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
CommonArgs {
json,
format: None,
input_format,
color: ColorChoice::Never,
quiet: false,
}
}
#[test]
fn parses_rust_and_csharp_text_diagnostics() {
let rust = parse_text_diagnostics(
"error[E0425]: cannot find value `x` in this scope\n --> src/main.rs:12:7\n",
);
assert_eq!(rust.len(), 1);
assert_eq!(rust[0].code.as_deref(), Some("E0425"));
assert_eq!(rust[0].tool_hint, "rustc");
let csharp = parse_text_diagnostics(
"fixtures\\reading\\sample.cs(9,17): error CS0103: The name 'speeed' does not exist in the current context\n",
);
assert_eq!(csharp.len(), 1);
assert_eq!(csharp[0].code.as_deref(), Some("CS0103"));
assert_eq!(csharp[0].tool_hint, "csharp");
let windows_rust =
parse_text_diagnostics("warning: unused variable\n --> C:\\repo\\src\\main.rs:8:3\n");
assert_eq!(windows_rust.len(), 1);
assert_eq!(windows_rust[0].path, "C:\\repo\\src\\main.rs");
let windows_csharp = parse_text_diagnostics(
"C:\\repo\\Game\\Scripts\\Demo.cs(4,21): warning CS0168: The variable 'unusedValue' is declared but never used\n",
);
assert_eq!(windows_csharp.len(), 1);
assert_eq!(windows_csharp[0].path, "C:\\repo\\Game\\Scripts\\Demo.cs");
}
#[test]
fn jsonl_parsing_and_source_attachment_cover_helpers() {
let jsonl = parse_input(
"{\"path\":\"demo.rs\",\"line\":4,\"column\":2,\"severity\":\"warning\",\"message\":\"unused\",\"tool_hint\":\"rustc\"}\n",
InputFormat::Jsonl,
)
.expect("jsonl diagnostics");
assert_eq!(jsonl.len(), 1);
assert_eq!(jsonl[0].severity, Severity::Warning);
assert!(severity_matches(Severity::Warning, SeverityFilter::All));
assert!(!severity_matches(Severity::Note, SeverityFilter::Error));
let temp = tempdir().expect("tempdir");
let source_path = temp.path().join("demo.rs");
fs::write(&source_path, "fn main() {}\nlet value = 1;\n").expect("fixture");
let attached = attach_source(
Diagnostic {
path: source_path.display().to_string(),
line: 2,
column: 1,
severity: Severity::Warning,
code: None,
message: "unused".to_string(),
tool_hint: "rustc".to_string(),
source: None,
snippet: None,
enclosing_definition: None,
context_warning: None,
},
1,
)
.expect("source attach");
assert_eq!(
attached.source.as_ref().map(|source| source.start_line),
Some(1)
);
}
#[test]
fn run_reports_usage_errors_without_input() {
let error = run(&Cli {
common: common_args(false, InputFormat::Auto),
with_snippet: false,
with_definition: false,
context: 1,
limit: None,
severity: SeverityFilter::All,
allow_empty: false,
paths: Vec::new(),
})
.expect_err("missing input should fail");
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("provide at least one log path")
));
let rendered = render_text(&[Diagnostic {
path: "demo.rs".to_string(),
line: 4,
column: 2,
severity: Severity::Error,
code: Some("E1".to_string()),
message: "boom".to_string(),
tool_hint: "rustc".to_string(),
source: None,
snippet: None,
enclosing_definition: None,
context_warning: None,
}]);
assert!(rendered.contains("severity=error"));
}
#[test]
fn json_and_text_parsers_cover_error_and_note_paths() {
let note = parse_text_diagnostics("note: try a different value\n --> src/main.rs:8:3\n");
assert_eq!(note.len(), 1);
assert_eq!(note[0].severity, Severity::Note);
assert_eq!(severity_label(Severity::Note), "note");
assert_eq!(parse_severity("boom"), None);
let invalid_json = parse_input("{\"path\":\"demo.rs\"}\n", InputFormat::Jsonl)
.expect_err("missing fields should fail");
assert!(matches!(
invalid_json,
CliError::Runtime(message) if message.contains("missing line")
));
let non_object =
parse_json_diagnostic(&serde_json::json!("demo"), 4).expect_err("string should fail");
assert!(matches!(
non_object,
CliError::Runtime(message) if message.contains("must be an object")
));
let invalid_capture =
parse_text_diagnostics("demo.cs(9,nope): error CS0103: Missing column value\n");
assert!(invalid_capture.is_empty());
}
#[test]
fn attach_source_render_text_and_run_cover_more_branches() {
let temp = tempdir().expect("tempdir");
let source_path = temp.path().join("demo.rs");
fs::write(&source_path, "fn main() {}\nlet value = 1;\n").expect("fixture");
let attached = attach_source(
Diagnostic {
path: source_path.display().to_string(),
line: 2,
column: 1,
severity: Severity::Warning,
code: Some("W1".to_string()),
message: "unused".to_string(),
tool_hint: "rustc".to_string(),
source: None,
snippet: None,
enclosing_definition: None,
context_warning: None,
},
1,
)
.expect("source attach");
let rendered = render_text(std::slice::from_ref(&attached));
assert!(rendered.contains("source_lines=1:2"));
assert!(rendered.contains("2: let value = 1;"));
let missing = attach_source(
Diagnostic {
path: temp.path().join("missing.rs").display().to_string(),
line: 1,
column: 1,
severity: Severity::Error,
code: None,
message: "missing".to_string(),
tool_hint: "rustc".to_string(),
source: None,
snippet: None,
enclosing_definition: None,
context_warning: None,
},
2,
)
.expect("missing path should be ignored");
assert!(missing.source.is_none());
assert!(missing.context_warning.is_some());
let log_path = temp.path().join("diag.log");
fs::write(
&log_path,
"warning: unused variable\n --> src/main.rs:8:3\n",
)
.expect("log");
let exit = run(&Cli {
common: common_args(false, InputFormat::Auto),
with_snippet: false,
with_definition: false,
context: 1,
limit: Some(1),
severity: SeverityFilter::Error,
allow_empty: false,
paths: vec![log_path],
})
.expect("warning filtered out");
assert_eq!(exit, ExitCode::NoResults);
let empty_render = render_text(&[]);
assert!(empty_render.is_empty());
assert!(empty_diagnostics_message(false).contains("logshape"));
assert!(empty_diagnostics_message(false).contains("fileprobe"));
assert!(empty_diagnostics_message(false).contains("--allow-empty"));
assert!(!empty_diagnostics_message(true).contains("--allow-empty"));
}
#[test]
fn allow_empty_promotes_filtered_zero_results_to_success() {
let temp = tempdir().expect("tempdir");
let log_path = temp.path().join("diag.log");
fs::write(
&log_path,
"warning: unused variable\n --> src/main.rs:8:3\n",
)
.expect("log");
let exit = run(&Cli {
common: common_args(false, InputFormat::Auto),
with_snippet: false,
with_definition: false,
context: 1,
limit: None,
severity: SeverityFilter::Error,
allow_empty: true,
paths: vec![log_path],
})
.expect("allow empty");
assert_eq!(exit, ExitCode::Success);
}
#[test]
fn parse_cli_rejects_zero_limit() {
let error = parse_cli_from(["diagpick", "--limit", "0", "demo.log"])
.expect_err("zero limit should fail");
assert!(matches!(
error,
CliError::Usage(message) if message.contains("--limit must be greater than 0")
));
}
#[test]
fn run_applies_limit_before_source_context_attachment() {
let temp = tempdir().expect("tempdir");
let source = temp.path().join("first.rs");
let unreadable = temp.path().join("second.rs");
fs::write(&source, "fn first() {}\n").expect("source");
fs::create_dir(&unreadable).expect("unreadable directory");
let log_path = temp.path().join("diag.log");
fs::write(
&log_path,
format!(
"error: first\n --> {}:1:1\nerror: second\n --> {}:1:1\n",
source.display(),
unreadable.display()
),
)
.expect("log");
let exit = run(&Cli {
common: common_args(false, InputFormat::Auto),
with_snippet: true,
with_definition: false,
context: 1,
limit: Some(1),
severity: SeverityFilter::All,
allow_empty: false,
paths: vec![log_path],
})
.expect("limited source attachment");
assert_eq!(exit, ExitCode::Success);
}
#[test]
fn parse_cli_accepts_aliases_and_jsonl_shortcut() {
let (_, cli) = parse_cli_from([
"diagpick",
"--with-snippet",
"--with-definition",
"--jsonl",
"demo.log",
])
.expect("cli");
assert!(cli.with_snippet);
assert!(cli.with_definition);
assert_eq!(cli.common.input_format, InputFormat::Jsonl);
assert_eq!(cli.paths, vec![PathBuf::from("demo.log")]);
}
#[test]
fn resolve_relative_paths_walks_log_ancestors() {
let temp = tempdir().expect("tempdir");
let repo_root = temp.path().join("repo");
let source_path = repo_root.join("src").join("main.rs");
fs::create_dir_all(source_path.parent().expect("parent")).expect("src dir");
fs::create_dir_all(repo_root.join("logs")).expect("logs dir");
fs::write(&source_path, "fn main() {}\n").expect("source");
let log_path = repo_root.join("logs").join("build.log");
fs::write(&log_path, "warning: demo\n --> src/main.rs:1:1\n").expect("log");
let mut diagnostics = vec![Diagnostic {
path: "src/main.rs".to_string(),
line: 1,
column: 1,
severity: Severity::Warning,
code: None,
message: "demo".to_string(),
tool_hint: "rustc".to_string(),
source: None,
snippet: None,
enclosing_definition: None,
context_warning: None,
}];
resolve_relative_paths(&mut diagnostics, &log_path);
assert_eq!(PathBuf::from(&diagnostics[0].path), source_path);
}
#[test]
fn tool_fallback_and_windows_path_normalization_are_stable() {
let parsed = parse_text_diagnostics(
"error: package ID specification `missing-crate` did not match any packages\n",
);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].tool_hint, "cargo");
assert_eq!(parsed[0].path, "<cargo>");
assert_eq!(parsed[0].line, 1);
assert_eq!(parsed[0].column, 1);
let normalized = normalize_path_string(r"\\?\C:/repo/src/main.rs");
if cfg!(windows) {
assert_eq!(normalized, r"C:\repo\src\main.rs");
} else {
assert_eq!(normalized, "C:/repo/src/main.rs");
}
}
}