Editor/SyncToolWindow.DataSourceStatus.cs
#nullable disable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Sandbox;
public partial class SyncToolWindow
{
private sealed class DataSourceStatusInfo
{
public bool SourceExists { get; init; }
public bool OutputExists { get; init; }
public bool IsStale { get; init; }
public string Icon { get; init; } = "?";
public Color Color { get; init; } = Color.White.WithAlpha( 0.6f );
public string Label { get; init; } = "Unknown";
public string Detail { get; init; } = "";
}
private readonly Dictionary<string, DataSourceStatusInfo> _dataSourceStatusCache = new();
private DataSourceStatusInfo GetDataSourceStatus( SyncToolConfig.SyncMapping mapping )
{
var csFiles = GetMappingSourceFiles( mapping, out var sourceExists );
var outputPath = SyncToolConfig.GetMappingCollectionPath( mapping );
var outputExists = File.Exists( outputPath );
var sourceTicks = sourceExists ? csFiles.Max( File.GetLastWriteTimeUtc ).Ticks : 0;
var outputTicks = outputExists ? File.GetLastWriteTimeUtc( outputPath ).Ticks : 0;
var cacheKey = $"{mapping.CsFile}|{mapping.Collection}|{csFiles.Length}|{sourceTicks}|{outputPath}|{outputTicks}";
if ( _dataSourceStatusCache.TryGetValue( cacheKey, out var cached ) )
return cached;
var status = BuildDataSourceStatus( mapping, csFiles, sourceExists, outputPath, outputExists, sourceTicks, outputTicks );
if ( _dataSourceStatusCache.Count > 64 )
_dataSourceStatusCache.Clear();
_dataSourceStatusCache[cacheKey] = status;
return status;
}
private static DataSourceStatusInfo BuildDataSourceStatus(
SyncToolConfig.SyncMapping mapping,
string[] csFiles,
bool sourceExists,
string outputPath,
bool outputExists,
long sourceTicks,
long outputTicks )
{
if ( !sourceExists )
{
return new DataSourceStatusInfo
{
SourceExists = false,
OutputExists = outputExists,
Icon = "x",
Color = Color.Red.WithAlpha( 0.75f ),
Label = "Missing source",
Detail = $"No .cs files found at {mapping.CsFile}"
};
}
if ( !outputExists )
{
return new DataSourceStatusInfo
{
SourceExists = true,
OutputExists = false,
IsStale = true,
Icon = "!",
Color = Color.Yellow.WithAlpha( 0.85f ),
Label = "Needs generate",
Detail = $"{mapping.Collection}.collection.yml does not exist yet"
};
}
var generatedHash = TryReadGeneratedSourceHash( outputPath );
if ( !string.IsNullOrWhiteSpace( generatedHash ) )
{
var currentHash = ComputeDataSourceHash( csFiles );
var matches = string.Equals( currentHash, generatedHash, StringComparison.OrdinalIgnoreCase );
return new DataSourceStatusInfo
{
SourceExists = true,
OutputExists = true,
IsStale = !matches,
Icon = matches ? "+" : "!",
Color = matches ? Color.Green.WithAlpha( 0.65f ) : Color.Yellow.WithAlpha( 0.85f ),
Label = matches ? "Generated" : "Needs generate",
Detail = matches ? "C# source matches generated collection" : "C# source changed since the collection was generated"
};
}
var staleByTime = sourceTicks > outputTicks;
return new DataSourceStatusInfo
{
SourceExists = true,
OutputExists = true,
IsStale = staleByTime,
Icon = staleByTime ? "!" : "+",
Color = staleByTime ? Color.Yellow.WithAlpha( 0.85f ) : Color.Green.WithAlpha( 0.55f ),
Label = staleByTime ? "Needs generate" : "Generated",
Detail = staleByTime
? "C# source is newer than the collection output"
: "No generated hash found; timestamp check passed"
};
}
private static string[] GetMappingSourceFiles( SyncToolConfig.SyncMapping mapping, out bool sourceExists )
{
var csPath = SyncToolConfig.GetMappingCsPath( mapping );
if ( File.Exists( csPath ) )
{
sourceExists = true;
return new[] { csPath };
}
if ( Directory.Exists( csPath ) )
{
var files = Directory.GetFiles( csPath, "*.cs" )
.OrderBy( path => path, StringComparer.OrdinalIgnoreCase )
.ToArray();
sourceExists = files.Length > 0;
return files;
}
sourceExists = false;
return Array.Empty<string>();
}
private static string TryReadGeneratedSourceHash( string outputPath )
{
try
{
foreach ( var line in File.ReadLines( outputPath ).Take( 20 ) )
{
var trimmed = line.Trim();
const string key = "# generatedSourceHash:";
if ( trimmed.StartsWith( key, StringComparison.OrdinalIgnoreCase ) )
return trimmed[key.Length..].Trim();
}
}
catch
{
}
return "";
}
private static string ComputeDataSourceHash( string[] csFiles )
{
using var buffer = new MemoryStream();
foreach ( var file in csFiles.OrderBy( path => ProjectRelativePath( path ), StringComparer.OrdinalIgnoreCase ) )
{
WriteUtf8( buffer, ProjectRelativePath( file ) );
WriteUtf8( buffer, "\n" );
var bytes = File.ReadAllBytes( file );
buffer.Write( bytes, 0, bytes.Length );
WriteUtf8( buffer, "\n" );
}
using var sha = SHA256.Create();
return ToHex( sha.ComputeHash( buffer.ToArray() ) );
}
private static string ProjectRelativePath( string absolutePath )
{
return Path.GetRelativePath( SyncToolConfig.ProjectRoot, absolutePath )
.Replace( Path.DirectorySeparatorChar, '/' )
.Replace( Path.AltDirectorySeparatorChar, '/' );
}
private static void WriteUtf8( Stream stream, string text )
{
var bytes = Encoding.UTF8.GetBytes( text );
stream.Write( bytes, 0, bytes.Length );
}
private static string ToHex( byte[] bytes )
{
var builder = new StringBuilder( bytes.Length * 2 );
foreach ( var b in bytes )
builder.Append( b.ToString( "x2" ) );
return builder.ToString();
}
}