Editor/Core/SoloCopy.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

namespace TeamCreate;

public sealed record SoloCopyResult(bool Ok, string Path, string Error, int Files, long Bytes);

/// <summary>
/// Builds the disposable copy of a project in which the solo test runs. A copy is a folder under
/// <c>.collaboration/test-copies/</c> that carries a marker file; the session honours the test identity only inside such a folder, so the
/// copy is what makes the test safe to run next to real work. Nothing here is engine code, so CoreTests runs it against temporary folders.
/// </summary>
public static class SoloCopy
{
    public const string FolderName = "Solo";

    public const long DefaultMaxBytes = 2L * 1024 * 1024 * 1024;

    /// <summary>The project folders that make a runnable copy. Everything else (build output, caches, the real project's journals) stays behind.</summary>
    public static readonly string[] Folders = { "Assets", "Code", "Libraries", "ProjectSettings" };

    private static readonly Regex Skipped = new Regex(@"[\\/](obj|bin|Properties|\.sbox)[\\/]|\.csproj$|\.slnx?$|_c$|_d$|\.generated\.", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);

    /// <summary>A second editor holds about 3.6 GB. Below this much AVAILABLE memory (free plus the standby cache) the test is refused.</summary>
    public const long MinAvailableBytes = 3L * 1024 * 1024 * 1024;

    /// <summary>
    /// The sentence to show when there is not room for a second editor, or null when there is (or when the reading is unavailable, because a
    /// guess must not block a person who has plenty of room).
    /// </summary>
    public static string MemoryAdvice(long? availableBytes)
    {
        if (!availableBytes.HasValue || availableBytes.Value < 0 || availableBytes.Value >= MinAvailableBytes)
        {
            return null;
        }
        double gb = availableBytes.Value / (1024.0 * 1024 * 1024);
        return "Only " + gb.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + " GB of memory is available. The solo test opens a second editor, which needs about 3 GB, so close some programs first. To run it anyway, use Tools/Run-SoloTest.ps1 -Force.";
    }

    public static string CopyRoot(string projectRoot)
    {
        return Path.Combine(projectRoot, ".collaboration", "test-copies", FolderName);
    }

    /// <summary>True for generated or per-machine files that a copy must not carry. The path is relative to the project root.</summary>
    public static bool ShouldSkip(string relativePath)
    {
        if (string.IsNullOrEmpty(relativePath))
        {
            return true;
        }
        return Skipped.IsMatch("/" + relativePath.Replace('\\', '/'));
    }

    public static string MarkerPath(string copyRoot)
    {
        return Path.Combine(copyRoot, ".collaboration", TestHarnessRules.MarkerFile);
    }

    public static bool HasMarker(string copyRoot)
    {
        try
        {
            return !string.IsNullOrEmpty(copyRoot) && File.Exists(MarkerPath(copyRoot));
        }
        catch (Exception)
        {
            return false;
        }
    }

    /// <summary>The marker's first line is <c>autorun</c> once, right after the copy is built, so the test starts by itself; then <c>manual</c>.</summary>
    public static bool WantsAutorun(string copyRoot)
    {
        try
        {
            string path = MarkerPath(copyRoot);
            return File.Exists(path) && File.ReadLines(path).FirstOrDefault()?.Trim() == "autorun";
        }
        catch (Exception)
        {
            return false;
        }
    }

    /// <summary>Called as the test starts, so a restart of the editor does not run it again unasked.</summary>
    public static void ConsumeAutorun(string copyRoot)
    {
        try
        {
            string path = MarkerPath(copyRoot);
            if (File.Exists(path))
            {
                string[] lines = File.ReadAllLines(path);
                if (lines.Length > 0 && lines[0].Trim() == "autorun")
                {
                    lines[0] = "manual";
                    File.WriteAllLines(path, lines);
                }
            }
        }
        catch (Exception)
        {
        }
    }

    public static SoloCopyResult Build(string sourceRoot, bool autorun, long maxBytes = DefaultMaxBytes)
    {
        if (string.IsNullOrWhiteSpace(sourceRoot) || !Directory.Exists(sourceRoot))
        {
            return Fail("The project folder could not be found.");
        }
        if (TestHarnessRules.IsDisposableCopyPath(sourceRoot))
        {
            return Fail("This is already a solo test copy. Run the test from here instead of copying it again.");
        }
        string projectFile = Directory.EnumerateFiles(sourceRoot, "*.sbproj", SearchOption.TopDirectoryOnly).FirstOrDefault();
        if (projectFile == null)
        {
            return Fail("No .sbproj file was found in the project folder.");
        }
        string copyRoot = CopyRoot(sourceRoot);
        if (!TestHarnessRules.IsDisposableCopyPath(copyRoot))
        {
            return Fail("The copy location is not inside .collaboration/test-copies/, so it was not created.");
        }

        List<(string From, string Rel, long Size)> files = new List<(string, string, long)>();
        long total = 0;
        try
        {
            foreach (string folder in Folders)
            {
                string from = Path.Combine(sourceRoot, folder);
                if (!Directory.Exists(from))
                {
                    continue;
                }
                foreach (string file in Directory.EnumerateFiles(from, "*", SearchOption.AllDirectories))
                {
                    string rel = Path.GetRelativePath(sourceRoot, file).Replace('\\', '/');
                    if (ShouldSkip(rel))
                    {
                        continue;
                    }
                    long size = new FileInfo(file).Length;
                    files.Add((file, rel, size));
                    total += size;
                    if (total > maxBytes)
                    {
                        return Fail("The project is larger than " + Megabytes(maxBytes) + " MB, which is more than a solo test copy will hold. Use a smaller project, or test with two Steam accounts.");
                    }
                }
            }
        }
        catch (Exception ex)
        {
            return Fail("The project could not be read: " + ex.Message);
        }

        if (Directory.Exists(copyRoot))
        {
            if (!HasMarker(copyRoot) && !HoldsNoFiles(copyRoot))
            {
                return Fail("A folder already exists at " + copyRoot + " that is not a solo test copy, so it was left alone. Move or delete it and try again.");
            }
            string cleared = Clear(copyRoot);
            if (cleared != null)
            {
                return Fail(cleared);
            }
        }

        try
        {
            foreach ((string from, string rel, long _) in files)
            {
                string to = Path.Combine(copyRoot, rel);
                Directory.CreateDirectory(Path.GetDirectoryName(to));
                File.Copy(from, to, true);
            }
            File.Copy(projectFile, Path.Combine(copyRoot, Path.GetFileName(projectFile)), true);
            Directory.CreateDirectory(Path.Combine(copyRoot, ".collaboration"));
            File.WriteAllLines(MarkerPath(copyRoot), new[]
            {
                autorun ? "autorun" : "manual",
                "Disposable copy made for the solo test. Safe to delete. It is never used for real work."
            });
        }
        catch (Exception ex)
        {
            return Fail("The solo test copy could not be written: " + ex.Message);
        }
        return new SoloCopyResult(true, copyRoot, null, files.Count + 1, total);

        static SoloCopyResult Fail(string message)
        {
            return new SoloCopyResult(false, null, message, 0, 0);
        }
    }

    /// <summary>True when the folder holds directories only. Nothing there can be somebody's work, so it is safe to reclaim.</summary>
    private static bool HoldsNoFiles(string folder)
    {
        try
        {
            return !Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Any();
        }
        catch (Exception)
        {
            return false;
        }
    }

    /// <summary>
    /// Empties the copy folder. The marker is removed LAST: if a file is held open (a terminal or editor still inside the old copy) the delete stops
    /// partway, and a folder that has lost its marker but keeps files would be refused as "not a solo test copy" from then on. Keeping the marker until
    /// everything else is gone means a failed replacement leaves a copy that is still recognised, and the next attempt simply tries again.
    /// </summary>
    private static string Clear(string copyRoot)
    {
        string marker = MarkerPath(copyRoot);
        try
        {
            foreach (string entry in Directory.EnumerateFileSystemEntries(copyRoot))
            {
                if (string.Equals(Path.GetFullPath(entry), Path.GetFullPath(Path.Combine(copyRoot, ".collaboration")), StringComparison.OrdinalIgnoreCase))
                {
                    foreach (string inner in Directory.EnumerateFileSystemEntries(entry).Where((string x) => !string.Equals(Path.GetFullPath(x), Path.GetFullPath(marker), StringComparison.OrdinalIgnoreCase)))
                    {
                        Remove(inner);
                    }
                }
                else
                {
                    Remove(entry);
                }
            }
            // The folders themselves are kept, only their contents go. A terminal or editor whose working directory is inside the copy holds that
            // DIRECTORY open, which blocks deleting it but not the files in it; the copy is refilled in place, so it does not matter.
            return null;
        }
        catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
        {
            return "The previous solo test copy still has a file open, so it could not be replaced. Close the solo test editor window and any terminal or program open inside " + copyRoot + ", then try again. (" + ex.Message + ")";
        }

        static void Remove(string path)
        {
            if (Directory.Exists(path))
            {
                foreach (string inner in Directory.EnumerateFileSystemEntries(path))
                {
                    Remove(inner);
                }
                try
                {
                    Directory.Delete(path, false);
                }
                catch (IOException)
                {
                    // Held open as some process's working directory. It is empty now, so leaving it is harmless.
                }
                catch (UnauthorizedAccessException)
                {
                }
            }
            else
            {
                File.Delete(path);
            }
        }
    }

    private static long Megabytes(long bytes)
    {
        return bytes / (1024 * 1024);
    }
}