public record BicepDecompileSaveCommandParams()

in src/Bicep.LangServer/Handlers/BicepDecompileSaveCommandHandler.cs [17:251]


    public record BicepDecompileSaveCommandParams(
        string decompileId,
        DecompiledFile[] outputFiles, // first is assumed to be main output file
        bool overwrite
    );
    public record BicepDecompileSaveCommandResult(
        string output,
        string? errorMessage,
        string? mainSavedBicepPath,
        string[] savedPaths);

    /// <summary>
    /// Handles saving the decompiled files from a BicepDecompileCommandHandler result (after the client asked the user whether to overwrite or create copies)
    /// </summary>
    public class BicepDecompileSaveCommandHandler : ExecuteTypedResponseCommandHandlerBase<BicepDecompileSaveCommandParams, BicepDecompileSaveCommandResult>
    {
        private readonly BicepDecompiler bicepDecompiler;
        private readonly ILanguageServerFacade languageServerFacade;
        private readonly IClientCapabilitiesProvider clientCapabilitiesProvider;
        private readonly TelemetryAndErrorHandlingHelper<BicepDecompileSaveCommandResult> telemetryHelper;

        public BicepDecompileSaveCommandHandler(
            ISerializer serializer,
            ILanguageServerFacade server,
            ITelemetryProvider telemetryProvider,
            IClientCapabilitiesProvider clientCapabilitiesProvider,
            BicepDecompiler bicepDecompiler)
            : base(LangServerConstants.DecompileSaveCommand, serializer)
        {
            this.telemetryHelper = new TelemetryAndErrorHandlingHelper<BicepDecompileSaveCommandResult>(server.Window, telemetryProvider);
            this.bicepDecompiler = bicepDecompiler;
            this.clientCapabilitiesProvider = clientCapabilitiesProvider;
            this.languageServerFacade = server;
        }

        public override Task<BicepDecompileSaveCommandResult> Handle(BicepDecompileSaveCommandParams parameters, CancellationToken cancellationToken)
        {
            return telemetryHelper.ExecuteWithTelemetryAndErrorHandling(async () =>
            {
                return await SaveDecompileResults(parameters.decompileId, parameters.outputFiles, parameters.overwrite);
            });
        }

        private async Task<(BicepDecompileSaveCommandResult result, BicepTelemetryEvent? successTelemetry)> SaveDecompileResults(
            string decompileId,
            DecompiledFile[] outputFiles,
            bool overwrite // If false, will create copy(ies) of the output file(s)
        )
        {
            StringBuilder output = new();

            try
            {
                if (outputFiles.Length == 0)
                {
                    throw new ArgumentException($"{nameof(outputFiles)} should not be empty");
                }

                string proposedMainBicepPath = outputFiles[0].absolutePath;
                string? outputFolder = Path.GetDirectoryName(proposedMainBicepPath);
                if (outputFolder is null)
                {
                    throw new ArgumentException($"Invalid input path {proposedMainBicepPath}");
                }

                // Figure out proper place to save files
                (string path, string contents)[] filesToSave = DeterminePathsToSave(output, ref outputFolder, proposedMainBicepPath, outputFiles, overwrite);
                if (!overwrite)
                {
                    Debug.Assert(filesToSave.All(f => IsPathRelativeToBasePath(f.path, outputFolder)), $"Expected all output files to be relative to {outputFolder}");
                }
                var actualMainBicepPath = filesToSave[0].path;

                // Save files
                SaveFiles(output, filesToSave);

                // Completion message
                Log(output, $"Decompilation complete.");

                // Show main output file
                if (this.clientCapabilitiesProvider.DoesClientSupportShowDocumentRequest())
                {
                    await this.languageServerFacade.Window.ShowDocument(
                        new() { TakeFocus = true, Uri = actualMainBicepPath },
                        CancellationToken.None);
                }

                // Return result
                return (
                    result: new BicepDecompileSaveCommandResult(
                        output.ToString(),
                        null,
                        actualMainBicepPath,
                        filesToSave.Select(f => f.path).ToArray()),
                    successTelemetry: BicepTelemetryEvent.DecompileSaveSuccess(decompileId)
                    );
            }
            catch (Exception ex)
            {
                Log(output, ex.Message);
                throw telemetryHelper.CreateException(
                    ex.Message,
                    BicepTelemetryEvent.DecompileSaveFailure(decompileId, ex.GetType().Name),
                    new BicepDecompileSaveCommandResult(
                        output.ToString(),
                        ex.Message,
                        null,
                        [])
                );
            }
        }

        private (string path, string contents)[] DeterminePathsToSave(StringBuilder output, ref string outputFolder, string mainBicepPath, DecompiledFile[] outputFiles, bool overwrite)
        {
            if (overwrite)
            {
                // Save to original paths
                return outputFiles.Select(of => (of.absolutePath, of.bicepContents)).ToArray();
            }

            var singleFileDecompilation = outputFiles.Length == 1;
            if (singleFileDecompilation)
            {
                // Create a bicep file with unique name alongside the existing bicep file
                string newBicepPath = FindUniqueFileOrFolderName(outputFolder, mainBicepPath);
                return [(newBicepPath, outputFiles[0].bicepContents)];
            }
            else
            {
                // Output every to a new subfolder (using the relative clonable paths)
                outputFolder = CreateUniqueSubfolder(outputFolder, $"{Path.GetFileNameWithoutExtension(mainBicepPath)}_decompiled");
                Log(output, String.Format(LangServerResources.Decompile_CreatedNewSubfolder, outputFolder));

                var newOutputFiles = new List<(string path, string contents)>();
                foreach (var outputFile in outputFiles)
                {
                    var newPath = MakePathRelativeToFolder(outputFolder, Path.Combine(outputFolder, outputFile.clonableRelativePath), newOutputFiles.Select(f => f.path).ToArray());
                    newOutputFiles.Add((newPath, outputFile.bicepContents));
                }

                return [.. newOutputFiles];
            }
        }

        // If we place copies of decompilation results into a new subfolder, we need to deal with files outside of the
        // base folder (e.g. "../child.json"). For these files, we munge the filename to indicate whether they came from,
        // then throw them into the folder. Not perfect but should be relatively rare.
        private string MakePathRelativeToFolder(string baseFolder, string path, string[] existingPaths)
        {
            if (IsPathRelativeToBasePath(path, baseFolder))
            {
                return path;
            }
            else
            {
                // Munge the name
                string folderName = Path.GetDirectoryName(path) ?? "invalidpath";
                folderName = Path.GetFileName(folderName); // Get last folder name

                foreach (char c in Path.GetInvalidFileNameChars())
                {
                    folderName = folderName.Replace(c, '_');
                }
                folderName = folderName.Replace("/", "_")
                    .Replace("\\", "_")
                    .Replace("..", "parent")
                    .Replace(".", "_");

                string mungedFilename = folderName + "_" + Path.GetFileName(path);

                // Munging could result in identical filenames
                var uniquePath = FindUniqueFileOrFolderName(
                    baseFolder,
                    mungedFilename,
                    path => existingPaths.Contains(path));

                Debug.Assert(IsPathRelativeToBasePath(uniquePath, baseFolder), $"Expected {uniquePath} to be relative to {baseFolder}");
                return uniquePath;
            }
        }

        private bool IsPathRelativeToBasePath(string path, string baseFolder)
        {
            baseFolder = Path.EndsInDirectorySeparator(baseFolder) ? baseFolder : baseFolder + Path.DirectorySeparatorChar;
            string relativePath = Path.GetRelativePath(baseFolder, path);
            return !Path.IsPathFullyQualified(relativePath) && !relativePath.StartsWith("..");
        }

        private static void Log(StringBuilder output, string message)
        {
            output.AppendLine(message);
            Trace.TraceInformation(message);
        }

        private static void SaveFiles(StringBuilder output, (string path, string contents)[] pathsToSave)
        {
            foreach (var (path, content) in pathsToSave)
            {
                Log(output, File.Exists(path) ? $"Overwriting {path}" : $"Writing {path}");
                File.WriteAllText(path, content);
            }
        }

        private string CreateUniqueSubfolder(string root, string desiredName)
        {
            string folder = FindUniqueFileOrFolderName(root, desiredName);
            Directory.CreateDirectory(folder);
            return folder;
        }

        delegate bool DoesPathExist(string path);
        private string FindUniqueFileOrFolderName(string root, string desiredFilename, DoesPathExist doesPathExist)
        {
            string desiredName = Path.GetFileNameWithoutExtension(desiredFilename);
            string extension = Path.GetExtension(desiredFilename);

            int nextAppend = 2;
            string path = Path.Join(root, $"{desiredName}{extension}");
            do
            {
                if (!doesPathExist(path))
                {
                    return path;
                }

                path = Path.Join(root, $"{desiredName}{nextAppend}{extension}");
                ++nextAppend;
            } while (true);
        }

        private string FindUniqueFileOrFolderName(string root, string desiredFilename)
        {
            return FindUniqueFileOrFolderName(root, desiredFilename, path => File.Exists(path) || Directory.Exists(path));
        }
    }