2026-09-13 14:17:25 +02:00
using Build.Tools ;
2026-10-29 08:12:57 +01:00
using SharedTools ;
2026-09-13 14:17:25 +02:00
namespace Build.Commands ;
/// <summary>
/// The quality gate: one command, the same one locally and in the pipeline.
/// </summary>
/// <remarks>
/// Every check runs, even after one of them has failed. A gate which stops at the first failure
/// tells you one thing per run, and the next run costs the same minutes again -- while the point of
/// running the whole thing is to learn everything which is wrong in one go.
/// </remarks>
public sealed class VerifyCommand
{
/// <summary>
/// How the .NET app is named once it lies where Tauri expects it.
/// </summary>
private const string SIDECAR_PREFIX = "mindworkAIStudioServer-" ;
2026-11-04 14:57:37 +01:00
/// <summary>
/// Defines the command "verify".
/// </summary>
public static Command CreateCommand ()
{
var command = new Command ( "verify" , "Run the quality gate: .NET tests, the trim smoke test, Rust tests, Clippy, and the model sources" );
command . SetAction (( _ , _ ) => new VerifyCommand (). Verify ());
return command ;
}
2026-09-13 14:17:25 +02:00
public async Task < int > Verify ()
{
if (! Environment . IsWorkingDirectoryValid ())
return 1 ;
Console . WriteLine ( "==============================" );
Console . WriteLine ( "- Quality gate: every check runs, so that the first failure does not hide the next ..." );
var results = new List <( string What , int ExitCode )>
{
( ".NET tests" , await CommandRunner . RunAsync ( Environment . GetTestsDirectory (), "dotnet" , "test --nologo" )),
2026-10-29 08:12:57 +01:00
( "Trim smoke test" , await RunTrimSmokeTestAsync ()),
2026-09-13 14:17:25 +02:00
};
var runtimeDirectory = Environment . GetRustRuntimeDirectory ();
if ( WhatTauriExpectsIsThere ())
{
results . Add (( "Rust tests" , await CommandRunner . RunAsync ( runtimeDirectory , "cargo" , "test" )));
results . Add (( "Clippy" , await CommandRunner . RunAsync ( runtimeDirectory , "cargo" , "clippy --all-targets -- -D warnings" )));
}
else
{
//
// Tauri's build script insists that everything the configuration lists is already
// there and refuses to run otherwise, so nothing Rust compiles until a build has
// produced those files once. Failing here would be a trap rather than a gate: the way
// to produce them is `dotnet run build`, and that command runs this gate first -- a
// fresh clone would never get past it.
//
Console . WriteLine ( "- Skipping the Rust tests and Clippy: the .NET sidecar or the downloaded libraries are missing, and Tauri's build script needs both before anything Rust compiles." );
Console . WriteLine ( " Run 'dotnet run build --skip-verify' once. From then on, this part of the gate runs with the rest." );
}
results . Add (( "Model sources" , new VerifyModelsCommand (). VerifyModels ()));
Console . WriteLine ( "==============================" );
Console . WriteLine ( "- Quality gate:" );
foreach ( var ( what , exitCode ) in results )
Console . WriteLine ( $" - {what}: {(exitCode is 0 ? " passed " : $" failed , exit code { exitCode } ")}" );
var failed = results . Count ( result => result . ExitCode is not 0 );
if ( failed is 0 )
{
Console . WriteLine ( $"- All {results.Count} checks passed." );
return 0 ;
}
Console . WriteLine ( $"- {failed} of {results.Count} checks failed." );
return 1 ;
}
2026-10-29 08:12:57 +01:00
/// <summary>
/// Publishes the trim smoke test for this machine, trimmed like the app, and runs it.
/// </summary>
/// <remarks>
/// Debug builds and the tests are not trimmed, so only a published build shows that the trimmer
/// removed code which System.Text.Json reaches by reflection. Then AI Studio can neither load nor
/// save, e.g., its chats, while everything looks fine during development.
/// </remarks>
/// <returns>The exit code of the publishing, when that failed; otherwise the one of the smoke test.</returns>
private static async Task < int > RunTrimSmokeTestAsync ()
{
var rid = Environment . GetCurrentRid ();
if ( rid is RID . NONE )
{
Console . WriteLine ( "- Error: The trim smoke test needs a platform AI Studio is built for." );
return 1 ;
}
var directory = Environment . GetTrimSmokeTestDirectory ();
var publishDirectory = Path . Combine ( directory , "bin" , "verify" );
2026-11-03 18:29:50 +01:00
//
// Publishing leaves the files of an earlier run in place, among them copies of assemblies which the trimmer
// now removes completely. The dependency manifest still lists such an assembly, so a leftover copy can be
// loaded in its place and hide exactly what the smoke test is there to find:
//
if ( Directory . Exists ( publishDirectory ))
Directory . Delete ( publishDirectory , recursive : true );
2026-11-04 13:00:56 +01:00
var publishExitCode = await CommandRunner . RunAsync ( directory , "dotnet" , $"publish --configuration release --runtime {rid.MicrosoftRid} --output \" { publishDirectory } \ " --nologo" );
2026-10-29 08:12:57 +01:00
if ( publishExitCode is not 0 )
return publishExitCode ;
var program = Path . Combine ( publishDirectory , OperatingSystem . IsWindows () ? "TrimSmokeTest.exe" : "TrimSmokeTest" );
return await CommandRunner . RunAsync ( directory , program , string . Empty );
}
2026-09-13 14:17:25 +02:00
/// <summary>
/// Whether a build has already produced the files Tauri's build script reads.
/// </summary>
/// <remarks>
/// Both are products of a build rather than of the repository: the .NET app arrives as a
/// sidecar, and the PDF library is downloaded into the resources. The other resource
/// directories the configuration names are in the repository and are always there.
/// </remarks>
/// <returns>True, when cargo can get past the build script.</returns>
private static bool WhatTauriExpectsIsThere ()
{
var distributionDirectory = Path . Combine ( Environment . GetAIStudioDirectory (), "bin" , "dist" );
if (! Directory . Exists ( distributionDirectory ) || ! Directory . EnumerateFiles ( distributionDirectory , $"{SIDECAR_PREFIX}*" ). Any ())
return false ;
var librariesDirectory = Path . Combine ( Environment . GetRustRuntimeDirectory (), "resources" , "libraries" );
return Directory . Exists ( librariesDirectory ) && Directory . EnumerateFiles ( librariesDirectory ). Any ();
}
}