File: IncrementalLayout.cs
Web Access
Project: sdk-tasks.csproj (sdk-tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Microsoft.DotNet.Build.Tasks;

/// <summary>
/// Validates a positional source-to-destination layout mapping and identifies outputs
/// that are no longer produced by that mapping.
/// </summary>
/// <remarks>
/// <see cref="ExistingOutputs"/> must contain only files owned by the caller. This task
/// treats every listed file outside the expected destination set as stale. All path-bearing
/// properties must contain fully qualified paths, and the source and destination sets must
/// be disjoint.
/// </remarks>
public sealed class PrepareIncrementalLayout : Task
{
    /// <summary>
    /// Gets or sets the fully qualified source files to lay out. Each item maps by index
    /// to an item in <see cref="DestinationFiles"/>.
    /// </summary>
    [Required]
    public ITaskItem[] SourceFiles { get; set; } = [];

    /// <summary>
    /// Gets or sets the fully qualified destination files. Each item maps by index to an
    /// item in <see cref="SourceFiles"/>.
    /// </summary>
    [Required]
    public ITaskItem[] DestinationFiles { get; set; } = [];

    /// <summary>
    /// Gets or sets the fully qualified existing files owned by the caller's layout. Files
    /// not present in <see cref="ExpectedOutputs"/> are reported through
    /// <see cref="StaleOutputs"/>.
    /// </summary>
    public ITaskItem[] ExistingOutputs { get; set; } = [];

    /// <summary>
    /// Gets the normalized destination files expected from the current mapping.
    /// </summary>
    [Output]
    public ITaskItem[] ExpectedOutputs { get; private set; } = [];

    /// <summary>
    /// Gets the normalized existing outputs that are no longer destinations of the
    /// current mapping.
    /// </summary>
    [Output]
    public ITaskItem[] StaleOutputs { get; private set; } = [];

    public override bool Execute()
    {
        try
        {
            if (SourceFiles.Length != DestinationFiles.Length)
            {
                Log.LogError(
                    $"The incremental layout has {SourceFiles.Length} source files but {DestinationFiles.Length} destination files.");
                return false;
            }

            StringComparer pathComparer = IncrementalLayoutState.PathComparer;
            var sourcePaths = new HashSet<string>(pathComparer);
            var destinationPaths = new HashSet<string>(pathComparer);
            var expectedOutputs = new List<ITaskItem>(SourceFiles.Length);

            for (int index = 0; index < SourceFiles.Length; index++)
            {
                bool sourcePathIsValid = IncrementalLayoutState.TryGetFullPath(
                    Log,
                    SourceFiles[index].ItemSpec,
                    nameof(SourceFiles),
                    out string sourcePath);
                bool destinationPathIsValid = IncrementalLayoutState.TryGetFullPath(
                    Log,
                    DestinationFiles[index].ItemSpec,
                    nameof(DestinationFiles),
                    out string destinationPath);

                if (!sourcePathIsValid || !destinationPathIsValid)
                {
                    continue;
                }

                sourcePaths.Add(sourcePath);

                if (!File.Exists(sourcePath))
                {
                    Log.LogError($"Incremental layout input '{sourcePath}' does not exist.");
                    continue;
                }

                if (!destinationPaths.Add(destinationPath))
                {
                    Log.LogError($"Incremental layout destination '{destinationPath}' is produced by more than one input.");
                    continue;
                }

                expectedOutputs.Add(new TaskItem(destinationPath));
            }

            foreach (string sourcePath in sourcePaths)
            {
                if (destinationPaths.Contains(sourcePath))
                {
                    Log.LogError($"Incremental layout input '{sourcePath}' must not also be a destination.");
                }
            }

            ExpectedOutputs = expectedOutputs.ToArray();
            var staleOutputPaths = new HashSet<string>(pathComparer);
            var staleOutputs = new List<ITaskItem>();
            foreach (ITaskItem existingOutput in ExistingOutputs)
            {
                if (IncrementalLayoutState.TryGetFullPath(
                    Log,
                    existingOutput.ItemSpec,
                    nameof(ExistingOutputs),
                    out string existingOutputPath)
                    && !destinationPaths.Contains(existingOutputPath)
                    && staleOutputPaths.Add(existingOutputPath))
                {
                    staleOutputs.Add(new TaskItem(existingOutputPath));
                }
            }

            StaleOutputs = staleOutputs.ToArray();

            return !Log.HasLoggedErrors;
        }
        catch (Exception exception)
        {
            Log.LogErrorFromException(exception, showStackTrace: true);
            return false;
        }
    }
}

/// <summary>
/// Verifies that every expected layout output exists, then writes the completion stamp.
/// </summary>
/// <remarks>
/// The completion stamp is written atomically and only after all outputs are present, so
/// an interrupted or incomplete layout cannot appear complete to subsequent builds. All
/// path-bearing properties must contain fully qualified paths.
/// </remarks>
public sealed class CompleteIncrementalLayout : Task
{
    internal const string CompletionMarker = "complete";

    /// <summary>
    /// Gets or sets the fully qualified files that must exist before the layout can be
    /// marked complete.
    /// </summary>
    [Required]
    public ITaskItem[] ExpectedOutputs { get; set; } = [];

    /// <summary>
    /// Gets or sets the fully qualified completion stamp to write after every expected
    /// output is present.
    /// </summary>
    [Required]
    public string CompletionStampFile { get; set; } = string.Empty;

    public override bool Execute()
    {
        try
        {
            bool completionStampPathIsValid = IncrementalLayoutState.TryGetFullPath(
                Log,
                CompletionStampFile,
                nameof(CompletionStampFile),
                out string completionStampPath);

            if (completionStampPathIsValid)
            {
                File.Delete(completionStampPath);
            }

            foreach (ITaskItem expectedOutput in ExpectedOutputs)
            {
                if (IncrementalLayoutState.TryGetFullPath(
                    Log,
                    expectedOutput.ItemSpec,
                    nameof(ExpectedOutputs),
                    out string outputPath)
                    && !File.Exists(outputPath))
                {
                    Log.LogError($"Incremental layout output '{outputPath}' was not produced.");
                }
            }

            if (!completionStampPathIsValid || Log.HasLoggedErrors)
            {
                return false;
            }

            IncrementalLayoutState.WriteFile(
                completionStampPath,
                CompletionMarker + Environment.NewLine);

            return true;
        }
        catch (Exception exception)
        {
            Log.LogErrorFromException(exception, showStackTrace: true);
            return false;
        }
    }
}

internal static class IncrementalLayoutState
{
    public static StringComparer PathComparer { get; } =
        OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;

    public static bool TryGetFullPath(
        TaskLoggingHelper log,
        string path,
        string parameterName,
        out string fullPath)
    {
        if (!Path.IsPathFullyQualified(path))
        {
            log.LogError($"Incremental layout path '{path}' from '{parameterName}' must be fully qualified.");
            fullPath = string.Empty;
            return false;
        }

        fullPath = Path.GetFullPath(path);
        return true;
    }

    public static void WriteFile(string path, string contents)
    {
        string fullPath = Path.GetFullPath(path);
        Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);

        string temporaryPath = $"{fullPath}.{Guid.NewGuid():N}.tmp";
        try
        {
            File.WriteAllText(temporaryPath, contents, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
            File.Move(temporaryPath, fullPath, overwrite: true);
        }
        finally
        {
            File.Delete(temporaryPath);
        }
    }
}