Skip to content

Fix/MultiFile pattern (Issue #696) - #697

Open
Pr0metheus2 wants to merge 12 commits into
LogExperts:Developmentfrom
Pr0metheus2:fix/MultiFile-support-for-mid-filename-index-tags-(app.1.log)
Open

Fix/MultiFile pattern (Issue #696)#697
Pr0metheus2 wants to merge 12 commits into
LogExperts:Developmentfrom
Pr0metheus2:fix/MultiFile-support-for-mid-filename-index-tags-(app.1.log)

Conversation

@Pr0metheus2

@Pr0metheus2 Pr0metheus2 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

This is a fix for #696

  • Fixed/MultiFile rollover patterns with an index before the extension:

    • RolloverFilenameBuilder: the parser was dropping literal text after the final placeholder, including .log.
    • *$J(.).log now correctly expands: app.log → app.1.log → app.2.log.
  • Updated MultiFile mask dialog legend:

    • Fixed incorrectly displayed <date> and <prefix> placeholders in English and German resources.
    • Added both usage examples:
      • *$J(.) → app.log, app.log.1, app.log.2
      • *$J(.).log → app.log, app.1.log, app.2.log
    • Added the examples in English, German, and Chinese.
    • Enlarged the dialog and syntax-help label so the date-format lines are no longer clipped.

Before
Legend_old

After
Legend_new

  • Fixed/Items in menu File/Multifile -> "Enable MultiFile" and "File name mask...", were always greyed out. Now they are displayed normally after the .log file is open.
    • removed the BackColor and ForeColor assignments for both:
      • multiFileEnabledStripMenuItem
      • multifileMaskToolStripMenuItem

Before
Multifile_old

After
Multifile_new

  • Added regression coverage:
    • Filename-builder tests for app.1.log and app.2.log.
    • Reader-level test that loads app.log, app.1.log, and app.2.log in correct order using *$J(.).log.
    • Kept the existing *$J(.) coverage for app.log.1 / app.log.2.

Verification:
- Full test suite: 1064 passed, 0 failed, 7 skipped.
- Resource project build: passed with 0 warnings, 0 errors.

- Fixed/MultiFile rollover patterns with an index before the extension:
    - RolloverFilenameBuilder: the parser was dropping literal text after the final placeholder, including .log.
    - *$J(.).log now correctly expands: app.log → app.1.log → app.2.log.

- Updated MultiFile mask dialog legend:
    - Fixed incorrectly displayed <date> and <prefix> placeholders in English and German resources.
    - Added both usage examples:
        *$J(.) → app.log, app.log.1, app.log.2
        *$J(.).log → app.log, app.1.log, app.2.log
    - Added the examples in English, German, and Chinese.
    - Enlarged the dialog and syntax-help label so the date-format lines are no longer clipped.

- Fixed/Items in menu File/Multifile -> "Enable MultiFile" and "Multi file mask...", were always greyed out. Now they are displayed normally after the .log file is open.
		- removed the BackColor and ForeColor assignments for both:
			- multiFileEnabledStripMenuItem
			- multifileMaskToolStripMenuItem

- Added regression coverage:
    - Filename-builder tests for app.1.log and app.2.log.
    - Reader-level test that loads app.log, app.1.log, and app.2.log in correct order using *$J(.).log.
    - Kept the existing *$J(.) coverage for app.log.1 / app.log.2.

  Verification:
	- Full test suite: 1064 passed, 0 failed, 7 skipped.
	- Resource project build: passed with 0 warnings, 0 errors.
}
}

_ = result.Append(Regex.Escape(segment.ToString()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated Code — [RolloverFilenameBuilder.cs:241] is the exact shape of the state-0 flush at line 202. A tiny local FlushEscaped() would let both share it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added FlushEscaped() to share duplicated escaping code.

Comment thread src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs Outdated
Comment thread src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs Outdated
Comment thread src/LogExpert.Resources/Resources.de.resx Outdated
}
}

_ = result.Append(Regex.Escape(segment.ToString()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Index parsing from a rotated file is still broken. Spec: "app.1.log → Rotated log (Index 1)". RolloverFilenameHandler calls SetFileName on whatever file the user opens; for $J(.).log the regex .(?'index'\d*).log has a greedy .* that swallows the digit in app.1.log, so Index parses as 0 and BuildFileName would produce app.1.1.log. Grouping only works when the active file (app.log) is opened. The same flaw pre-exists for *$J(.) with app.log.1, so it's parity rather than a regression — so the index mapping is only partially delivered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • fixed parsing of $J(<prefix>) from rotated files, so both app.log.1 and app.1.log correctly identify index 1.
  • made wildcard matching non-greedy and anchored the generated regex to the complete filename.
  • updated rebuilding logic to remove an existing prefix plus index together before inserting the new index.
  • fixed $D(...) parsing to use positions in the escaped format string, preventing errors when a literal regex character such as . appears before $D

Added coverage for opening:

  • engine.log.1 with *$J(.) → builds engine.log.2
  • engine.1.log with *$J(.).log → builds engine.2.log

[TestCase("engine1.log", "engine2.log","engine$J.log")]
[TestCase("engine.log", "engine.log.1","*$J(.)")]
[TestCase("engine.log", "engine.1.log", "*$J(.).log")]
[TestCase("engine_2010-06-12.log", "engine_2010-06-12.log.1", "*$D(yyyy-MM-dd).log$J(.)")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$I untested. For "$J, $I" support, the fix itself generalizes, but no test covers $I or $D followed by a trailing literal (e.g. *$D(yyyy-MM-dd)$J(.).log).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added cases which cover:

  • $I with a trailing literal .log, starting from a rotated file.
  • $D plus $J(.) followed by a trailing .log, also starting from a rotated file.

[TestCase("engine1.log", "engine2.log","engine$J.log")]
[TestCase("engine.log", "engine.log.1","*$J(.)")]
[TestCase("engine.log", "engine.1.log", "*$J(.).log")]
[TestCase("engine_2010-06-12.log", "engine_2010-06-12.log.1", "*$D(yyyy-MM-dd).log$J(.)")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParseFormatString computes datePos from the original string but edits the escaped one — a literal with regex metachars before $D (e.g. app.$D(yyyy-MM-dd)) corrupts the regex. Not introduced here, but the new trailing-literal support makes such patterns likelier.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by locating and parsing $D(...) in the escaped format string. Offsets remain correct when literal regex metacharacters appear before the date placeholder.
Added a regression test for app.$D(yyyy-MM-dd).log that verifies date parsing and rebuilding.

Fixed by locating and parsing $D(...) in the escaped format string. Offsets remain correct when literal regex metacharacters appear before the date placeholder.
Added a regression test for app.$D(yyyy-MM-dd).log that verifies date parsing and rebuilding.

Added cases which cover:
- $I with a trailing literal .log, starting from a rotated file.
- $D plus $J(.) followed by a trailing .log, also starting from a rotated file.
  - Added FlushEscaped() to share duplicated escaping code.
  - Fixed parsing of $J(<prefix>) from rotated files, so both:
      - app.log.1
      - app.1.log
        correctly identify index 1.

  - Made wildcard matching non-greedy and anchored the generated regex to the complete filename.
  - Updated rebuilding logic to remove an existing prefix plus index together before inserting the new index.
  - Fixed $D(...) parsing to use positions in the escaped format string, preventing errors when a literal regex character such as . appears before $D.
@Pr0metheus2

Copy link
Copy Markdown
Contributor Author

Check please

{
_ = result.Append(Regex.Escape(segment.ToString()));
segment = new StringBuilder();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract it to a real function not a function in a function, this only leads to unreadable code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the helper out of EscapeNonvarRegions() into a private class method. (1)

StringBuilder result = new();
StringBuilder segment = new();

void FlushEscaped ()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FlushEscaped() covers only case 0, while case 1 and case 3 still inline _ = result.Append(segment); segment = new StringBuilder();.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added two shared helpers: one appends an escaped literal segment and one appends a placeholder segment. Both also clear the segment, so states 0, 1, and 3 now use the same append & clear behavior. (2)

if (_indexGroup != null && _indexGroup.Success)
{
fileName = fileName.Remove(_indexGroup.Index, _indexGroup.Length);
var indexPosition = _indexGroup.Index;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hidden invariant / Feature Envy — indexPosition = _condGroup.Index; indexLength += _condGroup.Length; silently assumes cond is contiguous and immediately precedes index — an invariant set 60 lines away in ParseFormatString. Uncommented; changing either regex breaks the other.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the separate prefix-position calculation. The generated regex now captures the complete index part, including its prefix when present, in one indexPart group. Rebuilding removes and replaces that explicit group directly. (3)

Comment thread src/LogExpert.Tests/RollingNameTest.cs Outdated
}

[Test]
public void BuildFileName_DatePatternAfterRegexMetacharacter_IncrementsDate ()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildFileName_X_Y while the file's existing methods are TestFilenameAnd1/2.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to TestFilenameDateAfterRegexMetacharacter
(8)

_hideZeroIndex = fmt.Contains("$J", StringComparison.Ordinal);
fmt = fmt.Replace("$I", "(?'index'[\\d]+)", StringComparison.Ordinal);
fmt = fmt.Replace("$J", "(?'index'[\\d]*)", StringComparison.Ordinal);
var optionalIndexPattern = _condContent != null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optionalIndexPattern reads as "the pattern is optional";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the pattern variables to indexPattern and conditionalIndexPattern to describe what they match rather than suggesting that the whole pattern is optional. (4)


File.Copy(Path.Combine(_testDataDirectory, "app.log"), _logFile);
File.Copy(Path.Combine(_testDataDirectory, "app.log.1"), _logFile + ".1");
File.Copy(Path.Combine(_testDataDirectory, "app.1.log"), Path.Combine(_testDirectory, "app.1.log"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SetUp copies app.1.log/app.2.log unconditionally, so all four tests now see two extra files though only the new one needs them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two copies moved to SingleFileCtor_MultiFileTrue_LoadsIndexBeforeExtension().
(9)

fmt = fmt.Replace("*", ".*", StringComparison.Ordinal);
fmt = fmt.Replace("*", ".*?", StringComparison.Ordinal);
_hideZeroIndex = fmt.Contains("$J", StringComparison.Ordinal);
fmt = fmt.Replace("$I", "(?'index'[\\d]+)", StringComparison.Ordinal);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Allow index tags ($J, $I) to appear before a fixed file extension suffix." RolloverFilenameBuilder.cs:189 still maps $I → (?'index'[\d]+); only $J gets the prefix-aware alternation (:190-193). So *$I.log cannot match the active file app.log — SetFileName fails, IsIndexPattern stays false, no chain is built. The one new $I test (RollingNameTest.cs:36, engine1.log→engine2.log) starts from a numbered file and never exercises the failing case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated $I to match an unnumbered active file as well as numbered rotated files.
Added coverage for app.log becoming app1.log after the index is incremented.
(5)

$D(&amp;lt;date&amp;gt;) = Datumsmuster
$D(&lt;date&gt;) = Datumsmuster
$I = Dateiindexnummer
$J = Dateiindexnummer, versteckt wenn 09

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still reads $J = Dateiindexnummer, versteckt wenn 09 — should be 0. Correct in the English and Chinese files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (versteckt wenn null)

}

fmt = fmt.Replace("*", ".*", StringComparison.Ordinal);
fmt = fmt.Replace("*", ".*?", StringComparison.Ordinal);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.→.? and :194 \A…\z anchoring change every mask, not just ones with trailing literals. It holds because RolloverFilenameHandler.cs:39 passes a bare filename, not a path — but that invariant is undocumented and untested; a full path with any non-*-prefixed mask now fails where it previously matched.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the end anchor needed to parse an index before fixed suffix, but removed the start anchor.
This retains compatibility with full paths and non-wildcard masks.
Added a full-path regression test.
(6)

@@ -116,15 +116,23 @@ public string BuildFileName ()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A saved mask *$J(.).log previously behaved as *$J(.) and matched app.log.1; it now means app.1.log. That's what the spec wants, but existing user settings change

@Pr0metheus2 Pr0metheus2 Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the corrected syntax remain this way?

  • *$J(.) → app.log, app.log.1, app.log.2
  • *$J(.).log → app.log, app.1.log, app.2.log

If so, should I migrate existing saved *$J(.).log masks to *$J(.)?
(7)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added settings migration for corrected MultiFile mask behavior.

To preserve compatibility, settings version 2 migrates existing saved *$J(.).log preferences to *$J(.). New version-2 settings keep both patterns distinct:

  • *$J(.) for app.log.1
  • *$J(.).log for app.1.log

Added tests for migrating old settings and for ensuring new settings are not changed.

Added a settings migration for the corrected MultiFile mask behavior.

  Older LogExpert versions accidentally ignored the trailing .log in saved *$J(.).log masks, so users effectively got app.log.1 style rollover files. The parser now correctly supports app.1.log for that mask, but this would otherwise change existing users’ saved behavior.

  To preserve compatibility, settings version 2 migrates existing saved *$J(.).log preferences to *$J(.). New version-2 settings keep both patterns distinct:

  - *$J(.) for app.log.1
  - *$J(.).log for app.1.log

  Added tests for migrating old settings and for ensuring new settings are not changed.
private static void MigrateToV2 (Settings settings)
{
// Before the parser fix, the trailing .log in this pattern was ignored, making it behave
// like *$J(.). Preserve that behavior for existing saved preferences.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rewrites a saved *$J(.).log to *$J(.). A user who already saved that pattern hoping for the new grouping, which is exactly the situation, gets silently reverted to the old behaviour. It bumps the schema version for every user and does not touch per-file persisted patterns, so the "preserve old behaviour" rationale is inconsistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) OK, I've removed the settings migration completely:

  • Schema version restored to 1.
  • Removed MigrateToV2().
  • Removed its tests and test-only import.
  • Existing saved *$J(.).log masks now retain the corrected new meaning.

fmt = fmt.Replace("$I", indexPattern, StringComparison.Ordinal);
fmt = fmt.Replace("$J", conditionalIndexPattern, StringComparison.Ordinal);

_regex = new Regex(fmt + @"\z");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$I digit class went from one-or-more to zero-or-more. A plain app.log now matches *$I.log as index 0, so $I behaves like $J when the opened file has no digits.

*$J(.) no longer behaves exactly as before. The lazy wildcard plus the end anchor mean opening engine.log.1 now yields index 1. Previously it yielded index 0 and generated engine.log.1.1. This is a fix, but it is an unstated behaviour change.

The FlushEscaped method named in the PR description does not exist. The helpers are AppendEscapedSegment and AppendSegment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(2) Restored $I behavior:

  • $I again requires one or more digits ([\d]+).
  • app.log no longer matches *$I.log.
  • $J retains its zero-index handling and the rotated-file parsing fix.
  • The corrected behavior for opening engine.log.1 is kept; describe that explicitly in the PR rather than treating it as invisible behavior.

Note: I cannot find FlushEscaped in the current PR description.

[Test]
public void SingleFileCtor_MultiFileTrue_LoadsIndexBeforeExtension ()
{
var options = new MultiFileOptions { FormatPattern = "*$J(.).log" };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The $I case is only tested from the active file, despite the PR description claiming a rotated-file $I test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(3) Updated the $I test to start from a rotated filename:
engine1.log → engine2.log
with *$I.log.

{
return new LogfileReader(
_logFile,
new EncodingOptions { Encoding = Encoding.UTF8 },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opens the active file only. Opening a rotated file directly never picks up lower siblings because the name-list walk only goes upward. That is pre-existing, but the spec is only met when the active file is opened.

No round-trip test for *$J(.).log.

@Pr0metheus2 Pr0metheus2 Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that this is intended behavior: when a rotated file such as app.1.log is opened, MultiFile loads that file and higher indexes only. To load the complete chain including lower indexes, the user should open the active file (app.log). If user want to open log file from specified date only and further, i.e. app.8.log, this is a useful behaviour and in this case app will load app.8.log -> app.9.log etc.

Please confirm whether this is correct. If MultiFile should also discover lower-index siblings when a rotated file is opened directly, I can extend the handler and add coverage for that behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants