//! 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 Structured output format: text, json, toon --json Shortcut for --format json --toon Shortcut for --format toon --input-format Override stdin parsing mode: auto, lines, jsonl --color 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 Source context lines above and below the hit --limit Optional maximum number of diagnostics to emit --severity 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, /// 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, } /// 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, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct Diagnostic { path: String, line: usize, column: usize, severity: Severity, code: Option, message: String, tool_hint: String, source: Option, snippet: Option, enclosing_definition: Option, context_warning: Option, } /// 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(args: I) -> Result<(ParseOutcome, Cli), CliError> where I: IntoIterator, T: Into, { 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 { 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 { value .parse::() .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) } fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { 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 { 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 { 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, 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 { 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 { roots .iter() .map(|root| root.join(path)) .find(|candidate| candidate.exists()) } fn parse_input(content: &str, input_format: InputFormat) -> Result, 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, 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::(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 { 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 { let lines = content.lines().collect::>(); 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)> { 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 { 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 { 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, context: usize, with_snippet: bool, with_definition: bool, ) -> Result, CliError> { let mut cache = HashMap::>::new(); let mut definition_cache = HashMap::>::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 { let mut cache = HashMap::>::new(); let mut definition_cache = HashMap::>::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>, definition_cache: &mut HashMap>, indexer: &mut CodeIndexer, ) -> Result { 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::>(); 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 { 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::>(), }) } 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 { let trimmed = input.trim(); (!trimmed.is_empty()) .then_some(trimmed) .and_then(|value| value.parse::().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, ""); 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"); } } }