File: TestIdReportWriter.cs
Web Access
Project: Microsoft.TestPlatform.Extensions.TestIdsLogger.csproj (Microsoft.VisualStudio.TestPlatform.Extensions.TestIds.TestLogger)
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;

using Microsoft.VisualStudio.TestPlatform.ObjectModel;

namespace Microsoft.VisualStudio.TestPlatform.Extensions.TestIdsLogger;

/// <summary>
/// Writes the test id report as CSV.
/// </summary>
/// <remarks>
/// <para>
/// CSV because the report exists to be joined against whatever the consumer already stored their
/// test ids in. A flat, fixed set of scalar columns with no nesting is exactly the shape a
/// relational load or a spreadsheet wants, and every database, BI tool and scripting language reads
/// it without a parser being written for the occasion.
/// </para>
/// <para>
/// The quoting follows RFC 4180: a field is quoted when it contains a comma, a quote, a carriage
/// return or a line feed, and an embedded quote is doubled. Test names routinely contain commas
/// - a data driven row is usually rendered as <c>Test (1,2)</c> - so unquoted output would be
/// silently wrong rather than obviously broken, which is the worst kind of wrong for a file whose
/// entire job is to be machine read.
/// </para>
/// </remarks>
internal static class TestIdReportWriter
{
    /// <summary>
    /// The header row, and by extension the column order.
    /// </summary>
    public const string Header = "Source,ExecutorUri,FullyQualifiedName,DisplayName,Id,Sha1Id,XxHash128Id,IdSource";

    /// <summary>
    /// Writes the header and one row per record, in the order given.
    /// </summary>
    public static void Write(TextWriter writer, IEnumerable<TestIdRecord> records)
    {
        ValidateArg.NotNull(writer, nameof(writer));
        ValidateArg.NotNull(records, nameof(records));

        // A fixed newline rather than Environment.NewLine: a report produced on one operating
        // system is routinely consumed on another, and RFC 4180 says CRLF.
        writer.Write(Header);
        writer.Write("\r\n");

        foreach (TestIdRecord record in records)
        {
            var row = new StringBuilder();
            AppendField(row, record.Source);
            row.Append(',');
            AppendField(row, record.ExecutorUri);
            row.Append(',');
            AppendField(row, record.FullyQualifiedName);
            row.Append(',');
            AppendField(row, record.DisplayName);
            row.Append(',');
            AppendField(row, Format(record.Id));
            row.Append(',');
            AppendField(row, Format(record.Sha1Id));
            row.Append(',');
            AppendField(row, Format(record.XxHash128Id));
            row.Append(',');
            AppendField(row, record.IdSource.ToString());

            writer.Write(row.ToString());
            writer.Write("\r\n");
        }
    }

    /// <summary>
    /// Formats a test id the way every other vstest surface prints one, so a value in this report
    /// compares byte for byte against a value taken from a TRX file or from the object model.
    /// </summary>
    private static string Format(Guid id) => id.ToString("d", CultureInfo.InvariantCulture);

    private static void AppendField(StringBuilder row, string value)
    {
        if (value.IndexOfAny(CharactersRequiringQuoting) < 0)
        {
            row.Append(value);
            return;
        }

        row.Append('"');
        foreach (char character in value)
        {
            if (character == '"')
            {
                row.Append('"');
            }

            row.Append(character);
        }

        row.Append('"');
    }

    private static readonly char[] CharactersRequiringQuoting = new[] { ',', '"', '\r', '\n' };
}