Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Text.RegularExpressions;

namespace TALXIS.CLI.Features.Environment.Solution;

internal enum BuildOutputSeverity
{
Info,
Warning,
Error,
}

/// <summary>
/// Helpers for interpreting <c>dotnet build</c> output and locating the solution ZIP it produced.
/// </summary>
internal static partial class SolutionBuildOutput
{
[GeneratedRegex(@"\berror(\s+[A-Za-z]+\d+)?\s*:", RegexOptions.IgnoreCase)]
private static partial Regex ErrorPattern();

// Older Build SDKs pack an incomplete zip and exit 0 when root components have no source
// files; the only trace is one of these packager lines, printed without any Error: prefix.
[GeneratedRegex(@"^\s*(Following root components are not defined in customizations|Following objects, required by the solution, are not present)", RegexOptions.IgnoreCase)]
private static partial Regex MissingRootComponentsPattern();

[GeneratedRegex(@"\bwarning(\s+[A-Za-z]+\d+)?\s*:", RegexOptions.IgnoreCase)]
private static partial Regex WarningPattern();

internal static BuildOutputSeverity Classify(string line)
{
if (ErrorPattern().IsMatch(line)) return BuildOutputSeverity.Error;
if (MissingRootComponentsPattern().IsMatch(line)) return BuildOutputSeverity.Error;
if (WarningPattern().IsMatch(line)) return BuildOutputSeverity.Warning;

return BuildOutputSeverity.Info;
}

/// <summary>
/// Finds solution ZIPs under the build output directory (any target framework subfolder).
/// Fresh ZIPs are those written at or after <paramref name="buildStartUtc"/>, newest first.
/// </summary>
internal static (string[] Fresh, string[] All) FindSolutionZips(string binConfigDir, DateTime buildStartUtc)
{
if (!Directory.Exists(binConfigDir)) return (Array.Empty<string>(), Array.Empty<string>());

var all = Directory.GetFiles(binConfigDir, "*.zip", SearchOption.AllDirectories);
var fresh = all
.Where(f => File.GetLastWriteTimeUtc(f) >= buildStartUtc)
.OrderByDescending(File.GetLastWriteTimeUtc)
.ToArray();

return (fresh, all);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,17 @@ protected override async Task<int> ExecuteAsync()
}

/// <summary>
/// Runs <c>dotnet build</c> on a Build SDK project and locates the output ZIP.
/// Runs <c>dotnet build</c> on a Build SDK project and locates the ZIP produced by that build.
/// Returns the ZIP path on success, or null on failure.
/// </summary>
private async Task<string?> BuildAndLocateZipAsync(string csProjPath)
{
var config = Managed ? "Release" : "Debug";
Logger.LogInformation("Building '{Project}' with configuration '{Config}'...", Path.GetFileName(csProjPath), config);

var buildStartUtc = DateTime.UtcNow;
var errorLineCount = 0;

var psi = new ProcessStartInfo
{
FileName = "dotnet",
Expand All @@ -219,7 +222,23 @@ protected override async Task<int> ExecuteAsync()
};

using var process = new Process { StartInfo = psi };
process.OutputDataReceived += (_, e) => { if (e.Data is not null) Logger.LogInformation("{Line}", e.Data); };
process.OutputDataReceived += (_, e) =>
{
if (e.Data is null) return;
switch (SolutionBuildOutput.Classify(e.Data))
{
case BuildOutputSeverity.Error:
Interlocked.Increment(ref errorLineCount);
Logger.LogError("{Line}", e.Data);
break;
case BuildOutputSeverity.Warning:
Logger.LogWarning("{Line}", e.Data);
break;
default:
Logger.LogInformation("{Line}", e.Data);
break;
}
};
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) Logger.LogWarning("{Line}", e.Data); };
process.Start();
process.BeginOutputReadLine();
Expand All @@ -232,28 +251,31 @@ protected override async Task<int> ExecuteAsync()
return null;
}

// Build SDK convention: output ZIP is in bin/{config}/net462/*.zip
var outputDir = Path.Combine(Path.GetDirectoryName(csProjPath)!, "bin", config, "net462");
if (!Directory.Exists(outputDir))
// Older Build SDK versions report packager failures but still exit 0 (tools-devkit-build#47).
if (errorLineCount > 0)
{
Logger.LogError("Build output directory not found: {OutputDir}.", outputDir);
Logger.LogError("Build succeeded but its output contains {Count} error line(s) (see above). Refusing to import.", errorLineCount);
return null;
}

var zipFiles = Directory.GetFiles(outputDir, "*.zip");
if (zipFiles.Length == 0)
var outputDir = Path.Combine(Path.GetDirectoryName(csProjPath)!, "bin", config);
var (freshZips, allZips) = SolutionBuildOutput.FindSolutionZips(outputDir, buildStartUtc);

if (allZips.Length == 0)
{
Logger.LogError("No .zip file found in build output directory: {OutputDir}.", outputDir);
return null;
}

// Pick the most recently written ZIP to avoid using stale build artifacts
var zipPath = zipFiles
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
.First();
if (freshZips.Length == 0)
{
Logger.LogError("The build did not produce a solution ZIP; only stale artifacts from a previous build exist in '{OutputDir}'. Refusing to import a stale ZIP.", outputDir);
return null;
}

if (zipFiles.Length > 1)
Logger.LogWarning("Multiple .zip files found in '{OutputDir}'. Using newest: {ZipPath}", outputDir, Path.GetFileName(zipPath));
var zipPath = freshZips[0];
if (freshZips.Length > 1)
Logger.LogWarning("Multiple .zip files produced in '{OutputDir}'. Using newest: {ZipPath}", outputDir, Path.GetFileName(zipPath));

Logger.LogInformation("Using build output: {ZipPath}", zipPath);
return zipPath;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using TALXIS.CLI.Features.Environment.Solution;
using Xunit;

namespace TALXIS.CLI.Tests.Environment.Solution;

public class SolutionBuildOutputTests
{
[Theory]
[InlineData(" Error: RootComponent validation failed.", nameof(BuildOutputSeverity.Error))]
[InlineData(@"C:\proj\proj.csproj(4,5): error MSB4018: task failed", nameof(BuildOutputSeverity.Error))]
[InlineData("MSBUILD : error MSB1009: Project file does not exist.", nameof(BuildOutputSeverity.Error))]
[InlineData("Following root components are not defined in customizations:", nameof(BuildOutputSeverity.Error))]
[InlineData(" Following objects, required by the solution, are not present. ", nameof(BuildOutputSeverity.Error))]
[InlineData("proj.csproj : warning NU1903: Package has a known vulnerability", nameof(BuildOutputSeverity.Warning))]
[InlineData(" Warning: LocalBranchBuildVersionNumber is null", nameof(BuildOutputSeverity.Warning))]
[InlineData(" 0 Warning(s)", nameof(BuildOutputSeverity.Info))]
[InlineData(" 0 Error(s)", nameof(BuildOutputSeverity.Info))]
[InlineData("Build succeeded.", nameof(BuildOutputSeverity.Info))]
[InlineData(" Solution: bin\\Debug\\net462\\Sln.zip packed successfully", nameof(BuildOutputSeverity.Info))]
public void Classify_ReturnsExpectedSeverity(string line, string expected)
{
Assert.Equal(Enum.Parse<BuildOutputSeverity>(expected), SolutionBuildOutput.Classify(line));
}

[Fact]
public void FindZips_ReturnsEmpty_WhenDirectoryMissing()
{
var (fresh, all) = SolutionBuildOutput.FindSolutionZips(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), DateTime.UtcNow);
Assert.Empty(fresh);
Assert.Empty(all);
}

[Fact]
public void FindZips_FindsZipInAnyTfmSubfolder()
{
using var dir = new TempDir();
var zip = dir.CreateFile(Path.Combine("net472", "Sln.zip"));

var (fresh, all) = SolutionBuildOutput.FindSolutionZips(dir.Path, DateTime.UtcNow.AddMinutes(-1));

Assert.Equal([zip], fresh);
Assert.Equal([zip], all);
}

[Fact]
public void FindZips_ExcludesStaleZipsFromFresh()
{
using var dir = new TempDir();
var stale = dir.CreateFile(Path.Combine("net462", "Old.zip"));
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddHours(-2));

var (fresh, all) = SolutionBuildOutput.FindSolutionZips(dir.Path, DateTime.UtcNow.AddMinutes(-1));

Assert.Empty(fresh);
Assert.Equal([stale], all);
}

[Fact]
public void FindZips_OrdersFreshNewestFirst()
{
using var dir = new TempDir();
var older = dir.CreateFile(Path.Combine("net462", "Older.zip"));
var newer = dir.CreateFile(Path.Combine("net472", "Newer.zip"));
File.SetLastWriteTimeUtc(older, DateTime.UtcNow.AddSeconds(-30));
File.SetLastWriteTimeUtc(newer, DateTime.UtcNow);

var (fresh, _) = SolutionBuildOutput.FindSolutionZips(dir.Path, DateTime.UtcNow.AddMinutes(-1));

Assert.Equal([newer, older], fresh);
}

private sealed class TempDir : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "txc-tests-" + Guid.NewGuid().ToString("N"));

public TempDir() => Directory.CreateDirectory(Path);

public string CreateFile(string relativePath)
{
var fullPath = System.IO.Path.Combine(Path, relativePath);
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(fullPath)!);
File.WriteAllBytes(fullPath, [0x50, 0x4B]);
return fullPath;
}

public void Dispose() => Directory.Delete(Path, recursive: true);
}
}