using System.Runtime.CompilerServices; using System.Text; namespace MS.Microservice.Infrastructure.Utils; /// A logical CSV record; row numbering starts at one and includes blank records. public sealed record CsvRow(long RowNumber, IReadOnlyList Fields); /// A CSV syntax or size error with one-based logical record and field positions. public sealed class CsvFormatException(string message, long rowNumber, int columnNumber) : FormatException($"CSV row {rowNumber}, column {columnNumber}: {message}") { public long RowNumber { get; } = rowNumber; public int ColumnNumber { get; } = columnNumber; } /// Bounded, asynchronous CSV record reading and writing on caller-owned streams. public static class CsvHelper { /// /// Reads from the current position using the specified encoding, preserving field text and embedded newlines. /// Accepts CRLF/LF/CR and one initial encoding marker, keeping the caller stream open. /// public static async IAsyncEnumerable ReadAsync( Stream source, CsvOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(source); if (!source.CanRead) throw new ArgumentException("The stream must be readable.", nameof(source)); options ??= new CsvOptions(); options.Validate(); cancellationToken.ThrowIfCancellationRequested(); using var reader = new StreamReader(source, options.Encoding, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true); var buffer = new char[4096]; var field = new StringBuilder(); var fields = new List(); var state = FieldState.Start; long rowNumber = 1; long recordChars = 0; var hasRecord = false; // StreamReader consumes the configured preamble even with encoding detection disabled. var firstCharacter = options.Encoding.GetPreamble().Length == 0; var skipLineFeed = false; int read; while ((read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false)) != 0) { for (var index = 0; index < read; index++) { cancellationToken.ThrowIfCancellationRequested(); var character = buffer[index]; if (firstCharacter) { firstCharacter = false; if (character == '\uFEFF') continue; } if (skipLineFeed) { skipLineFeed = false; if (character == '\n') continue; } if (state != FieldState.Quoted && character is '\r' or '\n') { AddField(); yield return new CsvRow(rowNumber++, fields.AsReadOnly()); fields = []; state = FieldState.Start; recordChars = 0; hasRecord = false; skipLineFeed = character == '\r'; continue; } if (++recordChars > options.MaxRecordChars) throw Error("The record exceeds MaxRecordChars."); hasRecord = true; switch (state) { case FieldState.Start: if (character == '"') state = FieldState.Quoted; else if (character == options.Delimiter) AddField(); else { field.Append(character); state = FieldState.Unquoted; } break; case FieldState.Unquoted: if (character == options.Delimiter) { AddField(); state = FieldState.Start; } else if (character == '"') throw Error("A quote must start at the beginning of a field."); else field.Append(character); break; case FieldState.Quoted: if (character == '"') state = FieldState.AfterQuote; else field.Append(character); break; case FieldState.AfterQuote: if (character == '"') { field.Append('"'); state = FieldState.Quoted; } else if (character == options.Delimiter) { AddField(); state = FieldState.Start; } else throw Error("Only a delimiter or record ending may follow a closing quote."); break; } } } cancellationToken.ThrowIfCancellationRequested(); if (state == FieldState.Quoted) throw Error("The quoted field has no closing quote."); if (hasRecord) { AddField(); yield return new CsvRow(rowNumber, fields.AsReadOnly()); } void AddField() { if (fields.Count >= options.MaxColumns) throw Error("The record exceeds MaxColumns."); fields.Add(field.ToString()); field.Clear(); } CsvFormatException Error(string message) => new(message, rowNumber, fields.Count + 1); } /// /// Writes records lazily with CSV quoting; null fields become empty and zero-field records are rejected. /// Uses the configured encoding marker and keeps the stream open; failed or cancelled output may be partial. /// public static async Task WriteAsync( Stream destination, IEnumerable> rows, CsvOptions? options = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(rows); if (!destination.CanWrite) throw new ArgumentException("The stream must be writable.", nameof(destination)); options ??= new CsvOptions(); options.Validate(); cancellationToken.ThrowIfCancellationRequested(); var writer = new StreamWriter(destination, options.Encoding, bufferSize: 4096, leaveOpen: true); var delimiter = options.Delimiter.ToString(); long rowNumber = 0; IEnumerator>? enumerator = null; try { enumerator = rows.GetEnumerator(); while (enumerator.MoveNext()) { cancellationToken.ThrowIfCancellationRequested(); var row = enumerator.Current; rowNumber++; if (row is null || row.Count == 0) throw new CsvFormatException("A record must contain at least one field.", rowNumber, 1); if (row.Count > options.MaxColumns) throw new CsvFormatException("The record exceeds MaxColumns.", rowNumber, options.MaxColumns + 1); long recordChars = 0; for (var column = 0; column < row.Count; column++) { cancellationToken.ThrowIfCancellationRequested(); var value = row[column] ?? string.Empty; recordChars += (long)value.Length + (column == 0 ? 0 : 1); if (recordChars > options.MaxRecordChars) throw new CsvFormatException("The record exceeds MaxRecordChars.", rowNumber, column + 1); var quote = value.StartsWith('\uFEFF'); long quoteCount = 0; foreach (var character in value) { cancellationToken.ThrowIfCancellationRequested(); if (character == '"') quoteCount++; if (character == options.Delimiter || character is '"' or '\r' or '\n') quote = true; } if (quote) recordChars += quoteCount + 2; if (recordChars > options.MaxRecordChars) throw new CsvFormatException("The record exceeds MaxRecordChars.", rowNumber, column + 1); if (column != 0) await writer.WriteAsync(delimiter.AsMemory(), cancellationToken).ConfigureAwait(false); if (quote) { await writer.WriteAsync("\"".AsMemory(), cancellationToken).ConfigureAwait(false); await writer.WriteAsync(value.Replace("\"", "\"\"", StringComparison.Ordinal).AsMemory(), cancellationToken).ConfigureAwait(false); await writer.WriteAsync("\"".AsMemory(), cancellationToken).ConfigureAwait(false); } else await writer.WriteAsync(value.AsMemory(), cancellationToken).ConfigureAwait(false); } await writer.WriteAsync(options.NewLine.AsMemory(), cancellationToken).ConfigureAwait(false); } var completedEnumerator = enumerator; enumerator = null; completedEnumerator.Dispose(); await writer.FlushAsync(cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); } catch { try { enumerator?.Dispose(); } catch { // Preserve the processing failure if source cleanup also fails. } try { await writer.DisposeAsync().ConfigureAwait(false); } catch { // Preserve the processing failure if cleanup also fails. } throw; } await writer.DisposeAsync().ConfigureAwait(false); } private enum FieldState { Start, Unquoted, Quoted, AfterQuote } }