File: Model\Document.cs
Web Access
Project: Microsoft.DotNet.XliffTasks.csproj (Microsoft.DotNet.XliffTasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.IO;
using System.Text;

namespace XliffTasks.Model;

internal abstract class Document
{
    /// <summary>
    /// Indicates if content has been loaded in to the document.
    /// </summary>
    public abstract bool HasContent { get; }

    private static readonly Encoding s_utf8WithBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
    private static readonly Encoding s_utf8WithoutBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);

    /// <summary>
    /// The encoding to use when saving the document to a stream.
    /// This is the encoding that was detected on Load, or default value of UTF8-with-BOM for new documents.
    /// </summary>
    public Encoding Encoding { get; set; } = s_utf8WithBom;

    /// <summary>
    /// Loads (or reloads) the document content from the given file path.
    /// </summary>
    public void Load(FileInfo path)
    {
        using FileStream stream = File.Open(path.FullName, FileMode.Open, FileAccess.Read, FileShare.Read);
        Load(stream);
    }

    /// <summary>
    /// Loads (or reloads) the document content from the given stream.
    /// </summary>
    public void Load(Stream stream)
    {
        // NOTE: It is important to pass UTF8-without-BOM here and not UTF8-with-BOM (aka Encoding.UTF8 and also same
        // as default when not passing encoding). The reason is that CurrentEncoding is only different from the encoding 
        // provided when the StreamReader encounters a BOM. As such, if we start off with UTF8-with-BOM, we'll end up with 
        // UTF8-with-BOM even if the document has no BOM, which would defeat our purpose of preserving the encoding and BOM
        // of the original document in a Load/Modify/Save cycle.

        using StreamReader reader = new(stream, s_utf8WithoutBom, detectEncodingFromByteOrderMarks: true);
        Load(reader);
        Encoding = reader.CurrentEncoding;
    }

    /// <summary>
    /// Loads (or reloads) the document content from the given reader.
    /// </summary>
    public abstract void Load(TextReader reader);

    /// <summary>
    /// Saves the document's content to the given file path.
    /// </summary>
    public void Save(FileInfo path)
    {
        //On Windows:
        // Readers will prevent the file from being overwritten due to FileShare.Read.
        // Readers can read in parallel, but when there's contention with a writer, the retrying will kick in to resolve it.
        //On Unix:
        // FileShare.Read does nothing, but...
        // File.Replace is implemented with rename system call that will mean that even though writers can overwrite while 
        // reading is happening, each reader will see file before or after overwrite, not in between

        EnsureContent();
        string tempPath = Path.Combine(path.Directory.FullName, Path.GetRandomFileName());

        using (FileStream stream = File.Open(tempPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None))
        {
            Save(stream);
        }

        ExponentialRetry.ExecuteWithRetryOnIOException(() =>
        {
            if (File.Exists(path.FullName))
            {
                File.Replace(
                    sourceFileName: tempPath,
                    destinationFileName: path.FullName,
                    destinationBackupFileName: null,
                    ignoreMetadataErrors: true);
            }
            else
            {
                File.Move(sourceFileName: tempPath, destFileName: path.FullName);
            }
        }, maxRetryCount: 3);
    }

    /// <summary>
    /// Saves the document's content to the given stream.
    /// </summary>
    public void Save(Stream stream)
    {
        EnsureContent();

        using StreamWriter writer = new(stream, Encoding);
        Save(writer);
    }

    /// <summary>
    /// Saves the document's content to the given writer.
    /// </summary>
    public abstract void Save(TextWriter writer);

    /// <summary>
    /// Throws if this document has no content.
    /// </summary>
    /// <exception cref="InvalidOperationException"><see cref="HasContent"/> is false.</exception>
    protected void EnsureContent()
    {
        if (!HasContent)
        {
            throw new InvalidOperationException("Document has no content loaded.");
        }
    }
}