Editor/Prism/Toolchain/TempWorkspace.cs

Editor utility that manages a per-editor-window temporary workspace under the mounted .source2/temp/prism folder. It creates/adopts session folders, writes files atomically (text and binary), tracks owned files, sweeps pending or stale artifacts, collects garbage sessions, and removes the session on Dispose.

File Access
using Editor.Prism.Core;
using System.IO;

namespace Editor.Prism.Toolchain;

/// <summary>
/// A per-window scratch folder under <c>&lt;sbox&gt;/.source2/temp/prism/</c>.
/// <para>
/// <c>.source2/temp/</c> is a real mounted game search path (<c>mod_temp</c>), so a shader written to
/// <c>.source2/temp/prism/&lt;session&gt;/preview.shader</c> and compiled in place is loadable at
/// runtime by the relative path <c>prism/&lt;session&gt;/preview.shader</c> — no asset registration in
/// the user's project, no source-control noise, no <c>.vmat</c>. The engine hardcodes the compiled
/// output path to <c>&lt;source&gt;.shader_c</c> beside the source, which is precisely why the source
/// has to live somewhere scratch in the first place.
/// </para>
/// <para>
/// Every write is atomic (temp file plus replace), every file written is tracked so a cancelled
/// compile can be swept clean, and stale sessions left behind by a crash are garbage collected on
/// startup. Nothing here throws: a workspace that could not be created reports
/// <see cref="IsValid"/> false and every operation degrades to a no-op.
/// </para>
/// </summary>
public sealed class TempWorkspace : IDisposable
{
	/// <summary>Extension used for the in-flight half of an atomic write.</summary>
	const string PendingSuffix = ".prism-tmp";

	/// <summary>Suffix the engine's resource compiler appends to a compiled shader.</summary>
	const string CompiledSuffix = "_c";

	readonly HashSet<string> _tracked = new( StringComparer.OrdinalIgnoreCase );
	readonly object _lock = new();

	bool _disposed;

	/// <summary>
	/// Create (or adopt) the scratch folder for a session. Passing null uses
	/// <see cref="PrismConstants.SessionId"/>, which is minted once per editor process.
	/// </summary>
	public TempWorkspace( string sessionId = null )
	{
		SessionId = Sanitize( string.IsNullOrWhiteSpace( sessionId ) ? PrismConstants.SessionId : sessionId );

		RootRelative = $"{PrismConstants.TempRoot}/{SessionId}";
		ContentRelative = $"{ContentPrefix}/{SessionId}";

		RootAbsolute = PrismLog.Guard( "Resolving the Prism scratch folder", () =>
		{
			var fs = Editor.FileSystem.Root;

			if ( fs is null ) return null;

			fs.CreateDirectory( RootRelative );

			var full = fs.GetFullPath( RootRelative );

			if ( string.IsNullOrWhiteSpace( full ) ) return null;

			Directory.CreateDirectory( full );

			return full.Replace( '\\', '/' ).TrimEnd( '/' );
		}, null );

		IsValid = !string.IsNullOrWhiteSpace( RootAbsolute );

		if ( !IsValid )
		{
			PrismLog.Warn( "Prism could not create a scratch workspace; preview compiles are disabled." );
			return;
		}

		// Adopting a folder a previous run left behind: a pending write is by definition an interrupted
		// one, and nothing will ever come back for it.
		SweepPending();
	}

	/// <summary>
	/// Delete the half-written files an interrupted atomic write leaves behind. Unlike
	/// <see cref="Sweep"/> this touches nothing else, so it is safe on a folder whose real contents are
	/// still in use.
	/// </summary>
	public int SweepPending()
	{
		if ( !IsValid ) return 0;

		return PrismLog.Guard( "Sweeping interrupted Prism scratch writes", () =>
		{
			var removed = 0;

			foreach ( var file in Directory.EnumerateFiles( RootAbsolute, "*" + PendingSuffix ) )
			{
				try
				{
					File.Delete( file );
					removed++;
				}
				catch ( IOException )
				{
				}
				catch ( UnauthorizedAccessException )
				{
				}
			}

			return removed;
		}, 0 );
	}

	/// <summary>The folder name Prism owns inside the mounted temp search path.</summary>
	public const string ContentPrefix = "prism";

	/// <summary>The session this workspace belongs to. Two open windows never share one.</summary>
	public string SessionId { get; }

	/// <summary>The session folder relative to <c>Editor.FileSystem.Root</c>.</summary>
	public string RootRelative { get; }

	/// <summary>
	/// The session folder as the runtime sees it, i.e. relative to the mounted <c>.source2/temp</c>
	/// search path. This is the form <c>mat_reloadshaders</c> and <c>Material.Create</c> want.
	/// </summary>
	public string ContentRelative { get; }

	/// <summary>Absolute path of the session folder, or null when it could not be created.</summary>
	public string RootAbsolute { get; }

	/// <summary>False when the workspace could not be created. Every operation then no-ops.</summary>
	public bool IsValid { get; }

	/// <summary>Files this workspace has written and still owns, as bare file names.</summary>
	public IReadOnlyCollection<string> Files
	{
		get
		{
			lock ( _lock ) return _tracked.ToArray();
		}
	}

	// ---- path helpers ----------------------------------------------------

	/// <summary>The path of a file in this session, relative to <c>Editor.FileSystem.Root</c>.</summary>
	public string Relative( string fileName ) => $"{RootRelative}/{Clean( fileName )}";

	/// <summary>The path of a file in this session as the runtime resolves it.</summary>
	public string ContentPath( string fileName ) => $"{ContentRelative}/{Clean( fileName )}";

	/// <summary>The absolute path of a file in this session, or null when the workspace is invalid.</summary>
	public string Absolute( string fileName ) =>
		IsValid ? $"{RootAbsolute}/{Clean( fileName )}" : null;

	// ---- io --------------------------------------------------------------

	/// <summary>
	/// Write a text file atomically: the bytes go to a sibling temp file first and only then replace the
	/// target, so a cancelled or crashed write can never leave a half-written shader for the compiler to
	/// choke on. Returns false when nothing was written.
	/// </summary>
	public bool Write( string fileName, string text )
	{
		if ( !IsValid || _disposed ) return false;

		var target = Absolute( fileName );

		if ( string.IsNullOrEmpty( target ) ) return false;

		return PrismLog.Guard( $"Writing {fileName}", () =>
		{
			var pending = target + PendingSuffix;

			Directory.CreateDirectory( Path.GetDirectoryName( target ) );
			File.WriteAllText( pending, text ?? string.Empty, System.Text.Encoding.UTF8 );

			// File.Replace demands an existing destination; Move with overwrite is atomic enough on
			// NTFS and is the only form that works for the first write.
			File.Move( pending, target, true );

			Track( fileName );

			return true;
		}, false );
	}

	/// <summary>Write a binary file atomically. Returns false when nothing was written.</summary>
	public bool WriteBytes( string fileName, byte[] bytes )
	{
		if ( !IsValid || _disposed ) return false;

		var target = Absolute( fileName );

		if ( string.IsNullOrEmpty( target ) ) return false;

		return PrismLog.Guard( $"Writing {fileName}", () =>
		{
			var pending = target + PendingSuffix;

			Directory.CreateDirectory( Path.GetDirectoryName( target ) );
			File.WriteAllBytes( pending, bytes ?? Array.Empty<byte>() );
			File.Move( pending, target, true );

			Track( fileName );

			return true;
		}, false );
	}

	/// <summary>Read a file back, or null when it is absent or unreadable.</summary>
	public string Read( string fileName )
	{
		var target = Absolute( fileName );

		if ( string.IsNullOrEmpty( target ) ) return null;

		return PrismLog.Guard( $"Reading {fileName}", () =>
			File.Exists( target ) ? File.ReadAllText( target ) : null, null );
	}

	/// <summary>True when the file exists in this session.</summary>
	public bool Exists( string fileName )
	{
		var target = Absolute( fileName );

		return !string.IsNullOrEmpty( target ) && PrismLog.Guard( "Probing the scratch workspace",
			() => File.Exists( target ), false );
	}

	/// <summary>
	/// Delete a file, its compiled <c>_c</c> sibling and any leftover pending write, and stop tracking
	/// it. Safe to call for something that was never written.
	/// </summary>
	public bool Delete( string fileName )
	{
		var target = Absolute( fileName );

		if ( string.IsNullOrEmpty( target ) ) return false;

		lock ( _lock ) _tracked.Remove( Clean( fileName ) );

		return PrismLog.Guard( $"Deleting {fileName}", () =>
		{
			var removed = false;

			foreach ( var candidate in new[] { target, target + CompiledSuffix, target + PendingSuffix } )
			{
				if ( !File.Exists( candidate ) ) continue;

				File.Delete( candidate );
				removed = true;
			}

			return removed;
		}, false );
	}

	/// <summary>Record a file as owned by this workspace without writing it.</summary>
	public void Track( string fileName )
	{
		if ( string.IsNullOrWhiteSpace( fileName ) ) return;

		lock ( _lock ) _tracked.Add( Clean( fileName ) );
	}

	/// <summary>Stop tracking a file, leaving it on disk.</summary>
	public void Forget( string fileName )
	{
		if ( string.IsNullOrWhiteSpace( fileName ) ) return;

		lock ( _lock ) _tracked.Remove( Clean( fileName ) );
	}

	/// <summary>
	/// Delete everything in the session folder that this workspace does not currently own — the
	/// half-finished artifacts a cancelled compile leaves behind. Returns how many files went.
	/// </summary>
	public int Sweep()
	{
		if ( !IsValid ) return 0;

		return PrismLog.Guard( "Sweeping the Prism scratch workspace", () =>
		{
			HashSet<string> keep;

			lock ( _lock ) keep = new HashSet<string>( _tracked, StringComparer.OrdinalIgnoreCase );

			var removed = 0;

			foreach ( var file in Directory.EnumerateFiles( RootAbsolute ) )
			{
				var name = Path.GetFileName( file );

				// A pending write is never owned by anyone: it is by definition an interrupted write.
				if ( !name.EndsWith( PendingSuffix, StringComparison.OrdinalIgnoreCase ) )
				{
					var owner = name.EndsWith( CompiledSuffix, StringComparison.Ordinal )
						? name[..^CompiledSuffix.Length]
						: name;

					if ( keep.Contains( owner ) ) continue;
				}

				try
				{
					File.Delete( file );
					removed++;
				}
				catch ( IOException )
				{
					// Held open by the compiler; it will go on the next sweep or with the session.
				}
			}

			return removed;
		}, 0 );
	}

	/// <summary>Delete every file in the session folder and stop tracking all of them.</summary>
	public int Clear()
	{
		lock ( _lock ) _tracked.Clear();

		return Sweep();
	}

	/// <summary>Remove the session folder entirely. Called when the owning window closes.</summary>
	public void Dispose()
	{
		if ( _disposed ) return;

		_disposed = true;

		if ( !IsValid ) return;

		PrismLog.Guard( "Removing the Prism scratch workspace", () =>
		{
			if ( Directory.Exists( RootAbsolute ) ) Directory.Delete( RootAbsolute, true );
		} );

		lock ( _lock ) _tracked.Clear();
	}

	// ---- static maintenance ----------------------------------------------

	/// <summary>Absolute path of <c>.source2/temp/prism</c>, or null when it cannot be resolved.</summary>
	public static string RootPath => PrismLog.Guard( "Resolving the Prism scratch root", () =>
	{
		var fs = Editor.FileSystem.Root;

		if ( fs is null ) return null;

		fs.CreateDirectory( PrismConstants.TempRoot );

		var full = fs.GetFullPath( PrismConstants.TempRoot );

		return string.IsNullOrWhiteSpace( full ) ? null : full.Replace( '\\', '/' ).TrimEnd( '/' );
	}, null );

	/// <summary>
	/// Delete session folders older than <paramref name="lifetimeHours"/>, plus any folder that is
	/// obviously not one of ours. Run once on editor start: a crash leaves the session folder behind and
	/// nobody ever comes back for it.
	/// </summary>
	public static int CollectGarbage( int lifetimeHours = PrismConstants.TempSessionLifetimeHours,
		string keepSessionId = null )
	{
		var root = RootPath;

		if ( string.IsNullOrWhiteSpace( root ) ) return 0;

		var keep = string.IsNullOrWhiteSpace( keepSessionId ) ? PrismConstants.SessionId : Sanitize( keepSessionId );

		return PrismLog.Guard( "Collecting stale Prism scratch sessions", () =>
		{
			var cutoff = DateTime.UtcNow.AddHours( -Math.Max( 0, lifetimeHours ) );
			var removed = 0;

			foreach ( var directory in Directory.EnumerateDirectories( root ) )
			{
				// One unreadable session must never stop the others being collected, so the whole
				// per-folder decision is isolated rather than only the two calls that usually fail.
				try
				{
					var name = Path.GetFileName( directory );

					if ( string.Equals( name, keep, StringComparison.OrdinalIgnoreCase ) ) continue;
					if ( Newest( directory, cutoff ) > cutoff ) continue;

					Directory.Delete( directory, true );
					removed++;
				}
				catch ( IOException )
				{
					// Another editor instance still owns it. Leave it; it will age out later.
				}
				catch ( UnauthorizedAccessException )
				{
				}
			}

			// A stray file directly under the root is not a session and nothing will ever claim it.
			foreach ( var file in Directory.EnumerateFiles( root ) )
			{
				try
				{
					if ( File.GetLastWriteTimeUtc( file ) > cutoff ) continue;

					File.Delete( file );
					removed++;
				}
				catch ( IOException )
				{
				}
				catch ( UnauthorizedAccessException )
				{
				}
			}

			if ( removed > 0 ) PrismLog.Trace( $"Removed {removed} stale Prism scratch item(s)" );

			return removed;
		}, 0 );
	}

	/// <summary>
	/// The most recent write time anywhere inside a folder, including the folder itself. Stops as soon
	/// as it finds something newer than the cutoff, because the only question being asked is "is any of
	/// this still live?" and a compiled shader tree can hold thousands of files.
	/// </summary>
	static DateTime Newest( string directory, DateTime? stopAfter = null )
	{
		var newest = Directory.GetLastWriteTimeUtc( directory );

		if ( stopAfter is { } cutoff && newest > cutoff ) return newest;

		foreach ( var file in Directory.EnumerateFiles( directory, "*", SearchOption.AllDirectories ) )
		{
			var stamp = File.GetLastWriteTimeUtc( file );

			if ( stamp > newest ) newest = stamp;
			if ( stopAfter is { } limit && newest > limit ) break;
		}

		return newest;
	}

	// ---- naming ----------------------------------------------------------

	/// <summary>Strip anything that could escape the session folder or upset the resource compiler.</summary>
	static string Clean( string fileName )
	{
		if ( string.IsNullOrWhiteSpace( fileName ) ) return "unnamed";

		var name = Path.GetFileName( fileName.Replace( '\\', '/' ) );

		if ( string.IsNullOrWhiteSpace( name ) ) return "unnamed";

		var builder = new System.Text.StringBuilder( name.Length );

		foreach ( var c in name )
		{
			builder.Append( char.IsLetterOrDigit( c ) || c is '.' or '_' or '-' ? c : '_' );
		}

		var cleaned = builder.ToString().Trim( '.', '_', '-' );

		return cleaned.Length == 0 ? "unnamed" : cleaned;
	}

	/// <summary>Session ids become folder names, so they get the same treatment.</summary>
	static string Sanitize( string sessionId )
	{
		var builder = new System.Text.StringBuilder( sessionId.Length );

		foreach ( var c in sessionId )
		{
			builder.Append( char.IsLetterOrDigit( c ) || c is '_' or '-' ? c : '_' );
		}

		var cleaned = builder.ToString().Trim( '_', '-' );

		return cleaned.Length == 0 ? "session" : cleaned;
	}

	/// <inheritdoc/>
	public override string ToString() => IsValid ? RootRelative : $"{RootRelative} (unavailable)";
}