Editor/CodeGenerator.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using Sandbox;
using Editor;
/// <summary>
/// Generates strongly-typed C# files from Network Storage collection schemas,
/// endpoint definitions, workflow IDs, and project configuration.
///
/// Output goes to Code/Data/NetworkStorage/ in the game project, with filenames
/// prefixed "autoGenerated_" to make it clear they are machine-written.
///
/// Called automatically after Push All in the Sync Tool, or manually via
/// the Settings window.
/// </summary>
public static class CodeGenerator
{
private const string OutputFolder = "Code/Data/NetworkStorage";
private const string FileHeader =
"// <auto-generated>\n" +
"// Auto-generated by Network Storage Sync Tool — DO NOT EDIT.\n" +
"// Any changes will be overwritten on next sync.\n" +
"//\n" +
"// To modify, edit the source files in Editor/Network Storage/ and use\n" +
"// Editor → Network Storage → Sync Tool to push changes and regenerate.\n" +
"// </auto-generated>\n\n";
private sealed class EndpointMeta
{
public string Slug;
public string Name;
public string Description;
public string Method = "POST";
public bool Enabled = true;
public JsonElement? Element;
}
private sealed class WorkflowMeta
{
public string Id;
public string Description;
public JsonElement? Element;
}
// ──────────────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────────────
/// <summary>
/// Generate all auto-generated C# files from the current local schemas,
/// and update the runtime credentials file so the game client can auto-configure.
/// Returns the number of files written.
/// </summary>
public static int Generate()
{
var dir = SyncToolConfig.Abs( OutputFolder );
if ( !Directory.Exists( dir ) )
Directory.CreateDirectory( dir );
// Clean previous auto-generated files
foreach ( var f in Directory.GetFiles( dir, "autoGenerated_*.cs" ) )
File.Delete( f );
int count = 0;
count += WriteReadme( dir ) ? 1 : 0;
count += WriteConfig( dir ) ? 1 : 0;
count += WriteCollections( dir ) ? 1 : 0;
count += WriteEndpoints( dir ) ? 1 : 0;
count += WriteWorkflows( dir ) ? 1 : 0;
// Keep the runtime credentials file in sync so NetworkStorage.AutoConfigure() works
WriteRuntimeCredentials();
return count;
}
/// <summary>
/// Update Assets/network-storage.credentials.json with current SyncToolConfig values.
/// Merges into the existing file rather than overwriting — unknown fields (e.g. proxyEnabled)
/// set by the Settings window or other tools are preserved.
/// </summary>
private static void WriteRuntimeCredentials()
{
if ( string.IsNullOrEmpty( SyncToolConfig.ProjectId ) ) return;
var path = SyncToolConfig.Abs( SyncToolConfig.RuntimeCredentialsFile );
// Ensure Assets/ directory exists
var assetsDir = Path.GetDirectoryName( path );
if ( !Directory.Exists( assetsDir ) )
Directory.CreateDirectory( assetsDir );
// Read existing file to preserve fields we don't own (e.g. proxyEnabled)
var merged = new Dictionary<string, object>();
if ( File.Exists( path ) )
{
try
{
var existing = JsonSerializer.Deserialize<JsonElement>( File.ReadAllText( path ) );
foreach ( var prop in existing.EnumerateObject() )
{
merged[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.True => (object)true,
JsonValueKind.False => false,
JsonValueKind.Number => prop.Value.TryGetInt64( out var l ) ? l : prop.Value.GetDouble(),
_ => prop.Value.GetString() ?? ""
};
}
}
catch { /* Corrupt file — start fresh */ }
}
// Overwrite only the fields this generator owns
merged["projectId"] = SyncToolConfig.ProjectId;
merged["publicKey"] = SyncToolConfig.PublicApiKey;
merged["baseUrl"] = SyncToolConfig.BaseUrl;
merged["cdnUrl"] = SyncToolConfig.CdnUrl ?? "";
merged["apiVersion"] = SyncToolConfig.ApiVersion;
merged["enableAuthSessions"] = SyncToolConfig.EnableAuthSessions;
merged["enableEncryptedRequests"] = SyncToolConfig.EnableEncryptedRequests;
merged["publishTarget"] = SyncToolConfig.PublishTarget;
File.WriteAllText( path, JsonSerializer.Serialize( merged, new JsonSerializerOptions { WriteIndented = true } ) );
}
// ──────────────────────────────────────────────────────
// Readme
// ──────────────────────────────────────────────────────
private static bool WriteReadme( string dir )
{
var path = Path.Combine( dir, "Readme.txt" );
var sb = new StringBuilder();
sb.AppendLine( "================================================================================" );
sb.AppendLine( " Network Storage — Auto-Generated Code" );
sb.AppendLine( "================================================================================" );
sb.AppendLine();
sb.AppendLine( " DO NOT EDIT the autoGenerated_*.cs files in this folder." );
sb.AppendLine( " They are regenerated every time you push from the Sync Tool." );
sb.AppendLine();
sb.AppendLine( " HOW TO MAKE CHANGES" );
sb.AppendLine( " —————————-" );
sb.AppendLine();
sb.AppendLine( " 1. Edit the YAML source files in your project's Editor/Network Storage/ folder:" );
sb.AppendLine();
sb.AppendLine( " source files: *.collection.yml, *.endpoint.yml, *.workflow.yml" );
sb.AppendLine( " .yml is accepted as an alias and is the preferred local format for this project" );
sb.AppendLine( " legacy resource JSON is unsupported; use YAML source only" );
sb.AppendLine( " config/ — API credentials and project settings" );
sb.AppendLine();
sb.AppendLine( " 2. Push changes to the backend and regenerate this folder:" );
sb.AppendLine();
sb.AppendLine( " Editor → Network Storage → Sync Tool → Push All" );
sb.AppendLine();
sb.AppendLine( " Or regenerate code without pushing:" );
sb.AppendLine();
sb.AppendLine( " Editor → Network Storage → Generate Code" );
sb.AppendLine();
sb.AppendLine( " ARCHITECTURE" );
sb.AppendLine( " ——————" );
sb.AppendLine();
sb.AppendLine( " Editor/Network Storage/ — Full backend configuration. Stored in your" );
sb.AppendLine( " project repository for syncing with the" );
sb.AppendLine( " Network Storage API. Contains endpoint logic," );
sb.AppendLine( " collection schemas, validation workflows, and" );
sb.AppendLine( " secret keys. Editor-only — this folder is" );
sb.AppendLine( " excluded from the published game." );
sb.AppendLine();
sb.AppendLine( " Code/Data/NetworkStorage/ — Game bundle code. Auto-generated typed" );
sb.AppendLine( " references to your backend schema. Ships with" );
sb.AppendLine( " the published game so runtime code can use" );
sb.AppendLine( " constants and types instead of magic strings." );
sb.AppendLine( " Read-only — regenerated on every sync." );
sb.AppendLine();
sb.AppendLine( " USAGE EXAMPLES" );
sb.AppendLine( " ———————" );
sb.AppendLine();
sb.AppendLine( " // Configure the client at startup (auto-generated credentials)" );
sb.AppendLine( " NSConfig.EnsureConfigured();" );
sb.AppendLine();
sb.AppendLine( " // Call endpoints with typed slugs instead of magic strings" );
sb.AppendLine( " var data = await NetworkStorage.CallEndpoint( NSEndpoints.LoadPlayer );" );
sb.AppendLine();
sb.AppendLine( " // Reference input fields by name" );
sb.AppendLine( " var result = await NetworkStorage.CallEndpoint( NSEndpoints.SellOre," );
sb.AppendLine( " new { ore_id = \"moon_ore\", kg = 10.0, faction_id = \"avery_exchange\" } );" );
sb.AppendLine();
sb.AppendLine( " // Access collection field paths for queries" );
sb.AppendLine( " string field = PlayerDataSchema.Ores.MoonOre; // \"ores.moon_ore\"" );
sb.AppendLine();
sb.AppendLine( " // Read game constants" );
sb.AppendLine( " int startingQC = GameValuesSchema.Progression.StartingCurrency_Value; // 500" );
sb.AppendLine();
sb.AppendLine( "================================================================================" );
File.WriteAllText( path, sb.ToString() );
return true;
}
// ──────────────────────────────────────────────────────
// Config
// ──────────────────────────────────────────────────────
private static bool WriteConfig( string dir )
{
if ( string.IsNullOrEmpty( SyncToolConfig.ProjectId ) ) return false;
var proxyEnabledLiteral = SyncToolConfig.ProxyEnabled ? "true" : "false";
var authSessionsLiteral = SyncToolConfig.EnableAuthSessions ? "true" : "false";
var encryptedRequestsLiteral = SyncToolConfig.EnableEncryptedRequests ? "true" : "false";
var sb = new StringBuilder();
sb.Append( FileHeader );
sb.AppendLine( "namespace Sandbox;" );
sb.AppendLine();
sb.AppendLine( "/// <summary>" );
sb.AppendLine( "/// Network Storage project configuration — auto-generated from Editor → Network Storage → Sync Tool." );
sb.AppendLine( "/// The public key (sbox_ns_ prefix) is safe to embed — it is NOT a secret." );
sb.AppendLine( "/// Runtime settings (e.g. ProxyEnabled) are controlled via Editor → Network Storage → Settings." );
sb.AppendLine( "/// </summary>" );
sb.AppendLine( "public static class NSConfig" );
sb.AppendLine( "{" );
sb.AppendLine( $"\tpublic const string ProjectId = \"{Escape( SyncToolConfig.ProjectId )}\";" );
sb.AppendLine( $"\tpublic const string PublicKey = \"{Escape( SyncToolConfig.PublicApiKey )}\";" );
sb.AppendLine( $"\tpublic const string BaseUrl = \"{Escape( SyncToolConfig.BaseUrl )}\";" );
sb.AppendLine( $"\tpublic const string ApiVersion = \"{Escape( SyncToolConfig.ApiVersion )}\";" );
sb.AppendLine( $"\tpublic const string PublishTarget = \"{Escape( SyncToolConfig.PublishTarget )}\";" );
sb.AppendLine();
sb.AppendLine( "\t/// <summary>" );
sb.AppendLine( "\t/// Whether the game host proxies Network Storage API calls for non-host clients." );
sb.AppendLine( "\t/// Enable for editor/local testing. Disable in production when every player has their own Steam account." );
sb.AppendLine( "\t/// Controlled via Editor → Network Storage → Settings." );
sb.AppendLine( "\t/// </summary>" );
sb.AppendLine( $"\tpublic const bool ProxyEnabled = {proxyEnabledLiteral};" );
sb.AppendLine();
sb.AppendLine( "\t/// <summary>Whether this project is configured to use fast auth sessions.</summary>" );
sb.AppendLine( $"\tpublic const bool EnableAuthSessions = {authSessionsLiteral};" );
sb.AppendLine();
sb.AppendLine( "\t/// <summary>Whether this project is configured to send encrypted Network Storage requests.</summary>" );
sb.AppendLine( $"\tpublic const bool EnableEncryptedRequests = {encryptedRequestsLiteral};" );
sb.AppendLine();
sb.AppendLine( "\t/// <summary>Configure the NetworkStorage runtime client. Delegates to NetworkStorage.EnsureConfigured().</summary>" );
sb.AppendLine( "\tpublic static void EnsureConfigured() => NetworkStorage.EnsureConfigured();" );
sb.AppendLine( "}" );
File.WriteAllText( Path.Combine( dir, "autoGenerated_Config.cs" ), sb.ToString() );
return true;
}
// ──────────────────────────────────────────────────────
// Collections
// ──────────────────────────────────────────────────────
private static bool WriteCollections( string dir )
{
var collections = SyncToolConfig.LoadCollections();
MergeSourceCollectionSummaries( collections );
if ( collections.Count == 0 ) return false;
var sb = new StringBuilder();
sb.Append( FileHeader );
sb.AppendLine( "namespace Sandbox;" );
foreach ( var (name, data) in collections )
{
sb.AppendLine();
var json = JsonSerializer.Serialize( data );
var root = JsonSerializer.Deserialize<JsonElement>( json );
var description = GetString( root, "description" ) ?? name;
var scope = GetString( root, "collectionType" ) ?? "unknown";
var access = GetString( root, "accessMode" ) ?? "unknown";
var className = ToPascalCase( name ) + "Schema";
sb.AppendLine( $"/// <summary>{XmlEscape( description )}</summary>" );
sb.AppendLine( $"/// <remarks>Collection: {name} | Scope: {scope} | Access: {access}</remarks>" );
sb.AppendLine( $"public static class {className}" );
sb.AppendLine( "{" );
sb.AppendLine( $"\tpublic const string CollectionName = \"{Escape( name )}\";" );
sb.AppendLine( $"\tpublic const string Scope = \"{Escape( scope )}\";" );
sb.AppendLine( $"\tpublic const string AccessMode = \"{Escape( access )}\";" );
// Schema properties
if ( root.TryGetProperty( "schema", out var schema )
&& schema.TryGetProperty( "properties", out var props ) )
{
sb.AppendLine();
EmitSchemaProperties( sb, props, "\t", "" );
}
// Constants (game_values style)
if ( root.TryGetProperty( "constants", out var constants )
&& constants.ValueKind == JsonValueKind.Array )
{
sb.AppendLine();
sb.AppendLine( "\t// ── Constants ──" );
EmitConstants( sb, constants, "\t" );
}
// Tables (game_values style)
if ( root.TryGetProperty( "tables", out var tables )
&& tables.ValueKind == JsonValueKind.Array )
{
sb.AppendLine();
sb.AppendLine( "\t// ── Tables ──" );
sb.AppendLine();
sb.AppendLine( "\t/// <summary>Table definitions with column key constants.</summary>" );
sb.AppendLine( "\tpublic static class Tables" );
sb.AppendLine( "\t{" );
EmitTables( sb, tables, "\t\t" );
sb.AppendLine( "\t}" );
}
sb.AppendLine( "}" );
}
File.WriteAllText( Path.Combine( dir, "autoGenerated_Collections.cs" ), sb.ToString() );
return true;
}
private static void MergeSourceCollectionSummaries( List<(string Name, Dictionary<string, object> Data)> collections )
{
var existing = new HashSet<string>( collections.Select( c => c.Name ), StringComparer.OrdinalIgnoreCase );
foreach ( var summary in SyncToolConfig.LoadSourceSummaries( "collection" ) )
{
if ( string.IsNullOrWhiteSpace( summary.Id ) || existing.Contains( summary.Id ) )
continue;
collections.Add( (summary.Id, new Dictionary<string, object>
{
["name"] = summary.Id,
["description"] = string.IsNullOrWhiteSpace( summary.Description ) ? summary.Id : summary.Description,
["collectionType"] = "source",
["accessMode"] = "unknown",
["schema"] = new Dictionary<string, object>()
}) );
existing.Add( summary.Id );
}
}
private static void EmitSchemaProperties( StringBuilder sb, JsonElement props, string indent, string pathPrefix )
{
foreach ( var prop in props.EnumerateObject() )
{
var key = prop.Name;
var val = prop.Value;
var fullPath = string.IsNullOrEmpty( pathPrefix ) ? key : $"{pathPrefix}.{key}";
var type = GetString( val, "type" ) ?? "unknown";
if ( type == "object" && val.TryGetProperty( "properties", out var nested ) )
{
// Nested object → inner static class
sb.AppendLine();
sb.AppendLine( $"{indent}/// <summary>{XmlEscape( ToPascalCase( key ) )} fields.</summary>" );
sb.AppendLine( $"{indent}public static class {ToPascalCase( key )}" );
sb.AppendLine( $"{indent}{{" );
EmitSchemaProperties( sb, nested, indent + "\t", fullPath );
sb.AppendLine( $"{indent}}}" );
}
else
{
// Leaf property → const string
var doc = BuildTypeDoc( val );
sb.AppendLine( $"{indent}/// <summary>{XmlEscape( doc )}</summary>" );
sb.AppendLine( $"{indent}public const string {ToPascalCase( key )} = \"{Escape( fullPath )}\";" );
}
}
}
private static void EmitConstants( StringBuilder sb, JsonElement constants, string indent )
{
foreach ( var group in constants.EnumerateArray() )
{
var id = GetString( group, "id" ) ?? "unknown";
var name = GetString( group, "name" ) ?? id;
sb.AppendLine();
sb.AppendLine( $"{indent}/// <summary>{XmlEscape( name )}</summary>" );
sb.AppendLine( $"{indent}public static class {ToPascalCase( id )}" );
sb.AppendLine( $"{indent}{{" );
if ( group.TryGetProperty( "entries", out var entries ) )
{
foreach ( var entry in entries.EnumerateObject() )
{
var constName = ToPascalCase( entry.Name );
var val = entry.Value;
if ( val.ValueKind == JsonValueKind.Number )
{
// Determine int vs double
if ( val.TryGetDouble( out var dbl ) )
{
bool isInt = dbl == Math.Floor( dbl ) && Math.Abs( dbl ) < int.MaxValue;
var valueStr = isInt ? ((int)dbl).ToString() : dbl.ToString( "G" );
var typeName = isInt ? "int" : "double";
var suffix = isInt ? "" : "";
sb.AppendLine( $"{indent}\t/// <summary>Key: {entry.Name} | Default: {valueStr}</summary>" );
sb.AppendLine( $"{indent}\tpublic const string {constName} = \"{Escape( entry.Name )}\";" );
if ( isInt )
sb.AppendLine( $"{indent}\tpublic const int {constName}_Value = {(int)dbl};" );
else
sb.AppendLine( $"{indent}\tpublic const double {constName}_Value = {dbl:G};" );
}
}
else if ( val.ValueKind == JsonValueKind.String )
{
var strVal = val.GetString() ?? "";
sb.AppendLine( $"{indent}\t/// <summary>Key: {entry.Name} | Default: \"{XmlEscape( strVal )}\"</summary>" );
sb.AppendLine( $"{indent}\tpublic const string {constName} = \"{Escape( entry.Name )}\";" );
sb.AppendLine( $"{indent}\tpublic const string {constName}_Value = \"{Escape( strVal )}\";" );
}
}
}
sb.AppendLine( $"{indent}}}" );
}
}
private static void EmitTables( StringBuilder sb, JsonElement tables, string indent )
{
bool first = true;
foreach ( var table in tables.EnumerateArray() )
{
var id = GetString( table, "id" ) ?? "unknown";
var name = GetString( table, "name" ) ?? id;
var desc = GetString( table, "description" );
if ( !first ) sb.AppendLine();
first = false;
var colCount = 0;
var rowCount = 0;
if ( table.TryGetProperty( "columns", out var cols ) )
colCount = cols.GetArrayLength();
if ( table.TryGetProperty( "rows", out var rows ) )
rowCount = rows.GetArrayLength();
var summary = $"{XmlEscape( name )} — {colCount} columns, {rowCount} rows";
if ( desc != null ) summary += $". {XmlEscape( desc )}";
sb.AppendLine( $"{indent}/// <summary>{summary}</summary>" );
sb.AppendLine( $"{indent}public static class {ToPascalCase( id )}" );
sb.AppendLine( $"{indent}{{" );
sb.AppendLine( $"{indent}\tpublic const string TableId = \"{Escape( id )}\";" );
sb.AppendLine( $"{indent}\tpublic const int RowCount = {rowCount};" );
if ( table.TryGetProperty( "columns", out var columns ) )
{
sb.AppendLine();
foreach ( var col in columns.EnumerateArray() )
{
var colKey = GetString( col, "key" ) ?? "unknown";
var colType = GetString( col, "type" ) ?? "unknown";
sb.AppendLine( $"{indent}\t/// <summary>Column type: {colType}</summary>" );
sb.AppendLine( $"{indent}\tpublic const string Col_{ToPascalCase( colKey )} = \"{Escape( colKey )}\";" );
}
}
sb.AppendLine( $"{indent}}}" );
}
}
// ──────────────────────────────────────────────────────
// Endpoints
// ──────────────────────────────────────────────────────
private static bool WriteEndpoints( string dir )
{
var sorted = LoadEndpointMetadata();
if ( sorted.Count == 0 ) return false;
var sb = new StringBuilder();
sb.Append( FileHeader );
sb.AppendLine( "namespace Sandbox;" );
sb.AppendLine();
sb.AppendLine( "/// <summary>" );
sb.AppendLine( "/// Network Storage endpoint slugs." );
sb.AppendLine( "/// Use these constants with NetworkStorage.CallEndpoint() instead of raw strings." );
sb.AppendLine( "/// </summary>" );
sb.AppendLine( "public static class NSEndpoints" );
sb.AppendLine( "{" );
// Slug constants
foreach ( var endpoint in sorted )
{
var docParts = new List<string>();
if ( !string.IsNullOrWhiteSpace( endpoint.Description ) )
docParts.Add( XmlEscape( endpoint.Description ) );
docParts.Add( $"Method: {endpoint.Method}" );
if ( !endpoint.Enabled ) docParts.Add( "DISABLED" );
sb.AppendLine( $"\t/// <summary>{string.Join( " | ", docParts )}</summary>" );
sb.AppendLine( $"\tpublic const string {ToPascalCase( endpoint.Slug )} = \"{Escape( endpoint.Slug )}\";" );
sb.AppendLine();
}
// Input schemas
var endpointsWithInput = sorted
.Where( x => x.Element.HasValue && HasInputProperties( x.Element.Value ) )
.ToList();
if ( endpointsWithInput.Count > 0 )
{
sb.AppendLine( "\t/// <summary>Input field keys for endpoints that accept POST data.</summary>" );
sb.AppendLine( "\tpublic static class Input" );
sb.AppendLine( "\t{" );
bool first = true;
foreach ( var endpoint in endpointsWithInput )
{
if ( !first ) sb.AppendLine();
first = false;
var ep = endpoint.Element.Value;
var inputProps = ep.GetProperty( "input" ).GetProperty( "properties" );
var required = new HashSet<string>();
if ( ep.GetProperty( "input" ).TryGetProperty( "required", out var req )
&& req.ValueKind == JsonValueKind.Array )
{
foreach ( var r in req.EnumerateArray() )
if ( r.GetString() is string rs ) required.Add( rs );
}
var name = endpoint.Name;
sb.AppendLine( $"\t\t/// <summary>{XmlEscape( name )} input fields.</summary>" );
sb.AppendLine( $"\t\tpublic static class {ToPascalCase( endpoint.Slug )}" );
sb.AppendLine( "\t\t{" );
foreach ( var field in inputProps.EnumerateObject() )
{
var doc = BuildTypeDoc( field.Value );
if ( required.Contains( field.Name ) ) doc += " (required)";
sb.AppendLine( $"\t\t\t/// <summary>{XmlEscape( doc )}</summary>" );
sb.AppendLine( $"\t\t\tpublic const string {ToPascalCase( field.Name )} = \"{Escape( field.Name )}\";" );
}
sb.AppendLine( "\t\t}" );
}
sb.AppendLine( "\t}" );
}
sb.AppendLine( "}" );
File.WriteAllText( Path.Combine( dir, "autoGenerated_Endpoints.cs" ), sb.ToString() );
return true;
}
// ──────────────────────────────────────────────────────
// Workflows
// ──────────────────────────────────────────────────────
private static List<EndpointMeta> LoadEndpointMetadata()
{
var items = SyncToolConfig.LoadEndpoints()
.Select( ep =>
{
var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() ?? "" : "";
if ( string.IsNullOrWhiteSpace( slug ) )
return null;
var enabled = true;
if ( ep.TryGetProperty( "enabled", out var en ) && en.ValueKind == JsonValueKind.False )
enabled = false;
return new EndpointMeta
{
Slug = slug,
Name = GetString( ep, "name" ) ?? slug,
Description = GetString( ep, "description" ),
Method = GetString( ep, "method" ) ?? "POST",
Enabled = enabled,
Element = ep
};
} )
.Where( item => item != null )
.ToDictionary( item => item.Slug, item => item, StringComparer.OrdinalIgnoreCase );
foreach ( var source in SyncToolConfig.LoadSourceSummaries( "endpoint" ) )
{
if ( string.IsNullOrWhiteSpace( source.Id ) || items.ContainsKey( source.Id ) )
continue;
items[source.Id] = new EndpointMeta
{
Slug = source.Id,
Name = string.IsNullOrWhiteSpace( source.Name ) ? source.Id : source.Name,
Description = source.Description,
Method = "POST",
Enabled = true
};
}
return items.Values
.OrderBy( item => item.Slug, StringComparer.OrdinalIgnoreCase )
.ToList();
}
private static bool WriteWorkflows( string dir )
{
var sorted = LoadWorkflowMetadata();
if ( sorted.Count == 0 ) return false;
var sb = new StringBuilder();
sb.Append( FileHeader );
sb.AppendLine( "namespace Sandbox;" );
sb.AppendLine();
sb.AppendLine( "/// <summary>" );
sb.AppendLine( "/// Reusable Network Storage workflow IDs." );
sb.AppendLine( "/// Referenced in endpoint steps for shared validation and computation logic." );
sb.AppendLine( "/// </summary>" );
sb.AppendLine( "public static class NSWorkflows" );
sb.AppendLine( "{" );
foreach ( var workflow in sorted )
{
if ( !string.IsNullOrWhiteSpace( workflow.Description ) )
sb.AppendLine( $"\t/// <summary>{XmlEscape( workflow.Description )}</summary>" );
sb.AppendLine( $"\tpublic const string {ToPascalCase( workflow.Id )} = \"{Escape( workflow.Id )}\";" );
sb.AppendLine();
}
sb.AppendLine( "}" );
File.WriteAllText( Path.Combine( dir, "autoGenerated_Workflows.cs" ), sb.ToString() );
return true;
}
// ──────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────
private static List<WorkflowMeta> LoadWorkflowMetadata()
{
var items = SyncToolConfig.LoadWorkflows()
.Select( wf =>
{
var id = wf.TryGetProperty( "id", out var i ) ? i.GetString() ?? "" : "";
if ( string.IsNullOrWhiteSpace( id ) )
return null;
return new WorkflowMeta
{
Id = id,
Description = GetString( wf, "description" ),
Element = wf
};
} )
.Where( item => item != null )
.GroupBy( item => item.Id, StringComparer.OrdinalIgnoreCase )
.ToDictionary( group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase );
foreach ( var source in SyncToolConfig.LoadSourceSummaries( "workflow" ) )
{
if ( string.IsNullOrWhiteSpace( source.Id ) || items.ContainsKey( source.Id ) )
continue;
items[source.Id] = new WorkflowMeta
{
Id = source.Id,
Description = source.Description
};
}
return items.Values
.OrderBy( item => item.Id, StringComparer.OrdinalIgnoreCase )
.ToList();
}
/// <summary>Convert snake_case or kebab-case to PascalCase.</summary>
private static string ToPascalCase( string input )
{
if ( string.IsNullOrEmpty( input ) ) return "Unknown";
var sb = new StringBuilder();
bool capitalizeNext = true;
foreach ( var ch in input )
{
if ( ch == '_' || ch == '-' || ch == '.' )
{
capitalizeNext = true;
continue;
}
sb.Append( capitalizeNext ? char.ToUpperInvariant( ch ) : ch );
capitalizeNext = false;
}
var result = sb.ToString();
// Ensure it starts with a letter (C# identifier requirement)
if ( result.Length > 0 && char.IsDigit( result[0] ) )
result = "_" + result;
return result;
}
/// <summary>Build a type description string from a JSON schema property.</summary>
private static string BuildTypeDoc( JsonElement prop )
{
var parts = new List<string>();
var type = GetString( prop, "type" ) ?? "unknown";
if ( type == "array" )
{
var itemType = "unknown";
if ( prop.TryGetProperty( "items", out var items ) )
itemType = GetString( items, "type" ) ?? "unknown";
parts.Add( $"Type: array ({itemType})" );
}
else
{
parts.Add( $"Type: {type}" );
}
if ( prop.TryGetProperty( "min", out var min ) ) parts.Add( $"Min: {min}" );
if ( prop.TryGetProperty( "max", out var max ) ) parts.Add( $"Max: {max}" );
if ( prop.TryGetProperty( "default", out var def ) ) parts.Add( $"Default: {def}" );
if ( prop.TryGetProperty( "_ledger", out var ledger ) && ledger.GetBoolean() ) parts.Add( "Ledger-tracked" );
if ( prop.TryGetProperty( "_unique", out var unique ) && unique.GetBoolean() ) parts.Add( "Unique" );
return string.Join( " | ", parts );
}
private static bool HasInputProperties( JsonElement ep )
{
return ep.TryGetProperty( "input", out var input )
&& input.TryGetProperty( "properties", out var props )
&& props.EnumerateObject().Any();
}
private static string GetString( JsonElement el, string key )
{
if ( el.TryGetProperty( key, out var val ) && val.ValueKind == JsonValueKind.String )
return val.GetString();
return null;
}
private static string Escape( string s ) => s?.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" ) ?? "";
private static string XmlEscape( string s )
{
if ( string.IsNullOrEmpty( s ) ) return "";
// Collapse newlines to spaces so multi-line YAML descriptions don't
// spill out of single-line /// <summary>…</summary> doc comments and
// leak into the generated C# as free-floating tokens.
return s.Replace( "\r\n", " " )
.Replace( "\n", " " )
.Replace( "\r", " " )
.Replace( "&", "&" )
.Replace( "<", "<" )
.Replace( ">", ">" )
.Replace( "\"", """ );
}
}