//! Integration tests for the `dotnetshape` command. use std::fs; use std::path::Path; use assert_cmd::Command; use predicates::prelude::*; use serde_json::Value; use tempfile::TempDir; fn cargo_command() -> Command { Command::cargo_bin("dotnetshape").expect("binary") } fn write_file(path: &Path, contents: &str) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("parent directory"); } fs::write(path, contents).expect("write fixture"); } fn json_report(root: &Path) -> Value { let output = Command::cargo_bin("dotnetshape") .expect("binary") .arg("--json") .arg(root) .output() .expect("run dotnetshape"); assert!( output.status.success(), "dotnetshape failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); serde_json::from_slice(&output.stdout).expect("json output") } fn fixture_repo() -> TempDir { let temp = tempfile::tempdir().expect("tempdir"); write_file( &temp.path().join("Directory.Build.props"), r" enable true AllEnabledByDefault artifacts/bin/ InheritedName ", ); write_file( &temp.path().join("Directory.Build.targets"), r" Exe ", ); write_file( &temp.path().join("Directory.Packages.props"), r#" "#, ); write_file( &temp.path().join("repo.sln"), "Microsoft Visual Studio Solution File\n", ); write_file( &temp.path().join("src").join("App").join("App.csproj"), r#" net8.0 SharedAssembly Library artifacts/shared/ disable ..\lib\Legacy.dll false ..\lib\Implicit.dll "#, ); write_file( &temp.path().join("src").join("Lib").join("Lib.csproj"), r#" netstandard2.0;net8.0 SharedAssembly artifacts/shared/ ..\lib\PrivateLegacy.dll true "#, ); write_file( &temp .path() .join("tests") .join("Unit.Tests") .join("Unit.Tests.csproj"), r#" net8.0 true "#, ); write_file( &temp.path().join("build.ps1"), "Write-Host build\ncsc.exe Program.cs\ndotnet build src/App/App.csproj\n", ); temp } #[test] fn help_lists_shared_and_repo_options() { let mut command = cargo_command(); command .arg("--help") .assert() .success() .stdout(predicate::str::contains("--format ")) .stdout(predicate::str::contains("--json")) .stdout(predicate::str::contains("--toon")) .stdout(predicate::str::contains("--max-depth ")) .stdout(predicate::str::contains("--hidden")) .stdout(predicate::str::contains("dotnetshape . --json")); } #[test] #[allow(clippy::too_many_lines)] fn json_reports_static_project_shape_and_diagnostics() { let temp = fixture_repo(); let report = json_report(temp.path()); assert_eq!(report["evaluation_mode"], "static_ancestor_merge"); let projects = report["projects"].as_array().expect("projects"); assert_eq!(projects.len(), 3); let app = projects .iter() .find(|project| project["path"] == "src/App/App.csproj") .expect("app project"); assert_eq!(app["sdk"], "Microsoft.NET.Sdk"); assert_eq!(app["target_frameworks"], serde_json::json!(["net8.0"])); assert_eq!(app["assembly_name"], "SharedAssembly"); assert_eq!(app["output_type"], "Library"); assert_eq!(app["nullable"], "enable"); assert_eq!(app["treat_warnings_as_errors"], "true"); assert_eq!(app["analysis_mode"], "AllEnabledByDefault"); assert_eq!(app["kind"], "production"); assert!( app["conditioned_properties"] .as_array() .expect("conditioned properties") .iter() .any(|property| property["name"] == "Nullable" && property["value"] == "disable" && property["condition"] .as_str() .is_some_and(|condition| condition.contains("Release"))) ); let references = report["project_references"] .as_array() .expect("project refs"); assert!( references .iter() .any(|edge| edge["from"] == "src/App/App.csproj" && edge["to"] == "src/Lib/Lib.csproj" && edge["resolved"] == true) ); assert!( references .iter() .any(|edge| edge["from"] == "src/App/App.csproj" && edge["to"] == "src/Missing/Missing.csproj" && edge["resolved"] == false) ); let hint_refs = report["reference_hints"].as_array().expect("hint refs"); assert!(hint_refs.iter().any(|edge| edge["include"] == "Legacy" && edge["hint_path"] == "../lib/Legacy.dll" && edge["private"] == false)); assert!( hint_refs .iter() .any(|edge| edge["include"] == "Implicit" && edge["private"].is_null()) ); assert!( hint_refs .iter() .any(|edge| edge["include"] == "PrivateLegacy" && edge["private"] == true) ); let packages = report["package_references"].as_array().expect("packages"); assert!( packages .iter() .any(|package| package["include"] == "Newtonsoft.Json" && package["version"] == "13.0.3" && package["version_source"] == "central") ); assert!(packages.iter().any(|package| package["include"] == "Dapper" && package["version"] == "2.1.66" && package["version_source"] == "inline")); assert!( packages .iter() .any(|package| package["include"] == "Missing.Version" && package["version"].is_null() && package["version_source"] == "missing") ); assert!(packages.iter().any(|package| package["include"] == "xunit" && package["version"] == "2.9.2" && package["version_source"] == "central")); assert!(projects.iter().any(|project| { project["path"] == "src/Lib/Lib.csproj" && project["kind"] == "test" && project["kind_reasons"] .as_array() .expect("kind reasons") .iter() .any(|reason| { reason .as_str() .is_some_and(|value| value.contains("Microsoft.NET.Test.Sdk")) }) })); assert!(projects.iter().any(|project| { project["path"] == "tests/Unit.Tests/Unit.Tests.csproj" && project["kind"] == "test" && project["kind_reasons"] .as_array() .expect("kind reasons") .iter() .any(|reason| { reason .as_str() .is_some_and(|value| value.contains("IsTestProject")) }) })); let diagnostics = report["diagnostics"].as_array().expect("diagnostics"); assert!( diagnostics .iter() .any(|diagnostic| diagnostic["kind"] == "duplicate_assembly_name") ); assert!( diagnostics .iter() .any(|diagnostic| diagnostic["kind"] == "shared_output_path") ); assert!( diagnostics .iter() .any(|diagnostic| diagnostic["kind"] == "unresolved_project_reference") ); assert!( diagnostics .iter() .any(|diagnostic| diagnostic["kind"] == "missing_package_version") ); let bypass_hints = report["build_bypass_hints"] .as_array() .expect("bypass hints"); assert!( bypass_hints .iter() .any(|hint| hint["kind"] == "csc_task" && hint["path"] == "src/App/App.csproj") ); assert!( bypass_hints.iter().any(|hint| hint["kind"] == "csc_exe" && hint["path"] == "build.ps1" && hint["line"] == 2) ); assert!( bypass_hints .iter() .any(|hint| hint["kind"] == "direct_project_build" && hint["path"] == "build.ps1" && hint["line"] == 3) ); } #[test] fn max_depth_and_hidden_control_project_discovery() { let temp = tempfile::tempdir().expect("tempdir"); write_file( &temp.path().join("Root.csproj"), r"net8.0", ); write_file( &temp.path().join("deep").join("Nested.csproj"), r"net8.0", ); write_file( &temp.path().join(".hidden").join("Hidden.csproj"), r"net8.0", ); let shallow = json_report_with_args(temp.path(), &["--max-depth", "1"]); assert_eq!(shallow["projects"].as_array().expect("projects").len(), 1); assert!( shallow["projects"] .as_array() .expect("projects") .iter() .all(|project| project["path"] != "deep/Nested.csproj" && project["path"] != ".hidden/Hidden.csproj") ); let hidden = json_report_with_args(temp.path(), &["--hidden"]); assert!( hidden["projects"] .as_array() .expect("projects") .iter() .any(|project| project["path"] == ".hidden/Hidden.csproj") ); } #[test] fn text_and_toon_outputs_are_structured() { let temp = fixture_repo(); let mut text = cargo_command(); text.arg(temp.path()) .assert() .success() .stdout(predicate::str::contains("dotnetshape")) .stdout(predicate::str::contains("projects:")) .stdout(predicate::str::contains("diagnostics:")); let mut toon = cargo_command(); toon.arg("--toon") .arg(temp.path()) .assert() .success() .stdout(predicate::str::contains("evaluation_mode")) .stdout(predicate::str::contains("projects")); } fn json_report_with_args(root: &Path, args: &[&str]) -> Value { let mut command = cargo_command(); command.arg("--json"); for arg in args { command.arg(arg); } let output = command.arg(root).output().expect("run dotnetshape"); assert!( output.status.success(), "dotnetshape failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); serde_json::from_slice(&output.stdout).expect("json output") }