diff --git a/src/CsvColumnizer/CsvColumnizer.cs b/src/CsvColumnizer/CsvColumnizer.cs index e780fd2c..b5e0c8c8 100644 --- a/src/CsvColumnizer/CsvColumnizer.cs +++ b/src/CsvColumnizer/CsvColumnizer.cs @@ -176,6 +176,25 @@ public void Selected (ILogLineMemoryColumnizerCallback callback) { ArgumentNullException.ThrowIfNull(callback, nameof(callback)); + DetectDelimiterFromFile(callback); + BuildColumns(callback); + } + + /// + /// This instance may not have pre-processed the current file (e.g. a clone that loaded the saved config), + /// so re-detect the delimiter from the file's first line, like PreProcessLine() does on load. + /// + private void DetectDelimiterFromFile (ILogLineMemoryColumnizerCallback callback) + { + var line = _firstLine ?? callback.GetLogLineMemory(0); + if (line != null) + { + AutoDetectDelimiter(line.FullLine); + } + } + + private void BuildColumns (ILogLineMemoryColumnizerCallback callback) + { if (_isValidCsv) // see PreProcessLine() { _columnList.Clear(); @@ -183,28 +202,33 @@ public void Selected (ILogLineMemoryColumnizerCallback callback) ? _firstLine ?? callback.GetLogLineMemory(0) : callback.GetLogLineMemory(0); + string[]? fields = null; if (line != null) { - using CsvReader csv = new(new StringReader(line.FullLine.ToString()), _config.ReaderConfiguration); - _ = csv.Read(); - _ = csv.ReadHeader(); - - var fieldCount = csv.Parser.Count; - - var headerRecord = csv.HeaderRecord; + try + { + fields = ReadFields(line.FullLine.ToString()); + } + catch (CsvHelperException) + { + // first line doesn't parse with the current settings (e.g. wrong delimiter); same fallback as SplitCsvLine + } + } - if (_config.HasFieldNames && headerRecord != null) + if (fields != null) + { + if (_config.HasFieldNames) { - foreach (var headerColumn in headerRecord) + foreach (var headerColumn in fields) { _columnList.Add(new CsvColumn(headerColumn)); } } else { - for (var i = 0; i < fieldCount; ++i) + for (var i = 0; i < fields.Length; ++i) { - _columnList.Add(new CsvColumn("Column " + i + 1)); + _columnList.Add(new CsvColumn(string.Format(CultureInfo.InvariantCulture, "Column {0}", i + 1))); } } } @@ -254,6 +278,12 @@ public void Configure (ILogLineMemoryColumnizerCallback callback, string configD var configPath = configDir + "\\" + CONFIGFILENAME; FileInfo fileInfo = new(configPath); + // show the file's delimiter rather than the one from the saved config + if (callback != null) + { + DetectDelimiterFromFile(callback); + } + CsvColumnizerConfigDlg dlg = new(_config); if (dlg.ShowDialog() == DialogResult.OK) @@ -268,7 +298,11 @@ public void Configure (ILogLineMemoryColumnizerCallback callback, string configD _config.ConfigureReaderConfiguration(); - Selected(callback); + // no re-detection here: keep the delimiter the user chose in the dialog + if (callback != null) + { + BuildColumns(callback); + } } } @@ -360,6 +394,15 @@ private void AutoDetectDelimiter (ReadOnlyMemory lineContent) } } + /// + /// Parses one line into its fields; avoids ReadHeader(), which throws when HasHeaderRecord is false. + /// + private string[]? ReadFields (string line) + { + using CsvReader csv = new(new StringReader(line), _config.ReaderConfiguration); + return csv.Read() ? csv.Parser.Record : null; + } + private ColumnizedLogLine SplitCsvLine (ILogLineMemory line) { if (line.FullLine.IsEmpty) @@ -374,12 +417,7 @@ private ColumnizedLogLine SplitCsvLine (ILogLineMemory line) try { - using CsvReader csv = new(new StringReader(line.FullLine.ToString()), _config.ReaderConfiguration); - _ = csv.Read(); - _ = csv.ReadHeader(); - - //we only read line by line and not the whole file so it is always the header - var records = csv.HeaderRecord; + var records = ReadFields(line.FullLine.ToString()); if (records != null) { diff --git a/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs b/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs index ed7a404d..0e03e196 100644 --- a/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs +++ b/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs @@ -62,6 +62,37 @@ private static void SetPrivateField (CsvColumnizer.CsvColumnizer columnizer, str field!.SetValue(columnizer, value); } + private const string HEADERLESS_LINE = "2026-09-01 07:09:58.1980; INFO; 2001:7c0:3006:3014::8602:1a1d; ; ; ; zrnsh01 logged in"; + + // Valid-CSV mode with "first line contains field names" unchecked + private static CsvColumnizer.CsvColumnizer CreateColumnizerWithoutFieldNames () + { + CsvColumnizerConfig config = new(); + config.InitDefaults(); + config.HasFieldNames = false; + config.ConfigureReaderConfiguration(); + + CsvColumnizer.CsvColumnizer columnizer = new(); + SetPrivateField(columnizer, "_config", config); + SetPrivateField(columnizer, "_isValidCsv", true); + return columnizer; + } + + // Valid-CSV mode with ',' as configured delimiter, as if loaded from a saved csvcolumnizer.json + private static CsvColumnizer.CsvColumnizer CreateColumnizerWithSavedCommaDelimiter (bool hasFieldNames) + { + CsvColumnizerConfig config = new(); + config.InitDefaults(); + config.DelimiterChar = ","; + config.HasFieldNames = hasFieldNames; + config.ConfigureReaderConfiguration(); + + CsvColumnizer.CsvColumnizer columnizer = new(); + SetPrivateField(columnizer, "_config", config); + SetPrivateField(columnizer, "_isValidCsv", true); + return columnizer; + } + [Test] public void Selected_HasFieldNames_FirstLineNull_FallsBackToCallback () { @@ -81,6 +112,96 @@ public void Selected_HasFieldNames_FirstLineNull_FallsBackToCallback () Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["name", "age", "city"])); } + [Test] + public void Selected_NoFieldNames_ColumnCountFromFirstLine () + { + var columnizer = CreateColumnizerWithoutFieldNames(); + var mockCallback = new Mock(); + _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(new CsvLogLine(HEADERLESS_LINE, 0)); + + columnizer.Selected(mockCallback.Object); + + Assert.That(columnizer.GetColumnCount(), Is.EqualTo(7)); + } + + [Test] + public void Selected_NoFieldNames_GeneratesOneBasedColumnNames () + { + var columnizer = CreateColumnizerWithoutFieldNames(); + var mockCallback = new Mock(); + _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(new CsvLogLine(HEADERLESS_LINE, 0)); + + columnizer.Selected(mockCallback.Object); + + Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["Column 1", "Column 2", "Column 3", "Column 4", "Column 5", "Column 6", "Column 7"])); + } + + [Test] + public void SplitLine_NoFieldNames_SplitsIntoFields () + { + var columnizer = CreateColumnizerWithoutFieldNames(); + var line = new CsvLogLine(HEADERLESS_LINE, 0); + var mockCallback = new Mock(); + _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(line); + columnizer.Selected(mockCallback.Object); + + var result = columnizer.SplitLine(mockCallback.Object, line); + + Assert.That(result.ColumnValues.Select(c => c.FullValue.ToString()), Is.EqualTo(["2026-09-01 07:09:58.1980", " INFO", " 2001:7c0:3006:3014::8602:1a1d", " ", " ", " ", " zrnsh01 logged in"])); + } + + [TestCase(true)] + [TestCase(false)] + public void Selected_FirstLineIsBadData_FallsBackToTextColumn (bool hasFieldNames) + { + var columnizer = CreateColumnizerWithSavedCommaDelimiter(hasFieldNames); + var mockCallback = new Mock(); + _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(new CsvLogLine("\"2021-12-12\"x;\"TRACE\"y", 0)); + + columnizer.Selected(mockCallback.Object); + + Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["Text"])); + } + + [Test] + public void Selected_NoFieldNames_SavedDelimiterDiffersFromFile_DetectsFileDelimiter () + { + // Instance that never pre-processed this file, e.g. a clone that loaded ',' from csvcolumnizer.json + var columnizer = CreateColumnizerWithSavedCommaDelimiter(false); + var line = new CsvLogLine("\"2021-12-12\";\"TRACE\";\"semicolon file \"", 0); + var mockCallback = new Mock(); + _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(line); + + columnizer.Selected(mockCallback.Object); + var result = columnizer.SplitLine(mockCallback.Object, line); + + Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["Column 1", "Column 2", "Column 3"])); + Assert.That(result.ColumnValues.Select(c => c.FullValue.ToString()), Is.EqualTo(["2021-12-12", "TRACE", "semicolon file "])); + } + + [Test] + public void Selected_HasFieldNames_SavedDelimiterDiffersFromFile_DetectsFileDelimiter () + { + var columnizer = CreateColumnizerWithSavedCommaDelimiter(true); + SetPrivateField(columnizer, "_firstLine", new CsvLogLine("\"Date\";\"Level\";\"Message\"", 0)); + var mockCallback = new Mock(); + + columnizer.Selected(mockCallback.Object); + + Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["Date", "Level", "Message"])); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")] + public void PreProcessLine_NoFieldNames_KeepsFirstLineVisible () + { + var columnizer = CreateColumnizerWithoutFieldNames(); + + var result = columnizer.PreProcessLine(HEADERLESS_LINE.AsMemory(), 0, 0); + + Assert.That(result.ToString(), Is.EqualTo(HEADERLESS_LINE)); + } + [Test] public void Selected_HasFieldNames_NoLineAvailable_FallsBackToTextColumn () {