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");
}
}