Editor/SyncToolPullWriter.cs

Editor utility that converts remote API JSON resources (endpoints, collections, workflows) into local YAML source files and saves them to disk. It transforms server responses via SyncToolTransforms, builds YAML text, finds existing files, and writes or updates source files under configured paths.

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 );
	}

	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 )
			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 )
	{
		if ( !string.IsNullOrWhiteSpace( sourcePath ) )
		{
			SyncToolConfig.SaveSourceResource( kind, id, BuildSourceText( kind, id, data ), sourcePath );
			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, data ) );
	}

	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.OrdinalIgnoreCase );
		foreach ( var pair in data )
		{
			if ( !topLevelKeys.Contains( pair.Key ) && !pair.Key.StartsWith( "_", StringComparison.Ordinal ) )
				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.OrdinalIgnoreCase ),
				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.OrdinalIgnoreCase );

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

		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 );
}