Editor/SyncToolPullWriter.cs

This static class manages the serialization and writing of configuration resources retrieved by a synchronization tool into local files. It converts remote JSON data into YAML formats for endpoints, collections, workflows, and tests.

File Access
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;

public static class SyncToolPullWriter
{
	private static readonly JsonSerializerOptions YamlJsonOptions = new()
	{
		Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
	};

	public static bool SaveEndpoint( string slug, JsonElement remoteEndpoint )
	{
		var local = SyncToolTransforms.ServerEndpointToLocal( remoteEndpoint );
		return SaveResource(
			"endpoint",
			slug,
			remoteEndpoint,
			local );
	}

	/// <summary>
	/// Display-only text of an endpoint in the layout a pull writes: the envelope, then its script file below a
	/// divider. The pull preview shows both panes this way, so a script change reads line by line.
	/// </summary>
	public static string EndpointPreviewText( string slug, string json )
	{
		if ( string.IsNullOrWhiteSpace( json ) )
			return "";

		Dictionary<string, object> data;
		try
		{
			data = JsonSerializer.Deserialize<Dictionary<string, object>>( json ) ?? new Dictionary<string, object>();
		}
		catch ( JsonException )
		{
			return SyncToolYamlRenderer.RenderFromJson( json );
		}

		// Same split the writer makes, with the same kept script name, so the preview matches what a pull writes.
		var envelope = SyncToolConfig.SplitEndpointScript( slug, data, null, out var scriptFileName, out var scriptText );
		var text = BuildSourceText( "endpoint", slug, envelope );
		return scriptFileName == null ? text : $"{text}\n--- {scriptFileName} ---\n{scriptText}";
	}

	public static bool SaveCollection( string name, JsonElement remoteCollection )
	{
		var local = SyncToolTransforms.ServerCollectionToLocal( remoteCollection );
		return SaveResource(
			"collection",
			name,
			remoteCollection,
			local );
	}

	public static bool SaveWorkflow( string id, JsonElement remoteWorkflow )
	{
		var local = SyncToolTransforms.ServerWorkflowToLocal( remoteWorkflow );
		return SaveResource(
			"workflow",
			id,
			remoteWorkflow,
			local );
	}

	public static int SaveCollections( JsonElement serverResponse )
	{
		var data = serverResponse;
		if ( serverResponse.TryGetProperty( "data", out var d ) )
			data = d;
		if ( data.ValueKind != JsonValueKind.Array )
			return 0;

		var count = 0;
		foreach ( var collection in data.EnumerateArray() )
		{
			var local = SyncToolTransforms.ServerCollectionToLocal( collection );
			var name = local.TryGetValue( "name", out var value ) ? value?.ToString() : null;
			if ( string.IsNullOrWhiteSpace( name ) || name == "unknown" )
				continue;

			if ( SaveCollection( name, collection ) )
				count++;
		}

		return count;
	}

	private static bool SaveResource( string kind, string id, JsonElement remote, Dictionary<string, object> local )
	{
		var hasSource = SyncToolTransforms.TryGetSourceText( remote, out var sourceText );
		var sourcePath = SyncToolTransforms.GetSourcePath( remote );
		var hasCompiledRemoteView = SyncToolTransforms.TryGetCanonicalDefinition( remote, out _ )
			|| (remote.TryGetProperty( "definition", out var definition ) && definition.ValueKind == JsonValueKind.Object);

		// The pull preview is built from the compiled/canonical remote view. When the
		// API returns both sourceText and canonicalDefinition, sourceText can be stale
		// or semantically different from what the preview shows, so write the same
		// canonical data the user approved. Only preserve raw sourceText for older
		// responses that do not expose a compiled view.
		if ( hasSource && !hasCompiledRemoteView )
		{
			// A script endpoint's raw text still has its script to move into a file, so it goes through WriteSource.
			var rawEnvelope = kind == "endpoint" ? SyncToolConfig.RawEndpointToSplit( sourceText, sourcePath ) : null;
			if ( rawEnvelope != null )
				WriteSource( kind, id, rawEnvelope, sourcePath );
			else
				SyncToolConfig.SaveSourceResource( kind, id, sourceText, sourcePath );
		}
		else
			WriteSource( kind, id, local, sourcePath );

		return true;
	}

	public static void WriteSource( string kind, string id, Dictionary<string, object> data, string sourcePath = null )
	{
		// Endpoint code lives in its own script file; the envelope only references it.
		string scriptFileName = null;
		string scriptText = null;
		var envelope = data;
		if ( kind == "endpoint" )
			envelope = SyncToolConfig.SplitEndpointScript( id, data, sourcePath, out scriptFileName, out scriptText );

		if ( !string.IsNullOrWhiteSpace( sourcePath ) )
		{
			var savedPath = SyncToolConfig.SaveSourceResource( kind, id, BuildSourceText( kind, id, envelope ), sourcePath );
			if ( scriptFileName != null && savedPath != null )
				SyncToolConfig.WriteEndpointScriptFile( Path.GetDirectoryName( SyncToolConfig.Abs( savedPath ) ), scriptFileName, scriptText );
			return;
		}

		var folder = kind switch
		{
			"collection" => SyncToolConfig.CollectionsPath,
			"endpoint" => SyncToolConfig.EndpointsPath,
			"workflow" => SyncToolConfig.WorkflowsPath,
			"test" => SyncToolConfig.TestsPath,
			_ => SyncToolConfig.SyncToolsPath
		};
		var absoluteFolder = SyncToolConfig.Abs( folder );
		var path = FindExistingSourcePath( absoluteFolder, kind, id )
			?? Path.Combine( absoluteFolder, $"{id}.{kind}.yml" );
		var dir = Path.GetDirectoryName( path );
		if ( !Directory.Exists( dir ) )
			Directory.CreateDirectory( dir );

		File.WriteAllText( path, BuildSourceText( kind, id, envelope ) );
		if ( scriptFileName != null )
			SyncToolConfig.WriteEndpointScriptFile( dir, scriptFileName, scriptText );
	}

	// The script file name the envelope already uses. A pull keeps it, so a custom scriptFile is not orphaned.
	// The server reports sourcePath as a bare file name: the envelope is then that file in the endpoints folder,
	// where SaveSourceResource writes it. Without a path (the preview), the endpoints folder is searched by id.
	internal static string ExistingEndpointScriptFileName( string slug, string sourcePath )
	{
		var endpointsFolder = SyncToolConfig.Abs( SyncToolConfig.EndpointsPath );
		string envelope = null;
		if ( !string.IsNullOrWhiteSpace( sourcePath ) )
		{
			envelope = new[] { SyncToolConfig.Abs( sourcePath ), Path.Combine( endpointsFolder, Path.GetFileName( sourcePath.Replace( '\\', '/' ) ) ) }
				.FirstOrDefault( File.Exists );
		}
		envelope ??= FindExistingSourcePath( endpointsFolder, "endpoint", slug );
		var scriptPath = envelope == null ? null : SyncToolConfig.EndpointScriptFileFor( envelope );
		return scriptPath == null ? null : Path.GetFileName( scriptPath );
	}

	private static string FindExistingSourcePath( string absoluteFolder, string kind, string id )
	{
		if ( !Directory.Exists( absoluteFolder ) )
			return null;

		return Directory.GetFiles( absoluteFolder, $"*.{kind}.yml" )
			.Concat( Directory.GetFiles( absoluteFolder, $"*.{kind}.yaml" ) )
			.FirstOrDefault( path => string.Equals( SyncToolConfig.ResourceIdFromFilePath( path, kind ), id, StringComparison.OrdinalIgnoreCase ) );
	}

	private static string BuildSourceText( string kind, string id, Dictionary<string, object> data )
	{
		var topLevelKeys = kind switch
		{
			"collection" => new HashSet<string>( StringComparer.OrdinalIgnoreCase ) { "id", "name", "description", "notes" },
			"endpoint" => new HashSet<string>( StringComparer.OrdinalIgnoreCase ) { "id", "slug", "name", "description", "notes" },
			"workflow" => new HashSet<string>( StringComparer.OrdinalIgnoreCase ) { "id", "name", "description", "notes" },
			_ => new HashSet<string>( StringComparer.OrdinalIgnoreCase ) { "id", "name", "description", "notes" }
		};
		var body = new Dictionary<string, object>( StringComparer.Ordinal );
		foreach ( var pair in data )
		{
			if ( !topLevelKeys.Contains( pair.Key ) && !pair.Key.StartsWith( "_", StringComparison.Ordinal )
				&& !SyncToolTransforms.IsAuthoringOnlyField( pair.Key ) )
				body[pair.Key] = pair.Value;
		}

		var header = new List<string>
		{
			"sourceVersion: 1",
			$"kind: {kind}",
			$"id: {id}"
		};
		foreach ( var key in new[] { "name", "description", "notes" } )
		{
			if ( data.TryGetValue( key, out var value ) && value != null )
				header.Add( $"{key}: {JsonSerializer.Serialize( value.ToString() )}" );
		}

		var lines = new List<string>( header );
		if ( body.Count > 0 )
			AppendYamlMapping( lines, body, 0 );
		lines.Add( "" );
		return string.Join( "\n", lines );
	}

	private static void AppendYamlMapping( List<string> lines, IDictionary<string, object> values, int indentLevel )
	{
		if ( values.Count == 0 )
		{
			lines.Add( $"{Indent( indentLevel )}{{}}" );
			return;
		}

		foreach ( var pair in values )
		{
			AppendYamlProperty( lines, pair.Key, pair.Value, indentLevel );
		}
	}

	private static void AppendYamlProperty( List<string> lines, string key, object value, int indentLevel )
	{
		var indent = Indent( indentLevel );
		var yamlKey = FormatYamlKey( key );
		var normalized = NormalizeYamlValue( value );
		switch ( normalized )
		{
			case Dictionary<string, object> dict when dict.Count == 0:
				lines.Add( $"{indent}{yamlKey}: {{}}" );
				return;
			case Dictionary<string, object> dict:
				lines.Add( $"{indent}{yamlKey}:" );
				AppendYamlMapping( lines, dict, indentLevel + 1 );
				return;
			case List<object> list when list.Count == 0:
				lines.Add( $"{indent}{yamlKey}: []" );
				return;
			case List<object> list:
				lines.Add( $"{indent}{yamlKey}:" );
				AppendYamlList( lines, list, indentLevel + 1 );
				return;
			default:
				lines.Add( $"{indent}{yamlKey}: {FormatYamlScalar( normalized )}" );
				return;
		}
	}

	private static void AppendYamlList( List<string> lines, List<object> items, int indentLevel )
	{
		foreach ( var item in items )
		{
			AppendYamlListItem( lines, item, indentLevel );
		}
	}

	private static void AppendYamlListItem( List<string> lines, object item, int indentLevel )
	{
		var indent = Indent( indentLevel );
		var normalized = NormalizeYamlValue( item );
		switch ( normalized )
		{
			case Dictionary<string, object> dict when dict.Count == 0:
				lines.Add( $"{indent}- {{}}" );
				return;
			case Dictionary<string, object> dict:
				lines.Add( $"{indent}-" );
				AppendYamlMapping( lines, dict, indentLevel + 1 );
				return;
			case List<object> list when list.Count == 0:
				lines.Add( $"{indent}- []" );
				return;
			case List<object> list:
				lines.Add( $"{indent}-" );
				AppendYamlList( lines, list, indentLevel + 1 );
				return;
			default:
				lines.Add( $"{indent}- {FormatYamlScalar( normalized )}" );
				return;
		}
	}

	private static object NormalizeYamlValue( object value )
	{
		if ( value is null )
			return null;

		if ( value is JsonElement element )
		{
			return element.ValueKind switch
			{
				JsonValueKind.Object => element.EnumerateObject()
					.ToDictionary( prop => prop.Name, prop => NormalizeYamlValue( prop.Value ), StringComparer.Ordinal ),
				JsonValueKind.Array => element.EnumerateArray().Select( item => NormalizeYamlValue( item ) ).ToList(),
				JsonValueKind.String => element.GetString(),
				JsonValueKind.Number when element.TryGetInt64( out var l ) => l,
				JsonValueKind.Number when element.TryGetDouble( out var d ) => d,
				JsonValueKind.True => true,
				JsonValueKind.False => false,
				_ => null
			};
		}

		if ( value is Dictionary<string, object> dict )
			return dict.ToDictionary( pair => pair.Key, pair => NormalizeYamlValue( pair.Value ), StringComparer.Ordinal );

		if ( value is IDictionary<string, object> otherDict )
			return otherDict.ToDictionary( pair => pair.Key, pair => NormalizeYamlValue( pair.Value ), StringComparer.Ordinal );

		if ( value is IEnumerable<object> sequence && value is not string )
			return sequence.Select( item => NormalizeYamlValue( item ) ).ToList();

		return value;
	}

	private static string FormatYamlKey( string key )
	{
		return key.All( ch => char.IsLetterOrDigit( ch ) || ch == '_' || ch == '-' || ch == '.' )
			? key
			: JsonSerializer.Serialize( key, YamlJsonOptions );
	}

	private static string FormatYamlScalar( object value )
	{
		return value switch
		{
			null => "null",
			bool b => b ? "true" : "false",
			sbyte n => n.ToString( CultureInfo.InvariantCulture ),
			byte n => n.ToString( CultureInfo.InvariantCulture ),
			short n => n.ToString( CultureInfo.InvariantCulture ),
			ushort n => n.ToString( CultureInfo.InvariantCulture ),
			int n => n.ToString( CultureInfo.InvariantCulture ),
			uint n => n.ToString( CultureInfo.InvariantCulture ),
			long n => n.ToString( CultureInfo.InvariantCulture ),
			ulong n => n.ToString( CultureInfo.InvariantCulture ),
			float n => n.ToString( "R", CultureInfo.InvariantCulture ),
			double n => n.ToString( "R", CultureInfo.InvariantCulture ),
			decimal n => n.ToString( CultureInfo.InvariantCulture ),
			_ => JsonSerializer.Serialize( value.ToString(), YamlJsonOptions )
		};
	}

	private static string Indent( int indentLevel ) => new( ' ', indentLevel * 2 );
}