using System.Diagnostics; using System.Text.RegularExpressions; using SharedTools; namespace Build.Commands; // ReSharper disable MemberCanBePrivate.Global // ReSharper disable ClassNeverInstantiated.Global // ReSharper disable UnusedType.Global // ReSharper disable UnusedMember.Global public sealed partial class UpdateMetadataCommands { [Command("release", Description = "Prepare & build the next release")] public async Task Release(PrepareAction action) { if(!Environment.IsWorkingDirectoryValid()) return; // Prepare the metadata for the next release: await this.Prepare(action); // Build once to allow the Rust compiler to read the changed metadata // and to update all .NET artifacts: await this.Build(); // Now, we update the web assets (which may were updated by the first build): new UpdateWebAssetsCommand().UpdateWebAssets(); // Collect the I18N keys from the source code. This step yields a I18N file // that must be part of the final release: await new CollectI18NKeysCommand().CollectI18NKeys(); // Build the final release, where Rust knows the updated metadata, the .NET // artifacts are already in place, and .NET knows the updated web assets, etc.: await this.Build(); } [Command("prepare", Description = "Prepare the metadata for the next release")] public async Task Prepare(PrepareAction action) { if(!Environment.IsWorkingDirectoryValid()) return; Console.WriteLine("=============================="); Console.WriteLine("- Prepare the metadata for the next release ..."); var appVersion = await this.UpdateAppVersion(action); if (!string.IsNullOrWhiteSpace(appVersion)) { var buildNumber = await this.IncreaseBuildNumber(); var buildTime = await this.UpdateBuildTime(); await this.UpdateChangelog(buildNumber, appVersion, buildTime); await this.UpdateDotnetVersion(); await this.UpdateRustVersion(); await this.UpdateMudBlazorVersion(); await this.UpdateTauriVersion(); await this.UpdateProjectCommitHash(); await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "..", "..", "LICENSE.md"))); await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "Pages", "About.razor.cs"))); Console.WriteLine(); } } [Command("build", Description = "Build MindWork AI Studio")] public async Task Build() { if(!Environment.IsWorkingDirectoryValid()) return; // // Build the .NET project: // var pathApp = Environment.GetAIStudioDirectory(); var rids = Environment.GetRidsForCurrentOS(); foreach (var rid in rids) { Console.WriteLine("=============================="); await this.UpdateArchitecture(rid); Console.Write($"- Start .NET build for '{rid.AsMicrosoftRid()}' ..."); await this.ReadCommandOutput(pathApp, "dotnet", $"clean --configuration release --runtime {rid.AsMicrosoftRid()}"); var dotnetBuildOutput = await this.ReadCommandOutput(pathApp, "dotnet", $"publish --configuration release --runtime {rid.AsMicrosoftRid()} --disable-build-servers --force"); var dotnetBuildOutputLines = dotnetBuildOutput.Split([global::System.Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); var foundIssue = false; foreach (var buildOutputLine in dotnetBuildOutputLines) { if(buildOutputLine.Contains(" error ") || buildOutputLine.Contains("#warning")) { if(!foundIssue) { foundIssue = true; Console.WriteLine(); Console.WriteLine("- Build has issues:"); } Console.Write(" - "); Console.WriteLine(buildOutputLine); } } if(foundIssue) Console.WriteLine(); else { Console.WriteLine(" completed successfully."); } // // Prepare the .NET artifact to be used by Tauri as sidecar: // var os = Environment.GetOS(); var tauriSidecarArtifactName = rid switch { RID.WIN_X64 => "mindworkAIStudioServer-x86_64-pc-windows-msvc.exe", RID.WIN_ARM64 => "mindworkAIStudioServer-aarch64-pc-windows-msvc.exe", RID.LINUX_X64 => "mindworkAIStudioServer-x86_64-unknown-linux-gnu", RID.LINUX_ARM64 => "mindworkAIStudioServer-aarch64-unknown-linux-gnu", RID.OSX_ARM64 => "mindworkAIStudioServer-aarch64-apple-darwin", RID.OSX_X64 => "mindworkAIStudioServer-x86_64-apple-darwin", _ => string.Empty, }; if (string.IsNullOrWhiteSpace(tauriSidecarArtifactName)) { Console.WriteLine($"- Error: Unsupported rid '{rid.AsMicrosoftRid()}'."); return; } var dotnetArtifactPath = Path.Combine(pathApp, "bin", "dist"); if(!Directory.Exists(dotnetArtifactPath)) Directory.CreateDirectory(dotnetArtifactPath); var dotnetArtifactFilename = os switch { "windows" => "mindworkAIStudio.exe", _ => "mindworkAIStudio", }; var dotnetPublishedPath = Path.Combine(pathApp, "bin", "release", Environment.DOTNET_VERSION, rid.AsMicrosoftRid(), "publish", dotnetArtifactFilename); var finalDestination = Path.Combine(dotnetArtifactPath, tauriSidecarArtifactName); if(File.Exists(dotnetPublishedPath)) Console.WriteLine("- Published .NET artifact found."); else { Console.WriteLine($"- Error: Published .NET artifact not found: '{dotnetPublishedPath}'."); return; } Console.Write($"- Move the .NET artifact to the Tauri sidecar destination ..."); try { File.Move(dotnetPublishedPath, finalDestination, true); Console.WriteLine(" done."); } catch (Exception e) { Console.WriteLine(" failed."); Console.WriteLine($" - Error: {e.Message}"); } Console.WriteLine(); } // // Build the Rust project / runtime: // Console.WriteLine("=============================="); Console.WriteLine("- Start building the Rust runtime ..."); var pathRuntime = Environment.GetRustRuntimeDirectory(); var rustBuildOutput = await this.ReadCommandOutput(pathRuntime, "cargo", "tauri build --bundles none", true); var rustBuildOutputLines = rustBuildOutput.Split([global::System.Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); var foundRustIssue = false; foreach (var buildOutputLine in rustBuildOutputLines) { if(buildOutputLine.Contains("error", StringComparison.OrdinalIgnoreCase) || buildOutputLine.Contains("warning")) { if(!foundRustIssue) { foundRustIssue = true; Console.WriteLine(); Console.WriteLine("- Build has issues:"); } Console.Write(" - "); Console.WriteLine(buildOutputLine); } } if(foundRustIssue) Console.WriteLine(); else { Console.WriteLine(); Console.WriteLine("- Compilation completed successfully."); Console.WriteLine(); } } private async Task UpdateChangelog(int buildNumber, string appVersion, string buildTime) { var pathChangelogs = Path.Combine(Environment.GetAIStudioDirectory(), "wwwroot", "changelog"); var expectedLogFilename = $"v{appVersion}.md"; var expectedLogFilePath = Path.Combine(pathChangelogs, expectedLogFilename); if(!File.Exists(expectedLogFilePath)) { Console.WriteLine($"- Error: The changelog file '{expectedLogFilename}' does not exist."); return; } // Right now, the build time is formatted as "yyyy-MM-dd HH:mm:ss UTC", but must remove the seconds: buildTime = buildTime[..^7] + " UTC"; const string CODE_START = """ LOGS = [ """; var changelogCodePath = Path.Join(Environment.GetAIStudioDirectory(), "Components", "Changelog.Logs.cs"); var changelogCode = await File.ReadAllTextAsync(changelogCodePath, Encoding.UTF8); var updatedCode = $""" {CODE_START} new ({buildNumber}, "v{appVersion}, build {buildNumber} ({buildTime})", "{expectedLogFilename}"), """; changelogCode = changelogCode.Replace(CODE_START, updatedCode); await File.WriteAllTextAsync(changelogCodePath, changelogCode, Environment.UTF8_NO_BOM); } private async Task UpdateArchitecture(RID rid) { const int ARCHITECTURE_INDEX = 9; var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); Console.Write("- Updating architecture ..."); lines[ARCHITECTURE_INDEX] = rid.AsMicrosoftRid(); await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); Console.WriteLine(" done."); } private async Task UpdateProjectCommitHash() { const int COMMIT_HASH_INDEX = 8; var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var currentCommitHash = lines[COMMIT_HASH_INDEX].Trim(); var headCommitHash = await this.ReadCommandOutput(Environment.GetAIStudioDirectory(), "git", "rev-parse HEAD"); var first10Chars = headCommitHash[..11]; var updatedCommitHash = $"{first10Chars}, release"; Console.WriteLine($"- Updating commit hash from '{currentCommitHash}' to '{updatedCommitHash}'."); lines[COMMIT_HASH_INDEX] = updatedCommitHash; await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); } private async Task UpdateAppVersion(PrepareAction action) { const int APP_VERSION_INDEX = 0; if (action == PrepareAction.NONE) { Console.WriteLine("- No action specified. Skipping app version update."); return string.Empty; } var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var currentAppVersionLine = lines[APP_VERSION_INDEX].Trim(); var currentAppVersion = AppVersionRegex().Match(currentAppVersionLine); var currentPatch = int.Parse(currentAppVersion.Groups["patch"].Value); var currentMinor = int.Parse(currentAppVersion.Groups["minor"].Value); var currentMajor = int.Parse(currentAppVersion.Groups["major"].Value); switch (action) { case PrepareAction.PATCH: currentPatch++; break; case PrepareAction.MINOR: currentPatch = 0; currentMinor++; break; case PrepareAction.MAJOR: currentPatch = 0; currentMinor = 0; currentMajor++; break; } var updatedAppVersion = $"{currentMajor}.{currentMinor}.{currentPatch}"; Console.WriteLine($"- Updating app version from '{currentAppVersionLine}' to '{updatedAppVersion}'."); lines[APP_VERSION_INDEX] = updatedAppVersion; await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); return updatedAppVersion; } private async Task UpdateLicenceYear(string licenceFilePath) { var currentYear = DateTime.UtcNow.Year.ToString(); var lines = await File.ReadAllLinesAsync(licenceFilePath, Encoding.UTF8); var found = false; var copyrightYear = string.Empty; var updatedLines = new List(lines.Length); foreach (var line in lines) { var match = FindCopyrightRegex().Match(line); if (match.Success) { copyrightYear = match.Groups["year"].Value; if(!found && copyrightYear != currentYear) Console.WriteLine($"- Updating the licence's year in '{Path.GetFileName(licenceFilePath)}' from '{copyrightYear}' to '{currentYear}'."); updatedLines.Add(ReplaceCopyrightYearRegex().Replace(line, currentYear)); found = true; } else updatedLines.Add(line); } await File.WriteAllLinesAsync(licenceFilePath, updatedLines, Environment.UTF8_NO_BOM); if (!found) Console.WriteLine($"- Error: No copyright year found in '{Path.GetFileName(licenceFilePath)}'."); else if (copyrightYear == currentYear) Console.WriteLine($"- The copyright year in '{Path.GetFileName(licenceFilePath)}' is already up to date."); } private async Task UpdateTauriVersion() { const int TAURI_VERSION_INDEX = 7; var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var currentTauriVersion = lines[TAURI_VERSION_INDEX].Trim(); var matches = await this.DetermineVersion("Tauri", Environment.GetRustRuntimeDirectory(), TauriVersionRegex(), "cargo", "tree --depth 1"); if (matches.Count == 0) return; var updatedTauriVersion = matches[0].Groups["version"].Value; if(currentTauriVersion == updatedTauriVersion) { Console.WriteLine("- The Tauri version is already up to date."); return; } Console.WriteLine($"- Updated Tauri version from {currentTauriVersion} to {updatedTauriVersion}."); lines[TAURI_VERSION_INDEX] = updatedTauriVersion; await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); } private async Task UpdateMudBlazorVersion() { const int MUD_BLAZOR_VERSION_INDEX = 6; var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var currentMudBlazorVersion = lines[MUD_BLAZOR_VERSION_INDEX].Trim(); var matches = await this.DetermineVersion("MudBlazor", Environment.GetAIStudioDirectory(), MudBlazorVersionRegex(), "dotnet", "list package"); if (matches.Count == 0) return; var updatedMudBlazorVersion = matches[0].Groups["version"].Value; if(currentMudBlazorVersion == updatedMudBlazorVersion) { Console.WriteLine("- The MudBlazor version is already up to date."); return; } Console.WriteLine($"- Updated MudBlazor version from {currentMudBlazorVersion} to {updatedMudBlazorVersion}."); lines[MUD_BLAZOR_VERSION_INDEX] = updatedMudBlazorVersion; await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); } private async Task UpdateRustVersion() { const int RUST_VERSION_INDEX = 5; var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var currentRustVersion = lines[RUST_VERSION_INDEX].Trim(); var matches = await this.DetermineVersion("Rust", Environment.GetRustRuntimeDirectory(), RustVersionRegex(), "rustc", "-Vv"); if (matches.Count == 0) return; var updatedRustVersion = matches[0].Groups["version"].Value + " (commit " + matches[0].Groups["commit"].Value + ")"; if(currentRustVersion == updatedRustVersion) { Console.WriteLine("- Rust version is already up to date."); return; } Console.WriteLine($"- Updated Rust version from {currentRustVersion} to {updatedRustVersion}."); lines[RUST_VERSION_INDEX] = updatedRustVersion; await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); } private async Task UpdateDotnetVersion() { const int DOTNET_VERSION_INDEX = 4; const int DOTNET_SDK_VERSION_INDEX = 3; var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var currentDotnetVersion = lines[DOTNET_VERSION_INDEX].Trim(); var currentDotnetSdkVersion = lines[DOTNET_SDK_VERSION_INDEX].Trim(); var matches = await this.DetermineVersion(".NET", Environment.GetAIStudioDirectory(), DotnetVersionRegex(), "dotnet", "--info"); if (matches.Count == 0) return; var updatedDotnetVersion = matches[0].Groups["hostVersion"].Value + " (commit " + matches[0].Groups["hostCommit"].Value + ")"; var updatedDotnetSdkVersion = matches[0].Groups["sdkVersion"].Value + " (commit " + matches[0].Groups["sdkCommit"].Value + ")"; if(currentDotnetVersion == updatedDotnetVersion && currentDotnetSdkVersion == updatedDotnetSdkVersion) { Console.WriteLine("- .NET version is already up to date."); return; } Console.WriteLine($"- Updated .NET SDK version from {currentDotnetSdkVersion} to {updatedDotnetSdkVersion}."); Console.WriteLine($"- Updated .NET version from {currentDotnetVersion} to {updatedDotnetVersion}."); lines[DOTNET_VERSION_INDEX] = updatedDotnetVersion; lines[DOTNET_SDK_VERSION_INDEX] = updatedDotnetSdkVersion; await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); } private async Task> DetermineVersion(string name, string workingDirectory, Regex regex, string program, string command) { var processInfo = new ProcessStartInfo { WorkingDirectory = workingDirectory, FileName = program, Arguments = command, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; using var process = new Process(); process.StartInfo = processInfo; process.Start(); var output = await process.StandardOutput.ReadToEndAsync(); await process.WaitForExitAsync(); var matches = regex.Matches(output); if (matches.Count == 0) { Console.WriteLine($"- Error: Was not able to determine the {name} version."); return []; } return matches; } private async Task ReadCommandOutput(string workingDirectory, string program, string command, bool showLiveOutput = false) { var processInfo = new ProcessStartInfo { WorkingDirectory = workingDirectory, FileName = program, Arguments = command, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; var sb = new StringBuilder(); using var process = new Process(); process.StartInfo = processInfo; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); process.OutputDataReceived += (_, args) => { if(!string.IsNullOrWhiteSpace(args.Data)) { if(showLiveOutput) Console.WriteLine(args.Data); sb.AppendLine(args.Data); } }; process.ErrorDataReceived += (_, args) => { if(!string.IsNullOrWhiteSpace(args.Data)) { if(showLiveOutput) Console.WriteLine(args.Data); sb.AppendLine(args.Data); } }; await process.WaitForExitAsync(); return sb.ToString(); } private async Task IncreaseBuildNumber() { const int BUILD_NUMBER_INDEX = 2; var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var buildNumber = int.Parse(lines[BUILD_NUMBER_INDEX]) + 1; Console.WriteLine($"- Updating build number from '{lines[BUILD_NUMBER_INDEX]}' to '{buildNumber}'."); lines[BUILD_NUMBER_INDEX] = buildNumber.ToString(); await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); return buildNumber; } private async Task UpdateBuildTime() { const int BUILD_TIME_INDEX = 1; var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var buildTime = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") + " UTC"; Console.WriteLine($"- Updating build time from '{lines[BUILD_TIME_INDEX]}' to '{buildTime}'."); lines[BUILD_TIME_INDEX] = buildTime; await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); return buildTime; } [GeneratedRegex("""(?ms).?(NET\s+SDK|SDK\s+\.NET)\s*:\s+Version:\s+(?[0-9.]+).+Commit:\s+(?[a-zA-Z0-9]+).+Host:\s+Version:\s+(?[0-9.]+).+Commit:\s+(?[a-zA-Z0-9]+)""")] private static partial Regex DotnetVersionRegex(); [GeneratedRegex("""rustc (?[0-9.]+)(?:-nightly)? \((?[a-zA-Z0-9]+)""")] private static partial Regex RustVersionRegex(); [GeneratedRegex("""MudBlazor\s+(?[0-9.]+)""")] private static partial Regex MudBlazorVersionRegex(); [GeneratedRegex("""tauri\s+v(?[0-9.]+)""")] private static partial Regex TauriVersionRegex(); [GeneratedRegex("""^\s*Copyright\s+(?[0-9]{4})""")] private static partial Regex FindCopyrightRegex(); [GeneratedRegex("""([0-9]{4})""")] private static partial Regex ReplaceCopyrightYearRegex(); [GeneratedRegex("""(?[0-9]+)\.(?[0-9]+)\.(?[0-9]+)""")] private static partial Regex AppVersionRegex(); }