Files
rtk-ai__rtk/src/dotnet_cmd.rs
Artiom Tofan 2e194efb3a fix: resolve Windows command wrapper execution (#269)
- add PATHEXT-aware command resolution via which\n- migrate affected command runners to resolved_command\n- add regression coverage for wrapper/fallback behavior
2026-03-12 15:36:19 +01:00

1771 lines
56 KiB
Rust

use crate::binlog;
use crate::dotnet_format_report;
use crate::dotnet_trx;
use crate::tracking;
use crate::utils::{resolved_command, truncate};
use anyhow::{Context, Result};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const DOTNET_CLI_UI_LANGUAGE: &str = "DOTNET_CLI_UI_LANGUAGE";
const DOTNET_CLI_UI_LANGUAGE_VALUE: &str = "en-US";
static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn run_build(args: &[String], verbose: u8) -> Result<()> {
run_dotnet_with_binlog("build", args, verbose)
}
pub fn run_test(args: &[String], verbose: u8) -> Result<()> {
run_dotnet_with_binlog("test", args, verbose)
}
pub fn run_restore(args: &[String], verbose: u8) -> Result<()> {
run_dotnet_with_binlog("restore", args, verbose)
}
pub fn run_format(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let (report_path, cleanup_report_path) = resolve_format_report_path(args);
let mut cmd = resolved_command("dotnet");
cmd.env(DOTNET_CLI_UI_LANGUAGE, DOTNET_CLI_UI_LANGUAGE_VALUE);
cmd.arg("format");
for arg in build_effective_dotnet_format_args(args, report_path.as_deref()) {
cmd.arg(arg);
}
if verbose > 0 {
eprintln!("Running: dotnet format {}", args.join(" "));
}
let command_started_at = SystemTime::now();
let output = cmd.output().context("Failed to run dotnet format")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let raw = format!("{}\n{}", stdout, stderr);
let check_mode = !has_write_mode_override(args);
let filtered =
format_report_summary_or_raw(report_path.as_deref(), check_mode, &raw, command_started_at);
println!("{}", filtered);
timer.track(
&format!("dotnet format {}", args.join(" ")),
&format!("rtk dotnet format {}", args.join(" ")),
&raw,
&filtered,
);
if cleanup_report_path {
if let Some(path) = report_path.as_deref() {
cleanup_temp_file(path);
}
}
if !output.status.success() {
std::process::exit(output.status.code().unwrap_or(1));
}
Ok(())
}
pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> {
if args.is_empty() {
anyhow::bail!("dotnet: no subcommand specified");
}
let timer = tracking::TimedExecution::start();
let subcommand = args[0].to_string_lossy().to_string();
let mut cmd = resolved_command("dotnet");
cmd.env(DOTNET_CLI_UI_LANGUAGE, DOTNET_CLI_UI_LANGUAGE_VALUE);
cmd.arg(&subcommand);
for arg in &args[1..] {
cmd.arg(arg);
}
if verbose > 0 {
eprintln!("Running: dotnet {} ...", subcommand);
}
let output = cmd
.output()
.with_context(|| format!("Failed to run dotnet {}", subcommand))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let raw = format!("{}\n{}", stdout, stderr);
print!("{}", stdout);
eprint!("{}", stderr);
timer.track(
&format!("dotnet {}", subcommand),
&format!("rtk dotnet {}", subcommand),
&raw,
&raw,
);
if !output.status.success() {
std::process::exit(output.status.code().unwrap_or(1));
}
Ok(())
}
fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let binlog_path = build_binlog_path(subcommand);
let should_expect_binlog = subcommand != "test" || has_binlog_arg(args);
// For test commands, prefer user-provided results directory; otherwise create isolated one.
let (trx_results_dir, cleanup_trx_results_dir) = resolve_trx_results_dir(subcommand, args);
let mut cmd = resolved_command("dotnet");
cmd.env(DOTNET_CLI_UI_LANGUAGE, DOTNET_CLI_UI_LANGUAGE_VALUE);
cmd.arg(subcommand);
for arg in
build_effective_dotnet_args(subcommand, args, &binlog_path, trx_results_dir.as_deref())
{
cmd.arg(arg);
}
if verbose > 0 {
eprintln!("Running: dotnet {} {}", subcommand, args.join(" "));
}
let command_started_at = SystemTime::now();
let output = cmd
.output()
.with_context(|| format!("Failed to run dotnet {}", subcommand))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let raw = format!("{}\n{}", stdout, stderr);
let filtered = match subcommand {
"build" => {
let binlog_summary = if should_expect_binlog && binlog_path.exists() {
normalize_build_summary(
binlog::parse_build(&binlog_path).unwrap_or_default(),
output.status.success(),
)
} else {
binlog::BuildSummary::default()
};
let raw_summary = normalize_build_summary(
binlog::parse_build_from_text(&raw),
output.status.success(),
);
let summary = merge_build_summaries(binlog_summary, raw_summary);
format_build_output(&summary, &binlog_path)
}
"test" => {
// First try to parse from binlog/console output
let parsed_summary = if should_expect_binlog && binlog_path.exists() {
binlog::parse_test(&binlog_path).unwrap_or_default()
} else {
binlog::TestSummary::default()
};
let raw_summary = binlog::parse_test_from_text(&raw);
let merged_summary = merge_test_summaries(parsed_summary, raw_summary);
let summary = merge_test_summary_from_trx(
merged_summary,
trx_results_dir.as_deref(),
dotnet_trx::find_recent_trx_in_testresults(),
command_started_at,
);
let summary = normalize_test_summary(summary, output.status.success());
let binlog_diagnostics = if should_expect_binlog && binlog_path.exists() {
normalize_build_summary(
binlog::parse_build(&binlog_path).unwrap_or_default(),
output.status.success(),
)
} else {
binlog::BuildSummary::default()
};
let raw_diagnostics = normalize_build_summary(
binlog::parse_build_from_text(&raw),
output.status.success(),
);
let test_build_summary = merge_build_summaries(binlog_diagnostics, raw_diagnostics);
format_test_output(
&summary,
&test_build_summary.errors,
&test_build_summary.warnings,
&binlog_path,
)
}
"restore" => {
let binlog_summary = if should_expect_binlog && binlog_path.exists() {
normalize_restore_summary(
binlog::parse_restore(&binlog_path).unwrap_or_default(),
output.status.success(),
)
} else {
binlog::RestoreSummary::default()
};
let raw_summary = normalize_restore_summary(
binlog::parse_restore_from_text(&raw),
output.status.success(),
);
let summary = merge_restore_summaries(binlog_summary, raw_summary);
let (raw_errors, raw_warnings) = binlog::parse_restore_issues_from_text(&raw);
format_restore_output(&summary, &raw_errors, &raw_warnings, &binlog_path)
}
_ => raw.clone(),
};
let output_to_print = if !output.status.success() {
let stdout_trimmed = stdout.trim();
let stderr_trimmed = stderr.trim();
if !stdout_trimmed.is_empty() {
format!("{}\n\n{}", stdout_trimmed, filtered)
} else if !stderr_trimmed.is_empty() {
format!("{}\n\n{}", stderr_trimmed, filtered)
} else {
filtered
}
} else {
filtered
};
println!("{}", output_to_print);
timer.track(
&format!("dotnet {} {}", subcommand, args.join(" ")),
&format!("rtk dotnet {} {}", subcommand, args.join(" ")),
&raw,
&output_to_print,
);
cleanup_temp_file(&binlog_path);
if cleanup_trx_results_dir {
if let Some(dir) = trx_results_dir.as_deref() {
cleanup_temp_dir(dir);
}
}
if verbose > 0 {
eprintln!("Binlog cleaned up: {}", binlog_path.display());
}
if !output.status.success() {
std::process::exit(output.status.code().unwrap_or(1));
}
Ok(())
}
fn build_binlog_path(subcommand: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"rtk_dotnet_{}_{}.binlog",
subcommand,
unique_temp_suffix()
))
}
fn build_trx_results_dir() -> PathBuf {
std::env::temp_dir().join(format!("rtk_dotnet_testresults_{}", unique_temp_suffix()))
}
fn unique_temp_suffix() -> String {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let pid = std::process::id();
let seq = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
// Keep suffix compact to avoid long temp paths while preserving practical uniqueness.
format!("{:x}{:x}{:x}", ts, pid, seq)
}
fn resolve_trx_results_dir(subcommand: &str, args: &[String]) -> (Option<PathBuf>, bool) {
if subcommand != "test" {
return (None, false);
}
if let Some(user_dir) = extract_results_directory_arg(args) {
return (Some(user_dir), false);
}
(Some(build_trx_results_dir()), true)
}
fn build_format_report_path() -> PathBuf {
std::env::temp_dir().join(format!("rtk_dotnet_format_{}.json", unique_temp_suffix()))
}
fn resolve_format_report_path(args: &[String]) -> (Option<PathBuf>, bool) {
if let Some(user_report_path) = extract_report_arg(args) {
return (Some(user_report_path), false);
}
(Some(build_format_report_path()), true)
}
fn build_effective_dotnet_format_args(args: &[String], report_path: Option<&Path>) -> Vec<String> {
let mut effective: Vec<String> = args
.iter()
.filter(|arg| !arg.eq_ignore_ascii_case("--write"))
.cloned()
.collect();
let force_write_mode = has_write_mode_override(args);
if !force_write_mode && !has_verify_no_changes_arg(args) {
effective.push("--verify-no-changes".to_string());
}
if !has_report_arg(args) {
if let Some(path) = report_path {
effective.push("--report".to_string());
effective.push(path.display().to_string());
}
}
effective
}
fn format_report_summary_or_raw(
report_path: Option<&Path>,
check_mode: bool,
raw: &str,
command_started_at: SystemTime,
) -> String {
let Some(report_path) = report_path else {
return raw.to_string();
};
if !is_fresh_report(report_path, command_started_at) {
return raw.to_string();
}
match dotnet_format_report::parse_format_report(report_path) {
Ok(summary) => format_dotnet_format_output(&summary, check_mode),
Err(_) => raw.to_string(),
}
}
fn is_fresh_report(path: &Path, command_started_at: SystemTime) -> bool {
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
let Ok(modified_at) = metadata.modified() else {
return false;
};
modified_at.duration_since(command_started_at).is_ok()
}
fn format_dotnet_format_output(
summary: &dotnet_format_report::FormatSummary,
check_mode: bool,
) -> String {
let changed_count = summary.files_with_changes.len();
if changed_count == 0 {
return format!(
"ok dotnet format: {} files formatted correctly",
summary.total_files
);
}
if !check_mode {
return format!(
"ok dotnet format: formatted {} files ({} already formatted)",
changed_count, summary.files_unchanged
);
}
let mut output = format!("Format: {} files need formatting", changed_count);
output.push_str("\n---------------------------------------");
for (index, file) in summary.files_with_changes.iter().take(20).enumerate() {
let first_change = &file.changes[0];
let rule = if first_change.diagnostic_id.is_empty() {
first_change.format_description.as_str()
} else {
first_change.diagnostic_id.as_str()
};
output.push_str(&format!(
"\n{}. {} (line {}, col {}, {})",
index + 1,
file.path,
first_change.line_number,
first_change.char_number,
rule
));
}
if changed_count > 20 {
output.push_str(&format!("\n... +{} more files", changed_count - 20));
}
output.push_str(&format!(
"\n\nok {} files already formatted\nRun `dotnet format` to apply fixes",
summary.files_unchanged
));
output
}
fn cleanup_temp_file(path: &Path) {
if path.exists() {
std::fs::remove_file(path).ok();
}
}
fn cleanup_temp_dir(path: &Path) {
if path.exists() {
std::fs::remove_dir_all(path).ok();
}
}
fn merge_test_summary_from_trx(
mut summary: binlog::TestSummary,
trx_results_dir: Option<&Path>,
fallback_trx_path: Option<PathBuf>,
command_started_at: SystemTime,
) -> binlog::TestSummary {
let mut trx_summary = None;
if let Some(dir) = trx_results_dir.filter(|path| path.exists()) {
trx_summary = dotnet_trx::parse_trx_files_in_dir_since(dir, Some(command_started_at));
if trx_summary.is_none() {
trx_summary = dotnet_trx::parse_trx_files_in_dir(dir);
}
}
if trx_summary.is_none() {
if let Some(trx) = fallback_trx_path {
trx_summary = dotnet_trx::parse_trx_file_since(&trx, command_started_at);
}
}
let Some(trx_summary) = trx_summary else {
return summary;
};
if trx_summary.total > 0 && (summary.total == 0 || trx_summary.total >= summary.total) {
summary.passed = trx_summary.passed;
summary.failed = trx_summary.failed;
summary.skipped = trx_summary.skipped;
summary.total = trx_summary.total;
}
if summary.failed_tests.is_empty() && !trx_summary.failed_tests.is_empty() {
summary.failed_tests = trx_summary.failed_tests;
}
if let Some(duration) = trx_summary.duration_text {
summary.duration_text = Some(duration);
}
if trx_summary.project_count > summary.project_count {
summary.project_count = trx_summary.project_count;
}
summary
}
fn build_effective_dotnet_args(
subcommand: &str,
args: &[String],
binlog_path: &Path,
trx_results_dir: Option<&Path>,
) -> Vec<String> {
let mut effective = Vec::new();
if subcommand != "test" && !has_binlog_arg(args) {
effective.push(format!("-bl:{}", binlog_path.display()));
}
if subcommand != "test" && !has_verbosity_arg(args) {
effective.push("-v:minimal".to_string());
}
if !has_nologo_arg(args) {
effective.push("-nologo".to_string());
}
if subcommand == "test" {
if !has_trx_logger_arg(args) {
effective.push("--logger".to_string());
effective.push("trx".to_string());
}
if !has_results_directory_arg(args) {
if let Some(results_dir) = trx_results_dir {
effective.push("--results-directory".to_string());
effective.push(results_dir.display().to_string());
}
}
}
effective.extend(args.iter().cloned());
effective
}
fn has_binlog_arg(args: &[String]) -> bool {
args.iter().any(|arg| {
let lower = arg.to_ascii_lowercase();
lower.starts_with("-bl") || lower.starts_with("/bl")
})
}
fn has_verbosity_arg(args: &[String]) -> bool {
args.iter().any(|arg| {
let lower = arg.to_ascii_lowercase();
lower.starts_with("-v:")
|| lower.starts_with("/v:")
|| lower == "-v"
|| lower == "/v"
|| lower == "--verbosity"
|| lower.starts_with("--verbosity=")
})
}
fn has_nologo_arg(args: &[String]) -> bool {
args.iter()
.any(|arg| matches!(arg.to_ascii_lowercase().as_str(), "-nologo" | "/nologo"))
}
fn has_trx_logger_arg(args: &[String]) -> bool {
let mut iter = args.iter().peekable();
while let Some(arg) = iter.next() {
let lower = arg.to_ascii_lowercase();
if lower == "--logger" {
if let Some(next) = iter.peek() {
let next_lower = next.to_ascii_lowercase();
if next_lower == "trx" || next_lower.starts_with("trx;") {
return true;
}
}
continue;
}
for prefix in ["--logger:", "--logger="] {
if let Some(value) = lower.strip_prefix(prefix) {
if value == "trx" || value.starts_with("trx;") {
return true;
}
}
}
}
false
}
fn has_results_directory_arg(args: &[String]) -> bool {
args.iter().any(|arg| {
let lower = arg.to_ascii_lowercase();
lower == "--results-directory" || lower.starts_with("--results-directory=")
})
}
fn has_report_arg(args: &[String]) -> bool {
args.iter().any(|arg| {
let lower = arg.to_ascii_lowercase();
lower == "--report" || lower.starts_with("--report=")
})
}
fn extract_report_arg(args: &[String]) -> Option<PathBuf> {
let mut iter = args.iter().peekable();
while let Some(arg) = iter.next() {
if arg.eq_ignore_ascii_case("--report") {
if let Some(next) = iter.peek() {
return Some(PathBuf::from(next.as_str()));
}
continue;
}
if let Some((_, value)) = arg.split_once('=') {
if arg
.split('=')
.next()
.is_some_and(|key| key.eq_ignore_ascii_case("--report"))
{
return Some(PathBuf::from(value));
}
}
}
None
}
fn has_verify_no_changes_arg(args: &[String]) -> bool {
args.iter().any(|arg| {
let lower = arg.to_ascii_lowercase();
lower == "--verify-no-changes" || lower.starts_with("--verify-no-changes=")
})
}
fn has_write_mode_override(args: &[String]) -> bool {
args.iter().any(|arg| arg.eq_ignore_ascii_case("--write"))
}
fn extract_results_directory_arg(args: &[String]) -> Option<PathBuf> {
let mut iter = args.iter().peekable();
while let Some(arg) = iter.next() {
if arg.eq_ignore_ascii_case("--results-directory") {
if let Some(next) = iter.peek() {
return Some(PathBuf::from(next.as_str()));
}
continue;
}
if let Some((_, value)) = arg.split_once('=') {
if arg
.split('=')
.next()
.is_some_and(|key| key.eq_ignore_ascii_case("--results-directory"))
{
return Some(PathBuf::from(value));
}
}
}
None
}
fn normalize_build_summary(
mut summary: binlog::BuildSummary,
command_success: bool,
) -> binlog::BuildSummary {
if command_success {
summary.succeeded = true;
if summary.project_count == 0 {
summary.project_count = 1;
}
}
summary
}
fn merge_build_summaries(
mut binlog_summary: binlog::BuildSummary,
raw_summary: binlog::BuildSummary,
) -> binlog::BuildSummary {
if binlog_summary.errors.is_empty() {
binlog_summary.errors = raw_summary.errors;
}
if binlog_summary.warnings.is_empty() {
binlog_summary.warnings = raw_summary.warnings;
}
if binlog_summary.project_count == 0 {
binlog_summary.project_count = raw_summary.project_count;
}
if binlog_summary.duration_text.is_none() {
binlog_summary.duration_text = raw_summary.duration_text;
}
binlog_summary
}
fn normalize_test_summary(
mut summary: binlog::TestSummary,
command_success: bool,
) -> binlog::TestSummary {
if !command_success && summary.failed == 0 && summary.failed_tests.is_empty() {
summary.failed = 1;
if summary.total == 0 {
summary.total = 1;
}
}
if command_success && summary.total == 0 && summary.passed == 0 {
summary.project_count = summary.project_count.max(1);
}
summary
}
fn merge_test_summaries(
mut binlog_summary: binlog::TestSummary,
raw_summary: binlog::TestSummary,
) -> binlog::TestSummary {
if binlog_summary.total == 0 && raw_summary.total > 0 {
binlog_summary.passed = raw_summary.passed;
binlog_summary.failed = raw_summary.failed;
binlog_summary.skipped = raw_summary.skipped;
binlog_summary.total = raw_summary.total;
}
if !raw_summary.failed_tests.is_empty() {
binlog_summary.failed_tests = raw_summary.failed_tests;
}
if binlog_summary.project_count == 0 {
binlog_summary.project_count = raw_summary.project_count;
}
if binlog_summary.duration_text.is_none() {
binlog_summary.duration_text = raw_summary.duration_text;
}
binlog_summary
}
fn normalize_restore_summary(
mut summary: binlog::RestoreSummary,
command_success: bool,
) -> binlog::RestoreSummary {
if !command_success && summary.errors == 0 {
summary.errors = 1;
}
summary
}
fn merge_restore_summaries(
mut binlog_summary: binlog::RestoreSummary,
raw_summary: binlog::RestoreSummary,
) -> binlog::RestoreSummary {
if binlog_summary.restored_projects == 0 {
binlog_summary.restored_projects = raw_summary.restored_projects;
}
if binlog_summary.errors == 0 {
binlog_summary.errors = raw_summary.errors;
}
if binlog_summary.warnings == 0 {
binlog_summary.warnings = raw_summary.warnings;
}
if binlog_summary.duration_text.is_none() {
binlog_summary.duration_text = raw_summary.duration_text;
}
binlog_summary
}
fn format_issue(issue: &binlog::BinlogIssue, kind: &str) -> String {
if issue.file.is_empty() {
return format!(" {} {}", kind, truncate(&issue.message, 180));
}
if issue.code.is_empty() {
return format!(
" {}({},{}) {}: {}",
issue.file,
issue.line,
issue.column,
kind,
truncate(&issue.message, 180)
);
}
format!(
" {}({},{}) {} {}: {}",
issue.file,
issue.line,
issue.column,
kind,
issue.code,
truncate(&issue.message, 180)
)
}
fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> String {
let status_icon = if summary.succeeded { "ok" } else { "fail" };
let duration = summary.duration_text.as_deref().unwrap_or("unknown");
let mut out = format!(
"{} dotnet build: {} projects, {} errors, {} warnings ({})",
status_icon,
summary.project_count,
summary.errors.len(),
summary.warnings.len(),
duration
);
if !summary.errors.is_empty() {
out.push_str("\n---------------------------------------\n\nErrors:\n");
for issue in summary.errors.iter().take(20) {
out.push_str(&format!("{}\n", format_issue(issue, "error")));
}
if summary.errors.len() > 20 {
out.push_str(&format!(
" ... +{} more errors\n",
summary.errors.len() - 20
));
}
}
if !summary.warnings.is_empty() {
out.push_str("\nWarnings:\n");
for issue in summary.warnings.iter().take(10) {
out.push_str(&format!("{}\n", format_issue(issue, "warning")));
}
if summary.warnings.len() > 10 {
out.push_str(&format!(
" ... +{} more warnings\n",
summary.warnings.len() - 10
));
}
}
// Binlog path omitted from output (temp file, already cleaned up)
out
}
fn format_test_output(
summary: &binlog::TestSummary,
errors: &[binlog::BinlogIssue],
warnings: &[binlog::BinlogIssue],
_binlog_path: &Path,
) -> String {
let has_failures = summary.failed > 0 || !summary.failed_tests.is_empty();
let status_icon = if has_failures { "fail" } else { "ok" };
let duration = summary.duration_text.as_deref().unwrap_or("unknown");
let warning_count = warnings.len();
let counts_unavailable = summary.passed == 0
&& summary.failed == 0
&& summary.skipped == 0
&& summary.total == 0
&& summary.failed_tests.is_empty();
let mut out = if counts_unavailable {
format!(
"{} dotnet test: completed (binlog-only mode, counts unavailable, {} warnings) ({})",
status_icon, warning_count, duration
)
} else if has_failures {
format!(
"{} dotnet test: {} passed, {} failed, {} skipped, {} warnings in {} projects ({})",
status_icon,
summary.passed,
summary.failed,
summary.skipped,
warning_count,
summary.project_count,
duration
)
} else {
format!(
"{} dotnet test: {} tests passed, {} warnings in {} projects ({})",
status_icon, summary.passed, warning_count, summary.project_count, duration
)
};
if has_failures && !summary.failed_tests.is_empty() {
out.push_str("\n---------------------------------------\n\nFailed Tests:\n");
for failed in summary.failed_tests.iter().take(15) {
out.push_str(&format!(" {}\n", failed.name));
for detail in &failed.details {
out.push_str(&format!(" {}\n", truncate(detail, 320)));
}
out.push('\n');
}
if summary.failed_tests.len() > 15 {
out.push_str(&format!(
"... +{} more failed tests\n",
summary.failed_tests.len() - 15
));
}
}
if !errors.is_empty() {
out.push_str("\nErrors:\n");
for issue in errors.iter().take(10) {
out.push_str(&format!("{}\n", format_issue(issue, "error")));
}
if errors.len() > 10 {
out.push_str(&format!(" ... +{} more errors\n", errors.len() - 10));
}
}
if !warnings.is_empty() {
out.push_str("\nWarnings:\n");
for issue in warnings.iter().take(10) {
out.push_str(&format!("{}\n", format_issue(issue, "warning")));
}
if warnings.len() > 10 {
out.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10));
}
}
// Binlog path omitted from output (temp file, already cleaned up)
out
}
fn format_restore_output(
summary: &binlog::RestoreSummary,
errors: &[binlog::BinlogIssue],
warnings: &[binlog::BinlogIssue],
_binlog_path: &Path,
) -> String {
let has_errors = summary.errors > 0;
let status_icon = if has_errors { "fail" } else { "ok" };
let duration = summary.duration_text.as_deref().unwrap_or("unknown");
let mut out = format!(
"{} dotnet restore: {} projects, {} errors, {} warnings ({})",
status_icon, summary.restored_projects, summary.errors, summary.warnings, duration
);
if !errors.is_empty() {
out.push_str("\n---------------------------------------\n\nErrors:\n");
for issue in errors.iter().take(20) {
out.push_str(&format!("{}\n", format_issue(issue, "error")));
}
if errors.len() > 20 {
out.push_str(&format!(" ... +{} more errors\n", errors.len() - 20));
}
}
if !warnings.is_empty() {
out.push_str("\nWarnings:\n");
for issue in warnings.iter().take(10) {
out.push_str(&format!("{}\n", format_issue(issue, "warning")));
}
if warnings.len() > 10 {
out.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10));
}
}
// Binlog path omitted from output (temp file, already cleaned up)
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dotnet_format_report;
use std::fs;
use std::time::Duration;
fn build_dotnet_args_for_test(
subcommand: &str,
args: &[String],
with_trx: bool,
) -> Vec<String> {
let binlog_path = Path::new("/tmp/test.binlog");
let trx_results_dir = if with_trx {
Some(Path::new("/tmp/test results"))
} else {
None
};
build_effective_dotnet_args(subcommand, args, binlog_path, trx_results_dir)
}
fn trx_with_counts(total: usize, passed: usize, failed: usize) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<TestRun xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
<ResultSummary outcome="Completed">
<Counters total="{}" executed="{}" passed="{}" failed="{}" error="0" />
</ResultSummary>
</TestRun>"#,
total, total, passed, failed
)
}
fn format_fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("dotnet")
.join(name)
}
#[test]
fn test_has_binlog_arg_detects_variants() {
let args = vec!["-bl:my.binlog".to_string()];
assert!(has_binlog_arg(&args));
let args = vec!["/bl".to_string()];
assert!(has_binlog_arg(&args));
let args = vec!["--configuration".to_string(), "Release".to_string()];
assert!(!has_binlog_arg(&args));
}
#[test]
fn test_format_build_output_includes_errors_and_warnings() {
let summary = binlog::BuildSummary {
succeeded: false,
project_count: 2,
errors: vec![binlog::BinlogIssue {
code: "CS0103".to_string(),
file: "src/Program.cs".to_string(),
line: 42,
column: 15,
message: "The name 'foo' does not exist".to_string(),
}],
warnings: vec![binlog::BinlogIssue {
code: "CS0219".to_string(),
file: "src/Program.cs".to_string(),
line: 25,
column: 10,
message: "Variable 'x' is assigned but never used".to_string(),
}],
duration_text: Some("00:00:04.20".to_string()),
};
let output = format_build_output(&summary, Path::new("/tmp/build.binlog"));
assert!(output.contains("dotnet build: 2 projects, 1 errors, 1 warnings"));
assert!(output.contains("error CS0103"));
assert!(output.contains("warning CS0219"));
}
#[test]
fn test_format_test_output_shows_failures() {
let summary = binlog::TestSummary {
passed: 10,
failed: 1,
skipped: 0,
total: 11,
project_count: 1,
failed_tests: vec![binlog::FailedTest {
name: "MyTests.ShouldFail".to_string(),
details: vec!["Assert.Equal failure".to_string()],
}],
duration_text: Some("1 s".to_string()),
};
let output = format_test_output(&summary, &[], &[], Path::new("/tmp/test.binlog"));
assert!(output.contains("10 passed, 1 failed"));
assert!(output.contains("MyTests.ShouldFail"));
}
#[test]
fn test_format_test_output_surfaces_warnings() {
let summary = binlog::TestSummary {
passed: 940,
failed: 0,
skipped: 7,
total: 947,
project_count: 1,
failed_tests: Vec::new(),
duration_text: Some("1 s".to_string()),
};
let warnings = vec![binlog::BinlogIssue {
code: String::new(),
file: "/sdk/Microsoft.TestPlatform.targets".to_string(),
line: 48,
column: 5,
message: "Violators:".to_string(),
}];
let output = format_test_output(&summary, &[], &warnings, Path::new("/tmp/test.binlog"));
assert!(output.contains("940 tests passed, 1 warnings"));
assert!(output.contains("Warnings:"));
assert!(output.contains("Microsoft.TestPlatform.targets"));
}
#[test]
fn test_format_test_output_surfaces_errors() {
let summary = binlog::TestSummary {
passed: 939,
failed: 1,
skipped: 7,
total: 947,
project_count: 1,
failed_tests: Vec::new(),
duration_text: Some("1 s".to_string()),
};
let errors = vec![binlog::BinlogIssue {
code: "TESTERROR".to_string(),
file: "/repo/MessageMapperTests.cs".to_string(),
line: 135,
column: 0,
message: "CreateInstance_should_initialize_interface_message_type_on_demand"
.to_string(),
}];
let output = format_test_output(&summary, &errors, &[], Path::new("/tmp/test.binlog"));
assert!(output.contains("Errors:"));
assert!(output.contains("error TESTERROR"));
assert!(
output.contains("CreateInstance_should_initialize_interface_message_type_on_demand")
);
}
#[test]
fn test_format_restore_output_success() {
let summary = binlog::RestoreSummary {
restored_projects: 3,
warnings: 1,
errors: 0,
duration_text: Some("00:00:01.10".to_string()),
};
let output = format_restore_output(&summary, &[], &[], Path::new("/tmp/restore.binlog"));
assert!(output.starts_with("ok dotnet restore"));
assert!(output.contains("3 projects"));
assert!(output.contains("1 warnings"));
}
#[test]
fn test_format_restore_output_failure() {
let summary = binlog::RestoreSummary {
restored_projects: 2,
warnings: 0,
errors: 1,
duration_text: Some("00:00:01.00".to_string()),
};
let output = format_restore_output(&summary, &[], &[], Path::new("/tmp/restore.binlog"));
assert!(output.starts_with("fail dotnet restore"));
assert!(output.contains("1 errors"));
}
#[test]
fn test_format_restore_output_includes_error_details() {
let summary = binlog::RestoreSummary {
restored_projects: 2,
warnings: 0,
errors: 1,
duration_text: Some("00:00:01.00".to_string()),
};
let issues = vec![binlog::BinlogIssue {
code: "NU1101".to_string(),
file: "/repo/src/App/App.csproj".to_string(),
line: 0,
column: 0,
message: "Unable to find package Foo.Bar".to_string(),
}];
let output =
format_restore_output(&summary, &issues, &[], Path::new("/tmp/restore.binlog"));
assert!(output.contains("Errors:"));
assert!(output.contains("error NU1101"));
assert!(output.contains("Unable to find package Foo.Bar"));
}
#[test]
fn test_format_test_output_handles_binlog_only_without_counts() {
let summary = binlog::TestSummary {
passed: 0,
failed: 0,
skipped: 0,
total: 0,
project_count: 0,
failed_tests: Vec::new(),
duration_text: Some("unknown".to_string()),
};
let output = format_test_output(&summary, &[], &[], Path::new("/tmp/test.binlog"));
assert!(output.contains("counts unavailable"));
}
#[test]
fn test_normalize_build_summary_sets_success_floor() {
let summary = binlog::BuildSummary {
succeeded: false,
project_count: 0,
errors: Vec::new(),
warnings: Vec::new(),
duration_text: None,
};
let normalized = normalize_build_summary(summary, true);
assert!(normalized.succeeded);
assert_eq!(normalized.project_count, 1);
}
#[test]
fn test_merge_build_summaries_keeps_structured_issues_when_present() {
let binlog_summary = binlog::BuildSummary {
succeeded: false,
project_count: 11,
errors: vec![binlog::BinlogIssue {
code: String::new(),
file: "IDE0055".to_string(),
line: 0,
column: 0,
message: "Fix formatting".to_string(),
}],
warnings: Vec::new(),
duration_text: Some("00:00:03.54".to_string()),
};
let raw_summary = binlog::BuildSummary {
succeeded: false,
project_count: 2,
errors: vec![
binlog::BinlogIssue {
code: "IDE0055".to_string(),
file: "/repo/src/Behavior.cs".to_string(),
line: 13,
column: 32,
message: "Fix formatting".to_string(),
},
binlog::BinlogIssue {
code: "IDE0055".to_string(),
file: "/repo/src/Behavior.cs".to_string(),
line: 13,
column: 41,
message: "Fix formatting".to_string(),
},
],
warnings: Vec::new(),
duration_text: Some("00:00:03.54".to_string()),
};
let merged = merge_build_summaries(binlog_summary, raw_summary);
assert_eq!(merged.project_count, 11);
assert_eq!(merged.errors.len(), 1);
assert_eq!(merged.errors[0].file, "IDE0055");
assert_eq!(merged.errors[0].line, 0);
assert_eq!(merged.errors[0].column, 0);
}
#[test]
fn test_merge_build_summaries_keeps_binlog_when_context_is_good() {
let binlog_summary = binlog::BuildSummary {
succeeded: false,
project_count: 2,
errors: vec![binlog::BinlogIssue {
code: "CS0103".to_string(),
file: "src/Program.cs".to_string(),
line: 42,
column: 15,
message: "The name 'foo' does not exist".to_string(),
}],
warnings: Vec::new(),
duration_text: Some("00:00:01.00".to_string()),
};
let raw_summary = binlog::BuildSummary {
succeeded: false,
project_count: 2,
errors: vec![binlog::BinlogIssue {
code: "CS0103".to_string(),
file: String::new(),
line: 0,
column: 0,
message: "Build error #1 (details omitted)".to_string(),
}],
warnings: Vec::new(),
duration_text: None,
};
let merged = merge_build_summaries(binlog_summary.clone(), raw_summary);
assert_eq!(merged.errors, binlog_summary.errors);
}
#[test]
fn test_normalize_test_summary_sets_failure_floor() {
let summary = binlog::TestSummary {
passed: 0,
failed: 0,
skipped: 0,
total: 0,
project_count: 0,
failed_tests: Vec::new(),
duration_text: None,
};
let normalized = normalize_test_summary(summary, false);
assert_eq!(normalized.failed, 1);
assert_eq!(normalized.total, 1);
}
#[test]
fn test_merge_test_summaries_keeps_structured_counts_and_fills_failed_tests() {
let binlog_summary = binlog::TestSummary {
passed: 939,
failed: 1,
skipped: 8,
total: 948,
project_count: 1,
failed_tests: Vec::new(),
duration_text: Some("unknown".to_string()),
};
let raw_summary = binlog::TestSummary {
passed: 939,
failed: 1,
skipped: 7,
total: 947,
project_count: 0,
failed_tests: vec![binlog::FailedTest {
name: "MessageMapperTests.CreateInstance_should_initialize_interface_message_type_on_demand"
.to_string(),
details: vec!["Assert.That(messageInstance, Is.Null)".to_string()],
}],
duration_text: Some("1 s".to_string()),
};
let merged = merge_test_summaries(binlog_summary, raw_summary);
assert_eq!(merged.skipped, 8);
assert_eq!(merged.total, 948);
assert_eq!(merged.failed_tests.len(), 1);
assert!(merged.failed_tests[0]
.name
.contains("CreateInstance_should_initialize"));
}
#[test]
fn test_normalize_restore_summary_sets_error_floor_on_failed_command() {
let summary = binlog::RestoreSummary {
restored_projects: 2,
warnings: 0,
errors: 0,
duration_text: None,
};
let normalized = normalize_restore_summary(summary, false);
assert_eq!(normalized.errors, 1);
}
#[test]
fn test_merge_restore_summaries_prefers_raw_error_count() {
let binlog_summary = binlog::RestoreSummary {
restored_projects: 2,
warnings: 0,
errors: 0,
duration_text: Some("unknown".to_string()),
};
let raw_summary = binlog::RestoreSummary {
restored_projects: 0,
warnings: 0,
errors: 1,
duration_text: Some("unknown".to_string()),
};
let merged = merge_restore_summaries(binlog_summary, raw_summary);
assert_eq!(merged.errors, 1);
assert_eq!(merged.restored_projects, 2);
}
#[test]
fn test_forwarding_args_with_spaces() {
let args = vec![
"--filter".to_string(),
"FullyQualifiedName~MyTests.Calculator*".to_string(),
"-c".to_string(),
"Release".to_string(),
];
let injected = build_dotnet_args_for_test("test", &args, true);
assert!(injected.contains(&"--filter".to_string()));
assert!(injected.contains(&"FullyQualifiedName~MyTests.Calculator*".to_string()));
assert!(injected.contains(&"-c".to_string()));
assert!(injected.contains(&"Release".to_string()));
}
#[test]
fn test_forwarding_config_and_framework() {
let args = vec![
"--configuration".to_string(),
"Release".to_string(),
"--framework".to_string(),
"net8.0".to_string(),
];
let injected = build_dotnet_args_for_test("test", &args, true);
assert!(injected.contains(&"--configuration".to_string()));
assert!(injected.contains(&"Release".to_string()));
assert!(injected.contains(&"--framework".to_string()));
assert!(injected.contains(&"net8.0".to_string()));
}
#[test]
fn test_forwarding_project_file() {
let args = vec![
"--project".to_string(),
"src/My App.Tests/My App.Tests.csproj".to_string(),
];
let injected = build_dotnet_args_for_test("test", &args, true);
assert!(injected.contains(&"--project".to_string()));
assert!(injected.contains(&"src/My App.Tests/My App.Tests.csproj".to_string()));
}
#[test]
fn test_forwarding_no_build_and_no_restore() {
let args = vec!["--no-build".to_string(), "--no-restore".to_string()];
let injected = build_dotnet_args_for_test("test", &args, true);
assert!(injected.contains(&"--no-build".to_string()));
assert!(injected.contains(&"--no-restore".to_string()));
}
#[test]
fn test_user_verbose_override() {
let args = vec!["-v:detailed".to_string()];
let injected = build_dotnet_args_for_test("test", &args, true);
let verbose_count = injected.iter().filter(|a| a.starts_with("-v:")).count();
assert_eq!(verbose_count, 1);
assert!(injected.contains(&"-v:detailed".to_string()));
assert!(!injected.contains(&"-v:minimal".to_string()));
}
#[test]
fn test_user_long_verbosity_override() {
let args = vec!["--verbosity".to_string(), "detailed".to_string()];
let injected = build_dotnet_args_for_test("build", &args, false);
assert!(injected.contains(&"--verbosity".to_string()));
assert!(injected.contains(&"detailed".to_string()));
assert!(!injected.contains(&"-v:minimal".to_string()));
}
#[test]
fn test_test_subcommand_does_not_inject_minimal_verbosity_by_default() {
let args = Vec::<String>::new();
let injected = build_dotnet_args_for_test("test", &args, true);
assert!(!injected.contains(&"-v:minimal".to_string()));
}
#[test]
fn test_user_logger_override() {
let args = vec![
"--logger".to_string(),
"console;verbosity=detailed".to_string(),
];
let injected = build_dotnet_args_for_test("test", &args, true);
assert!(injected.contains(&"--logger".to_string()));
assert!(injected.contains(&"console;verbosity=detailed".to_string()));
assert!(injected.iter().any(|a| a == "trx"));
assert!(injected.iter().any(|a| a == "--results-directory"));
}
#[test]
fn test_trx_logger_and_results_directory_injected() {
let args = Vec::<String>::new();
let injected = build_dotnet_args_for_test("test", &args, true);
assert!(injected.contains(&"--logger".to_string()));
assert!(injected.contains(&"trx".to_string()));
assert!(injected.contains(&"--results-directory".to_string()));
assert!(injected.contains(&"/tmp/test results".to_string()));
}
#[test]
fn test_user_trx_logger_does_not_duplicate() {
let args = vec!["--logger".to_string(), "trx".to_string()];
let injected = build_dotnet_args_for_test("test", &args, true);
let trx_logger_count = injected.iter().filter(|a| *a == "trx").count();
assert_eq!(trx_logger_count, 1);
}
#[test]
fn test_user_results_directory_prevents_extra_injection() {
let args = vec![
"--results-directory".to_string(),
"/custom/results".to_string(),
];
let injected = build_dotnet_args_for_test("test", &args, true);
assert!(!injected
.windows(2)
.any(|w| w[0] == "--results-directory" && w[1] == "/tmp/test results"));
assert!(injected
.windows(2)
.any(|w| w[0] == "--results-directory" && w[1] == "/custom/results"));
}
#[test]
fn test_merge_test_summary_from_trx_uses_primary_and_cleans_file() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let primary = temp_dir.path().join("primary.trx");
fs::write(&primary, trx_with_counts(3, 3, 0)).expect("write primary trx");
let filled = merge_test_summary_from_trx(
binlog::TestSummary::default(),
Some(temp_dir.path()),
None,
SystemTime::now(),
);
assert_eq!(filled.total, 3);
assert_eq!(filled.passed, 3);
assert!(primary.exists());
}
#[test]
fn test_merge_test_summary_from_trx_falls_back_to_testresults() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let fallback = temp_dir.path().join("fallback.trx");
fs::write(&fallback, trx_with_counts(2, 1, 1)).expect("write fallback trx");
let missing_primary = temp_dir.path().join("missing.trx");
let filled = merge_test_summary_from_trx(
binlog::TestSummary::default(),
Some(&missing_primary),
Some(fallback.clone()),
UNIX_EPOCH,
);
assert_eq!(filled.total, 2);
assert_eq!(filled.failed, 1);
assert!(fallback.exists());
}
#[test]
fn test_merge_test_summary_from_trx_returns_default_when_no_trx() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let missing = temp_dir.path().join("missing.trx");
let filled = merge_test_summary_from_trx(
binlog::TestSummary::default(),
Some(&missing),
None,
SystemTime::now(),
);
assert_eq!(filled.total, 0);
}
#[test]
fn test_merge_test_summary_from_trx_ignores_stale_fallback_file() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let fallback = temp_dir.path().join("fallback.trx");
fs::write(&fallback, trx_with_counts(2, 1, 1)).expect("write fallback trx");
std::thread::sleep(std::time::Duration::from_millis(5));
let command_started_at = SystemTime::now();
let missing_primary = temp_dir.path().join("missing.trx");
let filled = merge_test_summary_from_trx(
binlog::TestSummary::default(),
Some(&missing_primary),
Some(fallback.clone()),
command_started_at,
);
assert_eq!(filled.total, 0);
assert!(fallback.exists());
}
#[test]
fn test_merge_test_summary_from_trx_keeps_larger_existing_counts() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let primary = temp_dir.path().join("primary.trx");
fs::write(&primary, trx_with_counts(5, 4, 1)).expect("write primary trx");
let existing = binlog::TestSummary {
passed: 10,
failed: 2,
skipped: 0,
total: 12,
project_count: 1,
failed_tests: Vec::new(),
duration_text: Some("1 s".to_string()),
};
let merged =
merge_test_summary_from_trx(existing, Some(temp_dir.path()), None, SystemTime::now());
assert_eq!(merged.total, 12);
assert_eq!(merged.passed, 10);
assert_eq!(merged.failed, 2);
}
#[test]
fn test_merge_test_summary_from_trx_overrides_smaller_existing_counts() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let primary = temp_dir.path().join("primary.trx");
fs::write(&primary, trx_with_counts(12, 10, 2)).expect("write primary trx");
let existing = binlog::TestSummary {
passed: 4,
failed: 1,
skipped: 0,
total: 5,
project_count: 1,
failed_tests: Vec::new(),
duration_text: Some("1 s".to_string()),
};
let merged =
merge_test_summary_from_trx(existing, Some(temp_dir.path()), None, SystemTime::now());
assert_eq!(merged.total, 12);
assert_eq!(merged.passed, 10);
assert_eq!(merged.failed, 2);
}
#[test]
fn test_merge_test_summary_from_trx_uses_larger_project_count() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let trx_a = temp_dir.path().join("a.trx");
let trx_b = temp_dir.path().join("b.trx");
fs::write(&trx_a, trx_with_counts(2, 2, 0)).expect("write first trx");
fs::write(&trx_b, trx_with_counts(3, 3, 0)).expect("write second trx");
let existing = binlog::TestSummary {
passed: 5,
failed: 0,
skipped: 0,
total: 5,
project_count: 1,
failed_tests: Vec::new(),
duration_text: Some("1 s".to_string()),
};
let merged =
merge_test_summary_from_trx(existing, Some(temp_dir.path()), None, SystemTime::now());
assert_eq!(merged.project_count, 2);
}
#[test]
fn test_has_results_directory_arg_detects_variants() {
let args = vec!["--results-directory".to_string(), "/tmp/trx".to_string()];
assert!(has_results_directory_arg(&args));
let args = vec!["--results-directory=/tmp/trx".to_string()];
assert!(has_results_directory_arg(&args));
let args = vec!["--logger".to_string(), "trx".to_string()];
assert!(!has_results_directory_arg(&args));
}
#[test]
fn test_extract_results_directory_arg_detects_variants() {
let args = vec!["--results-directory".to_string(), "/tmp/r1".to_string()];
assert_eq!(
extract_results_directory_arg(&args),
Some(PathBuf::from("/tmp/r1"))
);
let args = vec!["--results-directory=/tmp/r2".to_string()];
assert_eq!(
extract_results_directory_arg(&args),
Some(PathBuf::from("/tmp/r2"))
);
}
#[test]
fn test_resolve_trx_results_dir_user_directory_is_not_marked_for_cleanup() {
let args = vec![
"--results-directory".to_string(),
"/custom/results".to_string(),
];
let (dir, cleanup) = resolve_trx_results_dir("test", &args);
assert_eq!(dir, Some(PathBuf::from("/custom/results")));
assert!(!cleanup);
}
#[test]
fn test_resolve_trx_results_dir_generated_directory_is_marked_for_cleanup() {
let args = Vec::<String>::new();
let (dir, cleanup) = resolve_trx_results_dir("test", &args);
assert!(dir.is_some());
assert!(cleanup);
}
#[test]
fn test_format_all_formatted() {
let summary =
dotnet_format_report::parse_format_report(&format_fixture("format_success.json"))
.expect("parse format report");
let output = format_dotnet_format_output(&summary, true);
assert!(output.contains("ok dotnet format: 2 files formatted correctly"));
}
#[test]
fn test_format_needs_formatting() {
let summary =
dotnet_format_report::parse_format_report(&format_fixture("format_changes.json"))
.expect("parse format report");
let output = format_dotnet_format_output(&summary, true);
assert!(output.contains("Format: 2 files need formatting"));
assert!(output.contains("src/Program.cs (line 42, col 17, WHITESPACE)"));
assert!(output.contains("Run `dotnet format` to apply fixes"));
}
#[test]
fn test_format_temp_file_cleanup() {
let args = Vec::<String>::new();
let (report_path, cleanup) = resolve_format_report_path(&args);
let report_path = report_path.expect("report path");
assert!(cleanup);
fs::write(&report_path, "[]").expect("write temp report");
cleanup_temp_file(&report_path);
assert!(!report_path.exists());
}
#[test]
fn test_format_user_report_arg_no_cleanup() {
let args = vec![
"--report".to_string(),
"/tmp/user-format-report.json".to_string(),
];
let (report_path, cleanup) = resolve_format_report_path(&args);
assert_eq!(
report_path,
Some(PathBuf::from("/tmp/user-format-report.json"))
);
assert!(!cleanup);
}
#[test]
fn test_format_preserves_positional_project_argument_order() {
let args = vec!["src/App/App.csproj".to_string()];
let effective =
build_effective_dotnet_format_args(&args, Some(Path::new("/tmp/report.json")));
assert_eq!(
effective.first().map(String::as_str),
Some("src/App/App.csproj")
);
}
#[test]
fn test_format_report_summary_ignores_stale_report_file() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let report = temp_dir.path().join("report.json");
fs::write(&report, "[]").expect("write report");
let command_started_at = SystemTime::now()
.checked_add(Duration::from_secs(2))
.expect("future timestamp");
let raw = "RAW OUTPUT";
let output = format_report_summary_or_raw(Some(&report), true, raw, command_started_at);
assert_eq!(output, raw);
}
#[test]
fn test_format_report_summary_uses_fresh_report_file() {
let report = format_fixture("format_success.json");
let raw = "RAW OUTPUT";
let output = format_report_summary_or_raw(Some(&report), true, raw, UNIX_EPOCH);
assert!(output.contains("ok dotnet format: 2 files formatted correctly"));
}
#[test]
fn test_cleanup_temp_file_removes_existing_file() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let temp_file = temp_dir.path().join("temp.binlog");
fs::write(&temp_file, "content").expect("write temp file");
cleanup_temp_file(&temp_file);
assert!(!temp_file.exists());
}
#[test]
fn test_cleanup_temp_file_ignores_missing_file() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let missing_file = temp_dir.path().join("missing.binlog");
cleanup_temp_file(&missing_file);
assert!(!missing_file.exists());
}
}