From 1319f9d6d816afe39017e9885c46eb856dc9a616 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Wed, 27 May 2026 16:24:28 +0200 Subject: [PATCH 1/2] Add Windows C++ MSBuild and PowerShell diagnostics --- README.md | 3 + src/cmds/cpp/msbuild_cmd.rs | 419 ++++++++++++++++-- src/cmds/system/gci_cmd.rs | 189 ++++++++ src/cmds/system/log_cmd.rs | 92 +++- src/cmds/system/read.rs | 176 ++++++++ src/core/text_encoding.rs | 82 ++++ src/discover/registry.rs | 282 +++++++++++- src/main.rs | 89 +++- .../fixtures/cpp/msbuild_failure_msb3073.txt | 20 + .../fixtures/cpp/msbuild_failure_msb8012.txt | 16 + tests/fixtures/cpp/msbuild_failure_rc.txt | 18 + 11 files changed, 1339 insertions(+), 47 deletions(-) create mode 100644 src/cmds/system/gci_cmd.rs create mode 100644 tests/fixtures/cpp/msbuild_failure_msb3073.txt create mode 100644 tests/fixtures/cpp/msbuild_failure_msb8012.txt create mode 100644 tests/fixtures/cpp/msbuild_failure_rc.txt diff --git a/README.md b/README.md index 7a0c646689..9092a9d57c 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ Four strategies applied per command type: rtk ls . # Token-optimized directory tree rtk read file.rs # Smart file reading rtk read file.rs -l aggressive # Signatures only (strips bodies) +rtk read file.rs --lines 430:540 # Inclusive line range (1-based) rtk smart file.rs # 2-line heuristic code summary rtk find "*.rs" . # Compact find results rtk grep "pattern" . # Grouped search results @@ -223,6 +224,7 @@ rtk codegraph index # Summary only, no per-file progress (-90%) Select-String -Path f -Pattern p # auto-rewritten -> rtk grep Get-Content # auto-rewritten -> rtk read GC # auto-rewritten -> rtk read +rtk read --lines 430:540 # Prefer over Get-Content line-range loops Remove-Item -Force # -> ok ``` @@ -255,6 +257,7 @@ rtk json config.json # Structure without values rtk deps # Dependencies summary rtk env -f AWS # Filtered env vars rtk log app.log # Deduplicated logs +rtk log ERRORLOG.TXT --events 20 # Tail key error/assert events (deduped) rtk curl # Truncate + save full output rtk wget # Download, strip progress bars rtk summary # Heuristic summary diff --git a/src/cmds/cpp/msbuild_cmd.rs b/src/cmds/cpp/msbuild_cmd.rs index a864ad6b7c..04813e64e0 100644 --- a/src/cmds/cpp/msbuild_cmd.rs +++ b/src/cmds/cpp/msbuild_cmd.rs @@ -17,12 +17,50 @@ lazy_static! { // Linker: module : error|fatal error LNK1234: message static ref MSVC_LINKER_RE: Regex = Regex::new(r"^(.+) : (error|fatal error) (LNK\d+): (.+)$").unwrap(); + // Linker tool (no file prefix): "LINK : fatal error LNK1104: ..." + static ref MSVC_LINK_TOOL_RE: Regex = + Regex::new(r"^(?i:LINK)\s*: (warning|error|fatal error) (LNK\d+): (.+)$").unwrap(); + // Resource compiler: file.rc(line): error|fatal error RC1234: message [project.vcxproj] + static ref RC_DIAG_RE: Regex = + Regex::new(r"^(.+)\((\d+)\): (warning|error|fatal error) (RC\d+): (.+?)(?:\s+\[.+\])?$") + .unwrap(); + // MSBuild-style diagnostics: path.vcxproj(123,5): error MSB3073: ... + static ref MSBUILD_DIAG_RE: Regex = Regex::new( + r"^(.+?)\((\d+)(?:,(\d+))?\): (warning|error|fatal error) ((?:MSB|PRJ|CVT|LNK|RC|C)\d+): (.+)$" + ) + .unwrap(); + static ref MSB3073_RE: Regex = Regex::new(r"(?i)\b(MSB3073|MSB3721)\b").unwrap(); + static ref EXIT_CODE_RE: Regex = Regex::new(r"(?i)\bexited with code\s+(\d+)\b").unwrap(); + static ref COMMAND_QUOTED_RE: Regex = Regex::new(r#"(?i)\bcommand\s+\"([^\"]+)\""#).unwrap(); + static ref PROJECT_ON_NODE_RE: Regex = + Regex::new(r#"^Project \"(.+?)\" on node \d+ \((.+?) target\(s\)\)\."#).unwrap(); + static ref DONE_BUILDING_RE: Regex = + Regex::new(r#"^Done Building Project \"(.+?)\" \(.+\) -- (FAILED|SUCCESSFUL)\."#) + .unwrap(); // Build FAILED. or Build succeeded. static ref BUILD_RESULT_RE: Regex = Regex::new(r"^Build (FAILED|succeeded)\.").unwrap(); // " N Error(s)" or " N Warning(s)" static ref ERR_WARN_COUNT_RE: Regex = Regex::new(r"^\s+\d+\s+(Error|Warning)\(s\)").unwrap(); } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Severity { + Error, + Warning, +} + +#[derive(Debug, Clone)] +struct MsbuildDiag { + idx: usize, + severity: Severity, + code: String, + file: Option, + line: Option, + message: String, + project: Option, + raw: String, +} + pub fn run(args: &[String], verbose: u8) -> Result { let mut adjusted: Vec = Vec::with_capacity(args.len() + 1); let mut found_link_only = false; @@ -59,34 +97,36 @@ pub fn run(args: &[String], verbose: u8) -> Result { } pub(crate) fn filter_output(raw: &str, args: &[String]) -> String { - let mut compiler_errors: Vec = Vec::new(); - let mut compiler_warnings: Vec = Vec::new(); - let mut linker: Vec = Vec::new(); + let mut diags: Vec = Vec::new(); let mut summary: Vec = Vec::new(); let mut build_result: Option = None; let mut succeeded = false; + let mut current_project: Option = None; + let mut failed_projects: Vec = Vec::new(); - for line in raw.lines() { + let lines: Vec<&str> = raw.lines().collect(); + for (idx, line) in lines.iter().enumerate() { let trimmed = line.trim_end(); if trimmed.is_empty() { continue; } - if let Some(caps) = MSVC_COMPILER_RE.captures(trimmed) { - let file = &caps[1]; - let lnum = &caps[2]; - let kind = &caps[3]; - let code = &caps[4]; - let msg = &caps[5]; - let formatted = format!("{}({}): {} {}: {}", file, lnum, kind, code, msg); - if kind == "warning" { - compiler_warnings.push(formatted); - } else { - compiler_errors.push(formatted); + + if let Some(caps) = PROJECT_ON_NODE_RE.captures(trimmed) { + current_project = Some(caps[1].to_string()); + continue; + } + if let Some(caps) = DONE_BUILDING_RE.captures(trimmed) { + let proj = caps[1].to_string(); + let status = &caps[2]; + if status.eq_ignore_ascii_case("FAILED") && !failed_projects.contains(&proj) { + failed_projects.push(proj.clone()); } + current_project = Some(proj); continue; } - if MSVC_LINKER_RE.is_match(trimmed) { - linker.push(trimmed.to_string()); + + if let Some(diag) = parse_diag_line(trimmed, idx, current_project.as_deref()) { + diags.push(diag); continue; } if let Some(caps) = BUILD_RESULT_RE.captures(trimmed) { @@ -100,9 +140,9 @@ pub(crate) fn filter_output(raw: &str, args: &[String]) -> String { } } - let has_errors = !compiler_errors.is_empty() || !linker.is_empty(); + let has_errors = diags.iter().any(|d| d.severity == Severity::Error); - if !has_errors && build_result.is_none() && compiler_warnings.is_empty() { + if !has_errors && build_result.is_none() && diags.is_empty() { // Empty / redirected output let target = configuration_summary(args); return format!( @@ -118,21 +158,63 @@ pub(crate) fn filter_output(raw: &str, args: &[String]) -> String { } let mut out = String::new(); - for c in &compiler_errors { - out.push_str(c); + + if let Some(first_error) = first_real_error(&diags) { + let target = configuration_summary(args); + out.push_str("FIRST_ERROR\n"); + if !target.is_empty() { + out.push_str(&format!(" target: {}\n", target)); + } + if let Some(p) = first_error.project.as_deref() { + out.push_str(&format!(" project: {}\n", p)); + } + if let Some(f) = first_error.file.as_deref() { + out.push_str(&format!(" file: {}\n", f)); + } + if let Some(ln) = first_error.line { + out.push_str(&format!(" line: {}\n", ln)); + } + out.push_str(&format!(" code: {}\n", first_error.code)); + out.push_str(&format!(" message: {}\n", first_error.message)); + + let ctx = extract_context(&lines, first_error.idx, 3, 5); + if !ctx.prev.is_empty() || !ctx.next.is_empty() { + out.push_str(" context:\n"); + for l in ctx.prev { + out.push_str(&format!(" - {}\n", l)); + } + for l in ctx.next { + out.push_str(&format!(" + {}\n", l)); + } + } out.push('\n'); } - // Show warnings only on failure - if !succeeded { - for c in &compiler_warnings { - out.push_str(c); - out.push('\n'); + + if !failed_projects.is_empty() { + out.push_str("FAILED_PROJECTS\n"); + for p in &failed_projects { + out.push_str(&format!(" - {}\n", p)); } + out.push('\n'); } - for l in &linker { - out.push_str(l); + + out.push_str("DIAGNOSTICS\n"); + for d in dedup_diags(&diags) + .into_iter() + .filter(|d| d.severity == Severity::Error) + { + out.push_str(&d.raw); out.push('\n'); } + if !succeeded { + for d in dedup_diags(&diags) + .into_iter() + .filter(|d| d.severity == Severity::Warning) + { + out.push_str(&d.raw); + out.push('\n'); + } + } if let Some(br) = build_result { out.push_str(&br); out.push('\n'); @@ -144,6 +226,256 @@ pub(crate) fn filter_output(raw: &str, args: &[String]) -> String { out.trim_end().to_string() } +fn parse_diag_line(line: &str, idx: usize, current_project: Option<&str>) -> Option { + // Extract the project path from trailing "[...vcxproj]" when present. + let project_from_suffix = line + .rfind('[') + .and_then(|i| line[i..].strip_prefix('[')) + .and_then(|rest| rest.strip_suffix(']')) + .map(|s| s.trim().to_string()); + let project = project_from_suffix.or_else(|| current_project.map(str::to_string)); + + if let Some(caps) = MSVC_COMPILER_RE.captures(line) { + let file = caps.get(1)?.as_str().to_string(); + let lnum: usize = caps.get(2)?.as_str().parse().ok()?; + let kind = caps.get(3)?.as_str(); + let code = caps.get(4)?.as_str().to_string(); + let msg = caps.get(5)?.as_str().to_string(); + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + let raw = format!("{}({}): {} {}: {}", file, lnum, kind, code, msg); + return Some(MsbuildDiag { + idx, + severity, + code, + file: Some(file), + line: Some(lnum), + message: msg, + project, + raw, + }); + } + + if let Some(caps) = RC_DIAG_RE.captures(line) { + let file = caps.get(1)?.as_str().to_string(); + let lnum: usize = caps.get(2)?.as_str().parse().ok()?; + let kind = caps.get(3)?.as_str(); + let code = caps.get(4)?.as_str().to_string(); + let msg = caps.get(5)?.as_str().to_string(); + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + return Some(MsbuildDiag { + idx, + severity, + code, + file: Some(file), + line: Some(lnum), + message: msg, + project, + raw: line.to_string(), + }); + } + + if let Some(caps) = MSBUILD_DIAG_RE.captures(line) { + let file = caps.get(1)?.as_str().to_string(); + let lnum: usize = caps.get(2)?.as_str().parse().ok()?; + let kind = caps.get(4)?.as_str(); + let code = caps.get(5)?.as_str().to_string(); + let mut msg = caps.get(6)?.as_str().to_string(); + if MSB3073_RE.is_match(&code) { + let exit_code = EXIT_CODE_RE + .captures(&msg) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()); + if let Some(cmd_caps) = COMMAND_QUOTED_RE.captures(&msg) { + if let Some(cmd) = cmd_caps.get(1).map(|m| m.as_str()) { + msg = cmd.to_string(); + } + } + if let Some(n) = exit_code { + msg = format!("{} (exit code {})", msg, n); + } + } + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + return Some(MsbuildDiag { + idx, + severity, + code, + file: Some(file), + line: Some(lnum), + message: msg, + project, + raw: line.to_string(), + }); + } + + if let Some(caps) = MSVC_LINKER_RE.captures(line) { + let kind = caps.get(2)?.as_str(); + let code = caps.get(3)?.as_str().to_string(); + let msg = caps.get(4)?.as_str().to_string(); + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + return Some(MsbuildDiag { + idx, + severity, + code, + file: None, + line: None, + message: msg, + project, + raw: line.to_string(), + }); + } + + if let Some(caps) = MSVC_LINK_TOOL_RE.captures(line) { + let kind = caps.get(1)?.as_str(); + let code = caps.get(2)?.as_str().to_string(); + let msg = caps.get(3)?.as_str().to_string(); + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + return Some(MsbuildDiag { + idx, + severity, + code, + file: None, + line: None, + message: msg, + project, + raw: line.to_string(), + }); + } + + if MSB3073_RE.is_match(line) { + let code = MSB3073_RE + .captures(line) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "MSB3073".to_string()); + + let mut msg = line.to_string(); + let exit_code = EXIT_CODE_RE + .captures(&msg) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()); + if let Some(cmd_caps) = COMMAND_QUOTED_RE.captures(line) { + if let Some(cmd) = cmd_caps.get(1).map(|m| m.as_str()) { + msg = cmd.to_string(); + } + } + if let Some(n) = exit_code { + msg = format!("{} (exit code {})", msg, n); + } + + return Some(MsbuildDiag { + idx, + severity: Severity::Error, + code, + file: None, + line: None, + message: msg.clone(), + project, + raw: line.to_string(), + }); + } + + None +} + +fn first_real_error(diags: &[MsbuildDiag]) -> Option { + diags.iter().find(|d| d.severity == Severity::Error).cloned() +} + +fn dedup_diags(diags: &[MsbuildDiag]) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: Vec = Vec::new(); + for d in diags { + let key = format!("{}|{}", d.code, d.raw); + if seen.contains(&key) { + continue; + } + seen.push(key); + out.push(d.clone()); + } + out +} + +struct ContextWindow { + prev: Vec, + next: Vec, +} + +fn extract_context(lines: &[&str], idx: usize, prev_n: usize, next_n: usize) -> ContextWindow { + let mut prev = Vec::new(); + let mut next = Vec::new(); + + let mut i = idx; + while i > 0 && prev.len() < prev_n { + i -= 1; + let t = lines[i].trim_end(); + if t.is_empty() { + continue; + } + if is_msbuild_context_noise(t) { + continue; + } + prev.push(sanitize_context_line(t)); + } + prev.reverse(); + + let mut j = idx + 1; + while j < lines.len() && next.len() < next_n { + let t = lines[j].trim_end(); + j += 1; + if t.is_empty() { + continue; + } + if is_msbuild_context_noise(t) { + continue; + } + next.push(sanitize_context_line(t)); + } + + ContextWindow { prev, next } +} + +fn is_msbuild_context_noise(line: &str) -> bool { + let l = line.trim_start(); + let lower = l.to_ascii_lowercase(); + lower.starts_with("project \"") + || lower.starts_with("done building project ") + || lower.starts_with("build started ") + || lower.starts_with("time elapsed ") + || lower == "build failed." + || lower == "build succeeded." +} + +fn sanitize_context_line(line: &str) -> String { + // Common MSBuild suffix noise: " ... [C:\path\Project.vcxproj]" + // Keep behavior consistent with MSVC_COMPILER_RE stripping. + if line.ends_with(']') && line.contains(".vcxproj") { + if let Some(i) = line.rfind(" [") { + return line[..i].to_string(); + } + } + line.to_string() +} + fn configuration_summary(args: &[String]) -> String { let solution = args .iter() @@ -281,6 +613,37 @@ mod tests { assert!(out.contains("MyProject.lib(util.obj)")); } + #[test] + fn test_fixture_rc_failure() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_rc.txt"); + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("RC1015")); + assert!(out.contains("FIRST_ERROR")); + assert!(out.contains("FAILED_PROJECTS")); + } + + #[test] + fn test_fixture_msb3073_extraction() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_msb3073.txt"); + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("MSB3073")); + assert!(out.contains("exit code 1")); + assert!(out.contains("copy /Y")); + assert!(out.contains("FIRST_ERROR")); + } + + #[test] + fn test_fixture_msb8012_detection() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_msb8012.txt"); + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("MSB8012")); + assert!(out.contains("TargetPath")); + assert!(out.contains("FIRST_ERROR")); + } + #[test] fn test_fixture_empty_link() { let raw = include_str!("../../../tests/fixtures/cpp/msbuild_empty_link.txt"); diff --git a/src/cmds/system/gci_cmd.rs b/src/cmds/system/gci_cmd.rs new file mode 100644 index 0000000000..c3ba4cb089 --- /dev/null +++ b/src/cmds/system/gci_cmd.rs @@ -0,0 +1,189 @@ +//! PowerShell Get-ChildItem / gci / dir compatible (subset) file listing with compact output. + +use crate::core::tracking; +use anyhow::Result; +use ignore::WalkBuilder; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GciKind { + File, + Directory, + Any, +} + +#[derive(Debug)] +pub struct GciArgs { + pub path: PathBuf, + pub recurse: bool, + pub force: bool, + pub kind: GciKind, + pub filter: Option, + pub include: Vec, + pub max: usize, + pub select_full_name: bool, + pub select_last_write_time: bool, + pub select_length: bool, +} + +impl Default for GciArgs { + fn default() -> Self { + Self { + path: PathBuf::from("."), + recurse: false, + force: false, + kind: GciKind::Any, + filter: None, + include: Vec::new(), + max: 50, + select_full_name: true, + select_last_write_time: false, + select_length: false, + } + } +} + +pub fn run(args: &GciArgs, verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + if verbose > 0 { + eprintln!("gci: {} (recurse={})", args.path.display(), args.recurse); + } + + let mut builder = WalkBuilder::new(&args.path); + builder.git_ignore(true).git_exclude(true).hidden(!args.force); + if !args.recurse { + builder.max_depth(Some(1)); + } + + let filter = args.filter.as_deref(); + let include = &args.include; + + let mut matches: Vec = Vec::new(); + for dent in builder.build() { + let dent = match dent { + Ok(d) => d, + Err(_) => continue, + }; + let p = dent.path(); + if p == Path::new("") { + continue; + } + + let ft = match dent.file_type() { + Some(t) => t, + None => continue, + }; + match args.kind { + GciKind::File if !ft.is_file() => continue, + GciKind::Directory if !ft.is_dir() => continue, + _ => {} + } + + let name = match p.file_name().and_then(|s| s.to_str()) { + Some(n) => n, + None => continue, + }; + + if let Some(f) = filter { + if !glob_match(f, name) { + continue; + } + } + + if !include.is_empty() && !include.iter().any(|pat| glob_match(pat, name)) { + continue; + } + + matches.push(p.to_path_buf()); + } + + matches.sort(); + let total = matches.len(); + + let mut out = String::new(); + out.push_str(&format!("{} matches\n\n", total)); + + let shown = std::cmp::min(total, args.max); + for p in matches.iter().take(shown) { + if args.select_last_write_time || args.select_length { + let meta = fs::metadata(p).ok(); + let len = meta.as_ref().map(|m| m.len()); + let mtime = meta.as_ref().and_then(|m| m.modified().ok()); + + let mut parts = Vec::new(); + if args.select_full_name { + parts.push(p.display().to_string()); + } + if args.select_length { + parts.push(format!( + "len={}", + len.map(|v| v.to_string()).unwrap_or_else(|| "?".into()) + )); + } + if args.select_last_write_time { + parts.push(format!( + "mtime={}", + mtime.map(format_system_time).unwrap_or_else(|| "?".into()) + )); + } + out.push_str(&parts.join(" ")); + out.push('\n'); + } else { + out.push_str(&p.display().to_string()); + out.push('\n'); + } + } + + if total > shown { + out.push_str(&format!("[+{} more]\n", total - shown)); + } + + print!("{}", out); + timer.track( + &format!("gci {}", args.path.display()), + "rtk gci", + "", + &out, + ); + Ok(()) +} + +pub fn parse_select_list(spec: &str, out: &mut GciArgs) { + // Accept: "FullName,LastWriteTime,Length" (powershell-ish). + for raw in spec.split(',') { + let s = raw.trim().to_ascii_lowercase(); + match s.as_str() { + "fullname" => out.select_full_name = true, + "lastwritetime" => out.select_last_write_time = true, + "length" => out.select_length = true, + _ => {} + } + } +} + +fn format_system_time(t: SystemTime) -> String { + // Avoid chrono dependency; emit seconds since epoch for compactness. + match t.duration_since(UNIX_EPOCH) { + Ok(d) => format!("{}s", d.as_secs()), + Err(_) => "?".into(), + } +} + +fn glob_match(pattern: &str, name: &str) -> bool { + glob_match_inner(pattern.as_bytes(), name.as_bytes()) +} + +fn glob_match_inner(pat: &[u8], name: &[u8]) -> bool { + match (pat.first(), name.first()) { + (None, None) => true, + (Some(b'*'), _) => { + glob_match_inner(&pat[1..], name) + || (!name.is_empty() && glob_match_inner(pat, &name[1..])) + } + (Some(b'?'), Some(_)) => glob_match_inner(&pat[1..], &name[1..]), + (Some(&p), Some(&n)) if p == n => glob_match_inner(&pat[1..], &name[1..]), + _ => false, + } +} diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index 7c765f5e6e..c17c50fe86 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -22,7 +22,7 @@ lazy_static! { } /// Filter and deduplicate log output -pub fn run_file(file: &Path, verbose: u8) -> Result<()> { +pub fn run_file(file: &Path, recent_events: usize, keywords: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -30,7 +30,7 @@ pub fn run_file(file: &Path, verbose: u8) -> Result<()> { } let content = fs::read_to_string(file)?; - let result = analyze_logs(&content); + let result = analyze_logs_with_options(&content, recent_events, keywords); println!("{}", result); timer.track( &format!("cat {}", file.display()), @@ -42,7 +42,7 @@ pub fn run_file(file: &Path, verbose: u8) -> Result<()> { } /// Filter logs from stdin -pub fn run_stdin(_verbose: u8) -> Result<()> { +pub fn run_stdin(recent_events: usize, keywords: &[String], _verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); let mut content = String::new(); @@ -52,7 +52,7 @@ pub fn run_stdin(_verbose: u8) -> Result<()> { content.push('\n'); } - let result = analyze_logs(&content); + let result = analyze_logs_with_options(&content, recent_events, keywords); println!("{}", result); timer.track("log (stdin)", "rtk log (stdin)", &content, &result); @@ -216,6 +216,75 @@ fn analyze_logs(content: &str) -> String { result.join("\n") } +fn analyze_logs_with_options(content: &str, recent_events: usize, keywords: &[String]) -> String { + let mut base = analyze_logs(content); + if recent_events == 0 { + return base; + } + + let keys: Vec = if keywords.is_empty() { + vec![ + "assert", + "error", + "failed", + "fail", + "exception", + "crash", + "load", + "oninitialize", + "onpostinitialize", + "streamread", + ] + .into_iter() + .map(|s| s.to_string()) + .collect() + } else { + keywords.iter().map(|s| s.to_ascii_lowercase()).collect() + }; + + let mut picked: Vec<(usize, String)> = Vec::new(); + let mut seen_norm: Vec = Vec::new(); + + let lines: Vec<&str> = content.lines().collect(); + for idx0 in (0..lines.len()).rev() { + let l = lines[idx0].trim_end(); + if l.is_empty() { + continue; + } + let lower = l.to_ascii_lowercase(); + if !keys.iter().any(|k| lower.contains(k)) { + continue; + } + + let norm = normalize_log_line(l, &TIMESTAMP_RE, &UUID_RE, &HEX_RE, &NUM_RE, &PATH_RE); + if seen_norm.contains(&norm) { + continue; + } + seen_norm.push(norm); + picked.push((idx0 + 1, l.to_string())); + if picked.len() >= recent_events { + break; + } + } + + picked.reverse(); + + if !picked.is_empty() { + base.push_str("\n\n[RECENT_EVENTS]\n"); + for (ln, msg) in picked { + let truncated = if msg.len() > 200 { + let t: String = msg.chars().take(197).collect(); + format!("{}...", t) + } else { + msg + }; + base.push_str(&format!(" {}: {}\n", ln, truncated)); + } + } + + base.trim_end().to_string() +} + fn normalize_log_line( line: &str, timestamp_re: &Regex, @@ -274,4 +343,19 @@ mod tests { // Should not panic even with very long multi-byte messages assert!(result.contains("ERRORS")); } + + #[test] + fn test_recent_events_tail_dedup() { + let logs = "INFO: startup\n\ + ERROR: Load failed\n\ + ERROR: Load failed\n\ + OnInitialize: begin\n\ + ASSERT failed: x\n"; + let out = analyze_logs_with_options(logs, 3, &[]); + assert!(out.contains("[RECENT_EVENTS]")); + assert!(out.contains("ERROR: Load failed")); + assert!(out.contains("ASSERT failed")); + // Dedup identical ERROR line in recent events + assert_eq!(out.matches("ERROR: Load failed").count(), 2); // one in summary, one in recent events + } } diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 568a6a76a6..c91940324f 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -244,11 +244,123 @@ fn read_file_text( ) -> Result<(String, text_encoding::UsedEncoding, bool)> { let bytes = fs::read(path) .with_context(|| format!("Failed to read file: {}", path.display()))?; + + // Avoid dumping binary-ish content as Latin1 garbage in --encoding auto mode. + // This is intentionally conservative and only triggers for obvious cases. + if encoding == TextEncoding::Auto && looks_binary_bytes(&bytes) { + let preview = hex_preview(&bytes, 64); + let nul = bytes.iter().take(8192).filter(|b| **b == 0).count(); + let msg = format!( + "rtk read: file appears binary ({} bytes, nul={} in first 8192)\n\ +binary preview (first {} bytes): {}\n\ +hint: use `rtk read --encoding latin1 ` to force raw bytes-as-text\n", + bytes.len(), + nul, + preview.len, + preview.hex + ); + return Ok((msg, text_encoding::UsedEncoding::Utf8, false)); + } + let decoded = text_encoding::decode_bytes(&bytes, encoding) .with_context(|| format!("Failed to decode file: {}", path.display()))?; Ok((decoded.text, decoded.used, decoded.used_fallback)) } +struct HexPreview { + hex: String, + len: usize, +} + +fn hex_preview(bytes: &[u8], max: usize) -> HexPreview { + let n = std::cmp::min(bytes.len(), max); + let mut out = String::new(); + for (i, b) in bytes.iter().take(n).enumerate() { + if i > 0 { + out.push(' '); + } + out.push_str(&format!("{:02X}", b)); + } + HexPreview { hex: out, len: n } +} + +fn looks_binary_bytes(bytes: &[u8]) -> bool { + if bytes.len() >= 2 && ((bytes[0] == 0xFF && bytes[1] == 0xFE) || (bytes[0] == 0xFE && bytes[1] == 0xFF)) { + return false; + } + let sample = bytes.iter().take(8192).copied().collect::>(); + if sample.is_empty() { + return false; + } + + // UTF-16 without BOM can contain many NULs; don't treat it as binary. + if looks_utf16_no_bom(&sample) { + return false; + } + + // A single NUL byte is a strong signal for binary in this tool's context. + if sample.contains(&0) { + return true; + } + + // If a large fraction of bytes are control chars (excluding \t,\n,\r), treat as binary-ish. + let mut control = 0usize; + for b in &sample { + if *b < 0x09 || (*b > 0x0D && *b < 0x20) { + control += 1; + } + } + (control as f64 / sample.len() as f64) > 0.30 +} + +fn looks_utf16_no_bom(sample: &[u8]) -> bool { + if sample.len() < 4 || !sample.len().is_multiple_of(2) { + return false; + } + let mut zeros_even = 0usize; + let mut zeros_odd = 0usize; + let mut pairs = 0usize; + for chunk in sample.chunks_exact(2).take(4096) { + pairs += 1; + if chunk[0] == 0 { + zeros_even += 1; + } + if chunk[1] == 0 { + zeros_odd += 1; + } + } + if pairs == 0 { + return false; + } + let even_ratio = zeros_even as f64 / pairs as f64; + let odd_ratio = zeros_odd as f64 / pairs as f64; + + fn ascii_lane_ratio(sample: &[u8], lane: usize) -> f64 { + let mut total = 0usize; + let mut ascii = 0usize; + for chunk in sample.chunks_exact(2).take(4096) { + let b = chunk[lane]; + if b == 0 { + continue; + } + total += 1; + let is_ascii = + b == b'\t' || b == b'\n' || b == b'\r' || (0x20..=0x7E).contains(&b); + if is_ascii { + ascii += 1; + } + } + if total == 0 { + 0.0 + } else { + ascii as f64 / total as f64 + } + } + + (odd_ratio >= 0.60 && even_ratio < 0.10 && ascii_lane_ratio(sample, 0) >= 0.85) + || (even_ratio >= 0.60 && odd_ratio < 0.10 && ascii_lane_ratio(sample, 1) >= 0.85) +} + fn format_with_line_numbers(content: &str) -> String { let lines: Vec<&str> = content.lines().collect(); let width = lines.len().to_string().len(); @@ -363,6 +475,54 @@ fn main() {{ Ok(()) } + #[test] + fn test_read_auto_utf16_le_no_bom() -> Result<()> { + let mut file = NamedTempFile::new()?; + // "Hi\n" in UTF-16 LE without BOM + file.write_all(&[0x48, 0x00, 0x69, 0x00, 0x0A, 0x00])?; + let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?; + assert!(used_fallback); + assert_eq!(used, text_encoding::UsedEncoding::Utf16Le); + assert!(txt.contains("Hi")); + Ok(()) + } + + #[test] + fn test_read_auto_utf16_le_bom() -> Result<()> { + let mut file = NamedTempFile::new()?; + // BOM + "Hi\n" in UTF-16 LE + file.write_all(&[0xFF, 0xFE, 0x48, 0x00, 0x69, 0x00, 0x0A, 0x00])?; + let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?; + assert!(!used_fallback); + assert_eq!(used, text_encoding::UsedEncoding::Utf16Le); + assert!(txt.contains("Hi")); + Ok(()) + } + + #[test] + fn test_read_auto_windows_1252() -> Result<()> { + let mut file = NamedTempFile::new()?; + // "Hé" in windows-1252: 0x48 0xE9 (invalid UTF-8) + file.write_all(&[0x48, 0xE9])?; + let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?; + assert!(used_fallback); + assert_eq!(used, text_encoding::UsedEncoding::Windows1252); + assert!(txt.contains('é')); + Ok(()) + } + + #[test] + fn test_read_auto_binary_preview() -> Result<()> { + let mut file = NamedTempFile::new()?; + file.write_all(&[0x00, 0x01, 0x02, 0x03, 0x00, 0xFF])?; + let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?; + assert!(!used_fallback); + assert_eq!(used, text_encoding::UsedEncoding::Utf8); + assert!(txt.contains("file appears binary")); + assert!(txt.contains("binary preview")); + Ok(()) + } + #[test] fn test_apply_line_window_range() { let lang = Language::Unknown; @@ -371,6 +531,22 @@ fn main() {{ assert_eq!(out, "b\nc\n"); } + #[test] + fn test_apply_line_window_invalid_range_empty() { + let lang = Language::Unknown; + let s = "a\nb\nc\n"; + assert_eq!(apply_line_window(s, None, None, Some((0, 2)), &lang), ""); + assert_eq!(apply_line_window(s, None, None, Some((3, 2)), &lang), ""); + } + + #[test] + fn test_format_with_line_numbers_offset() { + let s = "b\nc\n"; + let out = format_with_line_numbers_offset(s, 2); + assert!(out.contains("2 │ b")); + assert!(out.contains("3 │ c")); + } + #[test] fn test_stdin_support_signature() { // Test that run_stdin has correct signature and compiles diff --git a/src/core/text_encoding.rs b/src/core/text_encoding.rs index 00fa1c2708..8098540820 100644 --- a/src/core/text_encoding.rs +++ b/src/core/text_encoding.rs @@ -65,6 +65,19 @@ pub fn decode_bytes(bytes: &[u8], requested: TextEncoding) -> Result { + // Heuristic: UTF-16 without BOM (common for some Windows logs / legacy tools). + // Check this BEFORE accepting UTF-8 when NUL bytes are present, because UTF-16 + // payloads like "H\0i\0" are valid UTF-8 but produce unreadable output. + if payload.contains(&0) { + if let Some(utf16) = detect_utf16_no_bom(payload) { + return Ok(DecodedText { + text: utf16.text, + used: utf16.used, + used_fallback: true, + }); + } + } + if let Ok(s) = std::str::from_utf8(payload) { return Ok(DecodedText { text: s.to_string(), @@ -139,6 +152,75 @@ pub fn decode_bytes(bytes: &[u8], requested: TextEncoding) -> Result Option { + if payload.len() < 4 || !payload.len().is_multiple_of(2) { + return None; + } + + let mut zeros_even = 0usize; + let mut zeros_odd = 0usize; + let mut pairs = 0usize; + for chunk in payload.chunks_exact(2).take(4096) { + pairs += 1; + if chunk[0] == 0 { + zeros_even += 1; + } + if chunk[1] == 0 { + zeros_odd += 1; + } + } + if pairs == 0 { + return None; + } + + let even_ratio = zeros_even as f64 / pairs as f64; + let odd_ratio = zeros_odd as f64 / pairs as f64; + + // For ASCII-ish UTF-16, every other byte is often 0x00 AND the other lane is + // mostly printable ASCII. + const THRESH: f64 = 0.60; + if odd_ratio >= THRESH && even_ratio < 0.10 && ascii_lane_ratio(payload, 0) >= 0.85 { + return Some(Utf16Guess { + text: decode_utf16(payload, true), + used: UsedEncoding::Utf16Le, + }); + } + if even_ratio >= THRESH && odd_ratio < 0.10 && ascii_lane_ratio(payload, 1) >= 0.85 { + return Some(Utf16Guess { + text: decode_utf16(payload, false), + used: UsedEncoding::Utf16Be, + }); + } + + None +} + +fn ascii_lane_ratio(payload: &[u8], lane: usize) -> f64 { + let mut total = 0usize; + let mut ascii = 0usize; + for chunk in payload.chunks_exact(2).take(4096) { + let b = chunk[lane]; + if b == 0 { + continue; + } + total += 1; + let is_ascii = b == b'\t' || b == b'\n' || b == b'\r' || (0x20..=0x7E).contains(&b); + if is_ascii { + ascii += 1; + } + } + if total == 0 { + 0.0 + } else { + ascii as f64 / total as f64 + } +} + pub fn encode_text(text: &str, encoding: UsedEncoding) -> Result> { match encoding { UsedEncoding::Utf8 => Ok(text.as_bytes().to_vec()), diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 554fd40da2..5331a20727 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -74,13 +74,21 @@ lazy_static! { static ref TAIL_LINES_EQ: Regex = Regex::new(r"^tail\s+--lines=(\d+)\s+(\S+)$").unwrap(); static ref TAIL_LINES_SPACE: Regex = Regex::new(r"^tail\s+--lines\s+(\d+)\s+(\S+)$").unwrap(); - // PowerShell: Select-String → rtk grep (handles both -Path/-Pattern orderings). + // PowerShell: Select-String → rtk grep (limited, safety-first). + // + // We only rewrite when we can prove equivalence. Most importantly: + // - Select-String is case-insensitive by default; rg/grep are not. + // - Pipelines ($_.FullName) and multi-pattern arrays are not safely rewritable here. static ref SELECT_STRING_PATH_FIRST: Regex = Regex::new( - r#"(?i)^Select-String\s+.*?-Path\s+(\S+).*?-Pattern\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)"# + r#"(?i)^Select-String\s+.*?-Path\s+(\S+).*?-Pattern\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)(?:\s|$)"# ).unwrap(); static ref SELECT_STRING_PATTERN_FIRST: Regex = Regex::new( - r#"(?i)^Select-String\s+.*?-Pattern\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+).*?-Path\s+(\S+)"# + r#"(?i)^Select-String\s+.*?-Pattern\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+).*?-Path\s+(\S+)(?:\s|$)"# ).unwrap(); + static ref SELECT_STRING_CASE_SENSITIVE_RE: Regex = Regex::new(r"(?i)\s-CaseSensitive(?:\s|$)").unwrap(); + static ref SELECT_STRING_SIMPLE_MATCH_RE: Regex = Regex::new(r"(?i)\s-SimpleMatch(?:\s|$)").unwrap(); + static ref SELECT_STRING_CONTEXT_RE: Regex = Regex::new(r"(?i)\s-Context\s+(\d+)\s*,\s*(\d+)(?:\s|$)").unwrap(); + static ref SELECT_STRING_RECURSE_RE: Regex = Regex::new(r"(?i)\s-Recurse(?:\s|$)").unwrap(); // PowerShell: Get-Content / GC → rtk read static ref GET_CONTENT_RE: Regex = @@ -583,6 +591,26 @@ fn rewrite_compound( } TokenKind::Pipe => { let seg = cmd[seg_start..tok.offset].trim(); + let pipe_group_end = tokens.iter().find(|t| { + t.offset > tok.offset + && (t.kind == TokenKind::Operator + || (t.kind == TokenKind::Shellism && t.value == "&")) + }); + let pipe_end = pipe_group_end.map(|t| t.offset).unwrap_or(cmd.len()); + let pipe_group = cmd[tok.offset..pipe_end].trim(); + + if let Some(rewritten) = try_rewrite_powershell_pipe_group(seg, pipe_group) { + if rewritten != seg { + any_changed = true; + } + result.push_str(&rewritten); + seg_start = pipe_end; + if pipe_group_end.is_none() { + return if any_changed { Some(result) } else { None }; + } + continue; + } + let is_pipe_incompatible = seg.starts_with("find ") || seg == "find" || seg.starts_with("fd ") @@ -598,12 +626,6 @@ fn rewrite_compound( } result.push_str(&rewritten); - let pipe_group_end = tokens.iter().find(|t| { - t.offset > tok.offset - && (t.kind == TokenKind::Operator - || (t.kind == TokenKind::Shellism && t.value == "&")) - }); - match pipe_group_end { Some(next_op) => { result.push(' '); @@ -759,7 +781,59 @@ fn try_powershell_rewrite(cmd: &str) -> Option { } else { (caps.get(1)?.as_str(), caps.get(2)?.as_str()) }; - return Some(format!("rtk grep {} {}", pattern, path)); + + // Safety: do not rewrite pipeline placeholders or obvious variables. + // Those depend on runtime values (e.g. $_.FullName) that rtk rewrite cannot evaluate. + if path.contains("$_") || path.starts_with('$') { + return None; + } + // Safety: multi-pattern arrays ("x","y") are not representable in a single rtk grep pattern arg. + if pattern.contains(',') { + return None; + } + // Safety: Select-String -Recurse with wildcard paths relies on PowerShell expansion semantics. + // rtk grep is recursive by default but cannot reliably reproduce PS globbing here. + if SELECT_STRING_RECURSE_RE.is_match(cmd) && (path.contains('*') || path.contains('?')) { + return None; + } + + let mut out = String::new(); + out.push_str("rtk grep "); + + if SELECT_STRING_SIMPLE_MATCH_RE.is_match(cmd) { + out.push_str("--fixed "); + } + + out.push_str(pattern); + out.push(' '); + out.push_str(path); + + // Select-String is case-insensitive by default. + if !SELECT_STRING_CASE_SENSITIVE_RE.is_match(cmd) { + out.push_str(" -- -i"); + } + + // Context window: -Context before,after + if let Some(ctx) = SELECT_STRING_CONTEXT_RE.captures(cmd) { + let before = ctx.get(1)?.as_str(); + let after = ctx.get(2)?.as_str(); + out.push_str(&format!(" -B {} -A {}", before, after)); + } + + return Some(out); + } + + // PowerShell: Get-ChildItem / gci / dir → rtk gci (subset) + { + let lower = cmd.trim_start().to_ascii_lowercase(); + if lower.starts_with("get-childitem") + || lower.starts_with("gci") + || lower.starts_with("dir") + { + if let Some(rewritten) = try_rewrite_powershell_get_child_item(cmd) { + return Some(rewritten); + } + } } if let Some(caps) = GET_CONTENT_RE.captures(cmd) { @@ -778,6 +852,133 @@ fn try_powershell_rewrite(cmd: &str) -> Option { None } +fn try_rewrite_powershell_pipe_group(left: &str, pipe_group: &str) -> Option { + // Only supports: Get-ChildItem ... | Select-Object FullName,LastWriteTime,Length + // Rewrites the whole pipe group into: rtk gci ... --select ... + let lower_left = left.trim_start().to_ascii_lowercase(); + if !(lower_left.starts_with("get-childitem") + || lower_left.starts_with("gci") + || lower_left.starts_with("dir")) + { + return None; + } + + let pg = pipe_group.trim(); + let pg_lower = pg.to_ascii_lowercase(); + if !pg_lower.starts_with("| select-object") { + return None; + } + + // Very small, safety-first parser: do not attempt to evaluate variables. + if left.contains("$_") || left.contains('$') { + return None; + } + + // Extract the property list after Select-Object. + let props = pg.split_once(char::is_whitespace)?.1.trim(); // "Select-Object ..." + let props = props + .strip_prefix("Select-Object") + .or_else(|| props.strip_prefix("select-object"))? + .trim(); + if props.is_empty() { + return None; + } + + let rewritten_left = try_rewrite_powershell_get_child_item(left)?; + Some(format!("{} --select {}", rewritten_left, props)) +} + +fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { + // Supports a small subset of PowerShell Get-ChildItem/gci/dir flags and rewrites + // to `rtk gci` with equivalent-ish behavior. + // + // Safety: no variables, no pipeline placeholders. + if cmd.contains("$_") || cmd.contains('$') { + return None; + } + + let mut tokens: Vec<&str> = cmd.split_whitespace().collect(); + if tokens.is_empty() { + return None; + } + + // Drop the leading command word (Get-ChildItem/gci/dir) + tokens.remove(0); + + let mut path: Option<&str> = None; + let mut recurse = false; + let mut force = false; + let mut kind_file = false; + let mut kind_dir = false; + let mut filter: Option<&str> = None; + let mut include: Option<&str> = None; + + let mut i = 0; + while i < tokens.len() { + let t = tokens[i]; + let lower = t.to_ascii_lowercase(); + if !t.starts_with('-') && path.is_none() { + path = Some(t); + i += 1; + continue; + } + match lower.as_str() { + "-recurse" => { + recurse = true; + i += 1; + } + "-force" => { + force = true; + i += 1; + } + "-file" => { + kind_file = true; + i += 1; + } + "-directory" => { + kind_dir = true; + i += 1; + } + "-filter" => { + filter = tokens.get(i + 1).copied(); + i += 2; + } + "-include" => { + include = tokens.get(i + 1).copied(); + i += 2; + } + _ => { + // Unknown flag → do not rewrite (safety). + return None; + } + } + } + + let mut out = String::new(); + out.push_str("rtk gci "); + out.push_str(path.unwrap_or(".")); + if recurse { + out.push_str(" --recurse"); + } + if force { + out.push_str(" --force"); + } + if kind_file { + out.push_str(" --file"); + } else if kind_dir { + out.push_str(" --directory"); + } + if let Some(f) = filter { + out.push_str(" --filter "); + out.push_str(f); + } + if let Some(inc) = include { + out.push_str(" --include "); + out.push_str(inc); + } + Some(out) +} + fn is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool { excluded.iter().any(|pat| match pat { ExcludePattern::Regex(re) => re.is_match(cmd), @@ -1317,6 +1518,67 @@ mod tests { ); } + #[test] + fn test_rewrite_powershell_select_string_default_case_insensitive() { + assert_eq!( + rewrite_command_no_prefixes("Select-String -Path f -Pattern p", &[]), + Some("rtk grep p f -- -i".into()) + ); + } + + #[test] + fn test_rewrite_powershell_select_string_case_sensitive() { + assert_eq!( + rewrite_command_no_prefixes("Select-String -Path f -Pattern p -CaseSensitive", &[]), + Some("rtk grep p f".into()) + ); + } + + #[test] + fn test_rewrite_powershell_select_string_simple_match_and_context() { + assert_eq!( + rewrite_command_no_prefixes( + "Select-String -Path f -Pattern p -SimpleMatch -Context 2,3", + &[] + ), + Some("rtk grep --fixed p f -- -i -B 2 -A 3".into()) + ); + } + + #[test] + fn test_rewrite_powershell_select_string_skips_variable_path() { + assert_eq!( + rewrite_command_no_prefixes("Select-String -Path $p -Pattern p", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("Select-String -Path $_.FullName -Pattern p", &[]), + None + ); + } + + #[test] + fn test_rewrite_powershell_get_child_item_simple() { + assert_eq!( + rewrite_command_no_prefixes("Get-ChildItem . -Recurse -File -Filter API_win.obj", &[]), + Some("rtk gci . --recurse --file --filter API_win.obj".into()) + ); + } + + #[test] + fn test_rewrite_powershell_get_child_item_pipe_select_object_absorbed() { + assert_eq!( + rewrite_command_no_prefixes( + "Get-ChildItem . -Recurse -Force -File -Filter API_win.obj | Select-Object FullName,LastWriteTime,Length", + &[] + ), + Some( + "rtk gci . --recurse --force --file --filter API_win.obj --select FullName,LastWriteTime,Length" + .into() + ) + ); + } + // --- git -C support (#555) --- #[test] diff --git a/src/main.rs b/src/main.rs index 9373936dd6..405d3c93bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,8 +21,8 @@ use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; use cmds::system::{ - deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, patch, - pipe_cmd, read, summary, tree, wc_cmd, + deps, env_cmd, find_cmd, format_cmd, gci_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, + patch, pipe_cmd, read, summary, tree, wc_cmd, }; use anyhow::{Context, Result}; @@ -290,6 +290,37 @@ enum Commands { args: Vec, }, + /// PowerShell-like Get-ChildItem (subset) with compact output + Gci { + /// Root path + #[arg(default_value = ".")] + path: PathBuf, + /// Recurse into subdirectories + #[arg(long)] + recurse: bool, + /// Include hidden files/directories + #[arg(long)] + force: bool, + /// Files only + #[arg(long, conflicts_with = "directory")] + file: bool, + /// Directories only + #[arg(long)] + directory: bool, + /// Name filter (glob, e.g. "*.cpp" or "API_win.obj") + #[arg(long)] + filter: Option, + /// Include patterns (comma-separated globs) + #[arg(long)] + include: Option, + /// Max results to show + #[arg(long, default_value = "50")] + max: usize, + /// Select properties (comma-separated: FullName,LastWriteTime,Length) + #[arg(long)] + select: Option, + }, + /// Ultra-condensed diff (only changed lines) Diff { /// First file or - for stdin (unified diff) @@ -302,6 +333,12 @@ enum Commands { Log { /// Log file (omit for stdin) file: Option, + /// Show last N matching events (deduped) + #[arg(long, default_value = "0")] + events: usize, + /// Additional keywords to treat as events (can be repeated) + #[arg(long = "keyword", action = clap::ArgAction::Append)] + keyword: Vec, }, /// .NET commands with compact output (build/test/restore/format) @@ -1881,6 +1918,44 @@ fn run_cli() -> Result { 0 } + Commands::Gci { + path, + recurse, + force, + file, + directory, + filter, + include, + max, + select, + } => { + let mut parsed = gci_cmd::GciArgs { + path, + recurse, + force, + max, + filter, + ..Default::default() + }; + if file { + parsed.kind = gci_cmd::GciKind::File; + } else if directory { + parsed.kind = gci_cmd::GciKind::Directory; + } + if let Some(spec) = include { + parsed.include = spec + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + if let Some(sel) = select.as_deref() { + gci_cmd::parse_select_list(sel, &mut parsed); + } + gci_cmd::run(&parsed, cli.verbose)?; + 0 + } + Commands::Diff { file1, file2 } => { if let Some(f2) = file2 { diff_cmd::run(&file1, &f2, cli.verbose)?; @@ -1890,11 +1965,15 @@ fn run_cli() -> Result { 0 } - Commands::Log { file } => { + Commands::Log { + file, + events, + keyword, + } => { if let Some(f) = file { - log_cmd::run_file(&f, cli.verbose)?; + log_cmd::run_file(&f, events, &keyword, cli.verbose)?; } else { - log_cmd::run_stdin(cli.verbose)?; + log_cmd::run_stdin(events, &keyword, cli.verbose)?; } 0 } diff --git a/tests/fixtures/cpp/msbuild_failure_msb3073.txt b/tests/fixtures/cpp/msbuild_failure_msb3073.txt new file mode 100644 index 0000000000..7a16cf46ca --- /dev/null +++ b/tests/fixtures/cpp/msbuild_failure_msb3073.txt @@ -0,0 +1,20 @@ +Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework +Copyright (C) Microsoft Corporation. All rights reserved. + +Build started 1/15/2026 9:06:00 AM. +Project "C:\src\MyProject.sln" on node 1 (Build target(s)). +Project "C:\src\MyProject\MyProject.vcxproj" on node 1 (default target(s)). +PrepareForBuild: + Creating directory "obj\Debug\". +PostBuildEvent: + copy /Y "C:\path with spaces\out.dll" "C:\dest\bin\" +C:\src\MyProject\MyProject.vcxproj(123,5): error MSB3073: The command "copy /Y \"C:\\path with spaces\\out.dll\" \"C:\\dest\\bin\\\"" exited with code 1. [C:\src\MyProject\MyProject.vcxproj] +Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED. +Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED. + +Build FAILED. + + 0 Warning(s) + 1 Error(s) + +Time Elapsed 00:00:02.00 diff --git a/tests/fixtures/cpp/msbuild_failure_msb8012.txt b/tests/fixtures/cpp/msbuild_failure_msb8012.txt new file mode 100644 index 0000000000..996e01162c --- /dev/null +++ b/tests/fixtures/cpp/msbuild_failure_msb8012.txt @@ -0,0 +1,16 @@ +Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework +Copyright (C) Microsoft Corporation. All rights reserved. + +Build started 1/15/2026 9:08:00 AM. +Project "C:\src\MyProject.sln" on node 1 (Build target(s)). +Project "C:\src\MyProject\MyProject.vcxproj" on node 1 (default target(s)). +C:\src\MyProject\MyProject.vcxproj(56,5): error MSB8012: TargetPath (C:\src\MyProject\bin\Debug\MyProject.dll) does not match the Linker's OutputFile property value (C:\src\MyProject\bin\Debug\MyProjectWrong.dll). This may cause your project to build incorrectly. [C:\src\MyProject\MyProject.vcxproj] +Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED. +Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED. + +Build FAILED. + + 0 Warning(s) + 1 Error(s) + +Time Elapsed 00:00:01.00 diff --git a/tests/fixtures/cpp/msbuild_failure_rc.txt b/tests/fixtures/cpp/msbuild_failure_rc.txt new file mode 100644 index 0000000000..e75d0eb73a --- /dev/null +++ b/tests/fixtures/cpp/msbuild_failure_rc.txt @@ -0,0 +1,18 @@ +Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework +Copyright (C) Microsoft Corporation. All rights reserved. + +Build started 1/15/2026 9:07:00 AM. +Project "C:\src\MyProject.sln" on node 1 (Build target(s)). +Project "C:\src\MyProject\MyProject.vcxproj" on node 1 (default target(s)). +ResourceCompile: + C:\src\MyProject\res\app.rc +C:\src\MyProject\res\app.rc(10): fatal error RC1015: cannot open include file 'windows.h'. [C:\src\MyProject\MyProject.vcxproj] +Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED. +Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED. + +Build FAILED. + + 0 Warning(s) + 1 Error(s) + +Time Elapsed 00:00:01.00 From 3984de410833fbb3ff1bd7dfc44606340e4c6164 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Wed, 27 May 2026 17:44:31 +0200 Subject: [PATCH 2/2] Address Windows diagnostics review feedback --- src/cmds/cpp/msbuild_cmd.rs | 68 +++++++++++++++++++++----- src/cmds/system/gci_cmd.rs | 22 ++++++++- src/cmds/system/read.rs | 10 ++-- src/core/text_encoding.rs | 3 +- src/discover/registry.rs | 95 ++++++++++++++++++++++++++++++++----- 5 files changed, 170 insertions(+), 28 deletions(-) diff --git a/src/cmds/cpp/msbuild_cmd.rs b/src/cmds/cpp/msbuild_cmd.rs index 04813e64e0..f6b981b377 100644 --- a/src/cmds/cpp/msbuild_cmd.rs +++ b/src/cmds/cpp/msbuild_cmd.rs @@ -8,6 +8,7 @@ use crate::core::utils::resolved_command; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; +use std::collections::HashSet; lazy_static! { // Compiler: file(line): error|warning C1234: message [project.vcxproj] @@ -31,7 +32,6 @@ lazy_static! { .unwrap(); static ref MSB3073_RE: Regex = Regex::new(r"(?i)\b(MSB3073|MSB3721)\b").unwrap(); static ref EXIT_CODE_RE: Regex = Regex::new(r"(?i)\bexited with code\s+(\d+)\b").unwrap(); - static ref COMMAND_QUOTED_RE: Regex = Regex::new(r#"(?i)\bcommand\s+\"([^\"]+)\""#).unwrap(); static ref PROJECT_ON_NODE_RE: Regex = Regex::new(r#"^Project \"(.+?)\" on node \d+ \((.+?) target\(s\)\)\."#).unwrap(); static ref DONE_BUILDING_RE: Regex = @@ -293,10 +293,8 @@ fn parse_diag_line(line: &str, idx: usize, current_project: Option<&str>) -> Opt .captures(&msg) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()); - if let Some(cmd_caps) = COMMAND_QUOTED_RE.captures(&msg) { - if let Some(cmd) = cmd_caps.get(1).map(|m| m.as_str()) { - msg = cmd.to_string(); - } + if let Some(cmd) = extract_msb3073_command(&msg) { + msg = cmd; } if let Some(n) = exit_code { msg = format!("{} (exit code {})", msg, n); @@ -373,10 +371,8 @@ fn parse_diag_line(line: &str, idx: usize, current_project: Option<&str>) -> Opt .captures(&msg) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()); - if let Some(cmd_caps) = COMMAND_QUOTED_RE.captures(line) { - if let Some(cmd) = cmd_caps.get(1).map(|m| m.as_str()) { - msg = cmd.to_string(); - } + if let Some(cmd) = extract_msb3073_command(line) { + msg = cmd; } if let Some(n) = exit_code { msg = format!("{} (exit code {})", msg, n); @@ -397,19 +393,67 @@ fn parse_diag_line(line: &str, idx: usize, current_project: Option<&str>) -> Opt None } +fn extract_msb3073_command(msg: &str) -> Option { + // Expected shape: + // The command "...." exited with code N. + // Command body may contain escaped quotes: \"C:\path with spaces\" + let start = msg.find("The command \"")? + "The command \"".len(); + let rest = &msg[start..]; + let mut out = String::new(); + let mut escape = false; + for (i, ch) in rest.char_indices() { + if escape { + out.push(ch); + escape = false; + continue; + } + + if ch == '\\' { + // Only treat backslash as an escape marker when it escapes a quote or a backslash. + // Otherwise it's a real Windows path separator. + let next = rest[i + ch.len_utf8()..].chars().next(); + if matches!(next, Some('"') | Some('\\')) { + escape = true; + } else { + out.push(ch); + } + continue; + } + + if ch == '"' { + let after = &rest[i + ch.len_utf8()..]; + if after.starts_with(" exited with code") { + break; + } + out.push(ch); + continue; + } + + out.push(ch); + } + if out.is_empty() { + return None; + } + + // Normalize MSBuild escaping so paths are readable. + // Keep this minimal: this is display output only. + let out = out.replace(r#"\""#, r#"""#); + Some(out) +} + fn first_real_error(diags: &[MsbuildDiag]) -> Option { diags.iter().find(|d| d.severity == Severity::Error).cloned() } fn dedup_diags(diags: &[MsbuildDiag]) -> Vec { let mut out: Vec = Vec::new(); - let mut seen: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); for d in diags { let key = format!("{}|{}", d.code, d.raw); if seen.contains(&key) { continue; } - seen.push(key); + seen.insert(key); out.push(d.clone()); } out @@ -631,6 +675,8 @@ mod tests { assert!(out.contains("MSB3073")); assert!(out.contains("exit code 1")); assert!(out.contains("copy /Y")); + assert!(out.contains("C:\\path with spaces\\out.dll")); + assert!(out.contains("C:\\dest\\bin")); assert!(out.contains("FIRST_ERROR")); } diff --git a/src/cmds/system/gci_cmd.rs b/src/cmds/system/gci_cmd.rs index c3ba4cb089..1feeaa16d5 100644 --- a/src/cmds/system/gci_cmd.rs +++ b/src/cmds/system/gci_cmd.rs @@ -172,7 +172,9 @@ fn format_system_time(t: SystemTime) -> String { } fn glob_match(pattern: &str, name: &str) -> bool { - glob_match_inner(pattern.as_bytes(), name.as_bytes()) + let pat = pattern.to_ascii_lowercase(); + let nm = name.to_ascii_lowercase(); + glob_match_inner(pat.as_bytes(), nm.as_bytes()) } fn glob_match_inner(pat: &[u8], name: &[u8]) -> bool { @@ -187,3 +189,21 @@ fn glob_match_inner(pat: &[u8], name: &[u8]) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_glob_match_case_insensitive() { + assert!(glob_match("*.CPP", "api_win.cpp")); + assert!(glob_match("API_WIN.OBJ", "api_win.obj")); + } + + #[test] + fn test_glob_match_wildcards() { + assert!(glob_match("a?c.txt", "abc.txt")); + assert!(glob_match("a*c.txt", "abbbbbc.txt")); + assert!(!glob_match("a?c.txt", "ac.txt")); + } +} diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index c91940324f..0fe3a319e8 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -288,13 +288,14 @@ fn looks_binary_bytes(bytes: &[u8]) -> bool { if bytes.len() >= 2 && ((bytes[0] == 0xFF && bytes[1] == 0xFE) || (bytes[0] == 0xFE && bytes[1] == 0xFF)) { return false; } - let sample = bytes.iter().take(8192).copied().collect::>(); + let len = std::cmp::min(bytes.len(), 8192); + let sample = &bytes[..len]; if sample.is_empty() { return false; } // UTF-16 without BOM can contain many NULs; don't treat it as binary. - if looks_utf16_no_bom(&sample) { + if looks_utf16_no_bom(sample) { return false; } @@ -305,7 +306,7 @@ fn looks_binary_bytes(bytes: &[u8]) -> bool { // If a large fraction of bytes are control chars (excluding \t,\n,\r), treat as binary-ish. let mut control = 0usize; - for b in &sample { + for b in sample { if *b < 0x09 || (*b > 0x0D && *b < 0x20) { control += 1; } @@ -314,7 +315,8 @@ fn looks_binary_bytes(bytes: &[u8]) -> bool { } fn looks_utf16_no_bom(sample: &[u8]) -> bool { - if sample.len() < 4 || !sample.len().is_multiple_of(2) { + #[allow(clippy::manual_is_multiple_of)] + if sample.len() < 4 || sample.len() % 2 != 0 { return false; } let mut zeros_even = 0usize; diff --git a/src/core/text_encoding.rs b/src/core/text_encoding.rs index 8098540820..707f8b847d 100644 --- a/src/core/text_encoding.rs +++ b/src/core/text_encoding.rs @@ -158,7 +158,8 @@ struct Utf16Guess { } fn detect_utf16_no_bom(payload: &[u8]) -> Option { - if payload.len() < 4 || !payload.len().is_multiple_of(2) { + #[allow(clippy::manual_is_multiple_of)] + if payload.len() < 4 || payload.len() % 2 != 0 { return None; } diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 5331a20727..51e9ba09dc 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -897,7 +897,7 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { return None; } - let mut tokens: Vec<&str> = cmd.split_whitespace().collect(); + let mut tokens: Vec = split_powershell_args_quote_aware(cmd)?; if tokens.is_empty() { return None; } @@ -905,20 +905,20 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { // Drop the leading command word (Get-ChildItem/gci/dir) tokens.remove(0); - let mut path: Option<&str> = None; + let mut path: Option = None; let mut recurse = false; let mut force = false; let mut kind_file = false; let mut kind_dir = false; - let mut filter: Option<&str> = None; - let mut include: Option<&str> = None; + let mut filter: Option = None; + let mut include: Option = None; let mut i = 0; while i < tokens.len() { - let t = tokens[i]; + let t = tokens[i].as_str(); let lower = t.to_ascii_lowercase(); if !t.starts_with('-') && path.is_none() { - path = Some(t); + path = Some(tokens[i].clone()); i += 1; continue; } @@ -940,11 +940,11 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { i += 1; } "-filter" => { - filter = tokens.get(i + 1).copied(); + filter = tokens.get(i + 1).cloned(); i += 2; } "-include" => { - include = tokens.get(i + 1).copied(); + include = tokens.get(i + 1).cloned(); i += 2; } _ => { @@ -956,7 +956,7 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { let mut out = String::new(); out.push_str("rtk gci "); - out.push_str(path.unwrap_or(".")); + out.push_str(path.as_deref().unwrap_or(".")); if recurse { out.push_str(" --recurse"); } @@ -970,11 +970,65 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { } if let Some(f) = filter { out.push_str(" --filter "); - out.push_str(f); + out.push_str(&f); } if let Some(inc) = include { out.push_str(" --include "); - out.push_str(inc); + out.push_str(&inc); + } + Some(out) +} + +fn split_powershell_args_quote_aware(cmd: &str) -> Option> { + let mut out: Vec = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + let mut escape = false; + + for ch in cmd.chars() { + if escape { + cur.push(ch); + escape = false; + continue; + } + + if quote == Some('"') && ch == '\\' { + // Treat backslash-escaped chars inside double quotes as literal. + escape = true; + cur.push(ch); + continue; + } + + if let Some(q) = quote { + cur.push(ch); + if ch == q { + quote = None; + } + continue; + } + + if ch == '"' || ch == '\'' { + quote = Some(ch); + cur.push(ch); + continue; + } + + if ch.is_whitespace() { + if !cur.is_empty() { + out.push(cur.clone()); + cur.clear(); + } + continue; + } + + cur.push(ch); + } + + if quote.is_some() { + return None; + } + if !cur.is_empty() { + out.push(cur); } Some(out) } @@ -1565,6 +1619,25 @@ mod tests { ); } + #[test] + fn test_rewrite_powershell_get_child_item_quoted_path_and_filter() { + assert_eq!( + rewrite_command_no_prefixes( + "Get-ChildItem \"C:\\My Path\" -Recurse -File -Filter \"API win.obj\"", + &[] + ), + Some("rtk gci \"C:\\My Path\" --recurse --file --filter \"API win.obj\"".into()) + ); + } + + #[test] + fn test_rewrite_powershell_get_child_item_quoted_glob_filter() { + assert_eq!( + rewrite_command_no_prefixes("Get-ChildItem . -Filter \"*.CPP\"", &[]), + Some("rtk gci . --filter \"*.CPP\"".into()) + ); + } + #[test] fn test_rewrite_powershell_get_child_item_pipe_select_object_absorbed() { assert_eq!(