using AcDream.Content.Pak; namespace AcDream.Bake; /// /// Publishes a bake as one same-volume filesystem transaction. Work and /// validation happen against a unique adjacent temporary file; failure or /// cancellation leaves an existing destination untouched. /// public static class BakeOutputTransaction { internal const string StagingMarker = ".acdream-bake."; public static TResult WriteValidateAndPublish( string destinationPath, Func writeTemporary, Action validateTemporary, CancellationToken cancellationToken = default) => WriteValidateAndPublish( destinationPath, writeTemporary, validateTemporary, beforePublicationLock: null, beforePromotion: null, cancellationToken); internal static TResult WriteValidateAndPublish( string destinationPath, Func writeTemporary, Action validateTemporary, Action? beforePublicationLock, Action? beforePromotion, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); ArgumentNullException.ThrowIfNull(writeTemporary); ArgumentNullException.ThrowIfNull(validateTemporary); string fullDestination = Path.GetFullPath(destinationPath); string? directory = Path.GetDirectoryName(fullDestination); if (string.IsNullOrEmpty(directory)) throw new InvalidOperationException("destination has no parent directory"); Directory.CreateDirectory(directory); string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid()); try { cancellationToken.ThrowIfCancellationRequested(); TResult result = writeTemporary(temporaryPath); cancellationToken.ThrowIfCancellationRequested(); validateTemporary(temporaryPath, result); cancellationToken.ThrowIfCancellationRequested(); beforePublicationLock?.Invoke(); using IDisposable? publication = BakePublicationGuard.AcquireIfRequested( fullDestination, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); beforePromotion?.Invoke(); cancellationToken.ThrowIfCancellationRequested(); // Same-volume MoveFileEx/rename is the publication primitive. // File.Replace additionally performs destination metadata/backup // semantics and proved brittle for a validated 28 GiB artifact on // Windows. Move(overwrite:true) changes only the directory entry: // readers observe the complete old file or the complete new file. File.Move(temporaryPath, fullDestination, overwrite: true); return result; } finally { try { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } catch { // Preserve the original exception. An adjacent .tmp is // recognizable and never mistaken for a published pak. } } } /// /// Exact adjacent staging-name contract shared, by documentation and /// conformance tests, with Launcher.Core. Keeping this tiny contract in /// each BCL-facing assembly avoids an otherwise inverted project edge. /// internal static string CreateStagingPath(string destinationPath, Guid transactionId) { string fullDestination = Path.GetFullPath(destinationPath); string directory = Path.GetDirectoryName(fullDestination) ?? throw new InvalidOperationException( "destination has no parent directory"); return Path.Combine( directory, $".{Path.GetFileName(fullDestination)}{StagingMarker}" + $"{transactionId:N}.tmp"); } }