diff --git a/src/ColumnizerLib/ColumnizerLib.csproj b/src/ColumnizerLib/ColumnizerLib.csproj
index df508ab62..54252f118 100644
--- a/src/ColumnizerLib/ColumnizerLib.csproj
+++ b/src/ColumnizerLib/ColumnizerLib.csproj
@@ -8,6 +8,7 @@
ColumnizerLib
LogExpert.ColumnizerLib
+ 1.22.0
diff --git a/src/ColumnizerLib/ILogFileInfo.cs b/src/ColumnizerLib/ILogFileInfo.cs
index 6a10b530c..ed5a36309 100644
--- a/src/ColumnizerLib/ILogFileInfo.cs
+++ b/src/ColumnizerLib/ILogFileInfo.cs
@@ -51,6 +51,12 @@ public interface ILogFileInfo
///
bool FileExists { get; }
+ ///
+ /// Gets the time when the file was last written, in UTC, or when the file system plugin
+ /// cannot provide it.
+ ///
+ DateTime? LastWriteTimeUtc => null;
+
///
/// The interval (in ms) LogExpert should check for file changes. The property is checked by LogExpert repeatedly in the loop which
/// checks for file changes. So you can adjust the poll interval as needed. E.g. you can lower the interval when many changes occur and
diff --git a/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs b/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs
index 1c4fe52c9..a7cefec28 100644
--- a/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs
+++ b/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs
@@ -21,6 +21,7 @@ public class RolloverFilenameHandler
private readonly RolloverFilenameBuilder _filenameBuilder;
private readonly ILogFileInfo _logFileInfo;
private readonly MultiFileOptions _options;
+ private readonly TimeProvider _timeProvider;
#endregion
@@ -31,10 +32,12 @@ public class RolloverFilenameHandler
///
/// The complete path of the logfile
/// Multifile option (e.g. format pattern)
- public RolloverFilenameHandler(ILogFileInfo logFileInfo, MultiFileOptions options)
+ /// Time source used to calculate the file-age cutoff.
+ public RolloverFilenameHandler(ILogFileInfo logFileInfo, MultiFileOptions options, TimeProvider timeProvider = null)
{
_options = options;
_logFileInfo = logFileInfo;
+ _timeProvider = timeProvider ?? TimeProvider.System;
_filenameBuilder = new RolloverFilenameBuilder(_options.FormatPattern);
_filenameBuilder.SetFileName(logFileInfo.FileName);
}
@@ -69,12 +72,18 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry)
_filenameBuilder.Index += 1;
fileName = _filenameBuilder.BuildFileName();
filePath = _logFileInfo.DirectoryName + _logFileInfo.DirectorySeparatorChar + fileName;//TODO: Change to Directory.Combine
- if (FileExists(filePath, pluginRegistry))
+ var result = GetCandidateResult(filePath, pluginRegistry);
+ if (result == CandidateResult.Include)
{
_ = fileList.AddFirst(filePath);
found = true;
continue;
}
+
+ if (result == CandidateResult.TooOld)
+ {
+ return fileList;
+ }
}
// if file with index isn't found or no index is in format pattern, decrement the current date
@@ -87,13 +96,19 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry)
_filenameBuilder.DecrementDate();
fileName = _filenameBuilder.BuildFileName();
filePath = _logFileInfo.DirectoryName + _logFileInfo.DirectorySeparatorChar + fileName;//TODO: Change to Directory.Combine
- if (FileExists(filePath, pluginRegistry))
+ var result = GetCandidateResult(filePath, pluginRegistry);
+ if (result == CandidateResult.Include)
{
_ = fileList.AddFirst(filePath);
found = true;
break;
}
+ if (result == CandidateResult.TooOld)
+ {
+ return fileList;
+ }
+
tryCounter++;
}
}
@@ -106,17 +121,35 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry)
#region Private Methods
- private bool FileExists(string filePath, IPluginRegistry pluginRegistry)
+ private CandidateResult GetCandidateResult(string filePath, IPluginRegistry pluginRegistry)
{
var fs = pluginRegistry.FindFileSystemForUri(filePath);
if(fs == null)
{
- return false;
+ return CandidateResult.Missing;
}
var info = fs.GetLogfileInfo(filePath);
- return info is not null && info.FileExists;
+ if (info is null || !info.FileExists)
+ {
+ return CandidateResult.Missing;
+ }
+
+ if (_options.MaxFileAgeDays <= 0 || info.LastWriteTimeUtc is not { } lastWriteTimeUtc)
+ {
+ return CandidateResult.Include;
+ }
+
+ var cutoffUtc = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-_options.MaxFileAgeDays);
+ return lastWriteTimeUtc >= cutoffUtc ? CandidateResult.Include : CandidateResult.TooOld;
+ }
+
+ private enum CandidateResult
+ {
+ Missing,
+ Include,
+ TooOld
}
#endregion
diff --git a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs
index 37e80e377..fb72943c0 100644
--- a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs
+++ b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs
@@ -71,6 +71,8 @@ public class PersistenceData
public int MultiFileMaxDays { get; set; }
+ public int MultiFileMaxFileAgeDays { get; set; }
+
public List MultiFileNames { get; set; } = [];
public string MultiFilePattern { get; set; }
diff --git a/src/LogExpert.Core/Classes/Persister/PersisterXML.cs b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs
index f27285a72..a236e19b8 100644
--- a/src/LogExpert.Core/Classes/Persister/PersisterXML.cs
+++ b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs
@@ -521,6 +521,18 @@ FormatException or
persistenceData.MultiFileMaxDays = 0;
}
+ value = GetOptionsAttribute(optionsNode, "multifile", "maxFileAgeDays");
+ try
+ {
+ persistenceData.MultiFileMaxFileAgeDays = value != null ? int.Parse(value, CultureInfo.InvariantCulture) : 0;
+ }
+ catch (Exception ex) when (ex is ArgumentNullException or
+ FormatException or
+ OverflowException)
+ {
+ persistenceData.MultiFileMaxFileAgeDays = 0;
+ }
+
XmlNode multiFileNode = optionsNode.SelectSingleNode("multifile");
if (multiFileNode != null)
{
diff --git a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs
index 490aa8c83..5482075e2 100644
--- a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs
+++ b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs
@@ -36,6 +36,7 @@ public static PersistenceData Compose (SessionSnapshot snapshot)
FilterSaveListVisible = snapshot.FilterSaveListVisible,
MultiFile = snapshot.MultiFile,
MultiFileMaxDays = snapshot.MultiFileMaxDays,
+ MultiFileMaxFileAgeDays = snapshot.MultiFileMaxFileAgeDays,
MultiFilePattern = snapshot.MultiFilePattern,
TabName = snapshot.TabName,
HighlightGroupName = snapshot.HighlightGroupName,
@@ -83,6 +84,7 @@ public static SessionSnapshot Decompose (PersistenceData persistenceData)
FilterSaveListVisible = persistenceData.FilterSaveListVisible,
MultiFile = persistenceData.MultiFile,
MultiFileMaxDays = persistenceData.MultiFileMaxDays,
+ MultiFileMaxFileAgeDays = persistenceData.MultiFileMaxFileAgeDays,
MultiFilePattern = persistenceData.MultiFilePattern,
TabName = persistenceData.TabName,
HighlightGroupName = persistenceData.HighlightGroupName,
diff --git a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs
index bec804861..462309012 100644
--- a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs
+++ b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs
@@ -43,6 +43,8 @@ public class SessionSnapshot
public int MultiFileMaxDays { get; set; }
+ public int MultiFileMaxFileAgeDays { get; set; }
+
public string MultiFilePattern { get; set; }
public string TabName { get; set; }
diff --git a/src/LogExpert.Core/Entities/MultifileOptions.cs b/src/LogExpert.Core/Entities/MultifileOptions.cs
index b9ab6d782..e83d1362f 100644
--- a/src/LogExpert.Core/Entities/MultifileOptions.cs
+++ b/src/LogExpert.Core/Entities/MultifileOptions.cs
@@ -5,8 +5,16 @@ public class MultiFileOptions
{
#region Properties
+ ///
+ /// Gets or sets how many consecutive days without a file may be skipped when walking a date pattern.
+ ///
public int MaxDayTry { get; set; } = 3;
+ ///
+ /// Gets or sets the maximum age, in days, of rollover files loaded into a MultiFile. Zero disables the limit.
+ ///
+ public int MaxFileAgeDays { get; set; }
+
public string FormatPattern { get; set; } = "*$J(.)";
#endregion
diff --git a/src/LogExpert.Persister.Tests/PersisterTests.cs b/src/LogExpert.Persister.Tests/PersisterTests.cs
index 52e0dcb78..b07be02cd 100644
--- a/src/LogExpert.Persister.Tests/PersisterTests.cs
+++ b/src/LogExpert.Persister.Tests/PersisterTests.cs
@@ -357,6 +357,7 @@ public void RoundTrip_WithApplicationStartupDir_PreservesAllData ()
MultiFile = true,
MultiFilePattern = "test*.log",
MultiFileMaxDays = 7,
+ MultiFileMaxFileAgeDays = 5,
LineCount = 1000,
CellSelectMode = true
};
@@ -377,6 +378,8 @@ public void RoundTrip_WithApplicationStartupDir_PreservesAllData ()
Assert.That(loadedData.MultiFile, Is.EqualTo(originalData.MultiFile), "MultiFile should match");
Assert.That(loadedData.MultiFilePattern, Is.EqualTo(originalData.MultiFilePattern), "MultiFilePattern should match");
Assert.That(loadedData.MultiFileMaxDays, Is.EqualTo(originalData.MultiFileMaxDays), "MultiFileMaxDays should match");
+ Assert.That(loadedData.MultiFileMaxFileAgeDays, Is.EqualTo(originalData.MultiFileMaxFileAgeDays),
+ "MultiFileMaxFileAgeDays should match");
Assert.That(loadedData.LineCount, Is.EqualTo(originalData.LineCount), "LineCount should match");
Assert.That(loadedData.CellSelectMode, Is.EqualTo(originalData.CellSelectMode), "CellSelectMode should match");
}
diff --git a/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs b/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs
index ee6aaba95..b4546e5f7 100644
--- a/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs
+++ b/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs
@@ -44,6 +44,7 @@ public void Load_FileElementWithoutOptions_ReturnsDefaults ()
Assert.That(data.MultiFile, Is.False);
Assert.That(data.MultiFilePattern, Is.Null);
Assert.That(data.MultiFileMaxDays, Is.Zero);
+ Assert.That(data.MultiFileMaxFileAgeDays, Is.Zero);
Assert.That(data.FilterVisible, Is.False);
// A missing means "nothing was persisted", so every option keeps its
diff --git a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs
index b1c97af82..f820d7165 100644
--- a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs
+++ b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs
@@ -172,6 +172,7 @@ private static SessionSnapshot CreateFullSnapshot (int filterTabDepth = 2)
FilterSaveListVisible = true,
MultiFile = true,
MultiFileMaxDays = 7,
+ MultiFileMaxFileAgeDays = 5,
MultiFilePattern = "app*.log",
TabName = "My Tab",
HighlightGroupName = "Errors",
@@ -247,6 +248,7 @@ private static PersistenceData CreateCarriedPersistenceData (int filterTabDepth)
FilterSaveListVisible = true,
MultiFile = true,
MultiFileMaxDays = 7,
+ MultiFileMaxFileAgeDays = 5,
MultiFilePattern = "app*.log",
TabName = "My Tab",
HighlightGroupName = "Errors",
@@ -422,6 +424,28 @@ public void DiskTrip_ComposePersistLoadDecompose_ReturnsEqualSnapshot ()
Assert.That(SerializeForComparison(roundTripped), Is.EqualTo(SerializeForComparison(snapshot)));
}
+ [Test]
+ public void Decompose_LegacyJsonWithoutMaxFileAgeDays_DefaultsToOff ()
+ {
+ var sessionFileName = Path.Join(_testDirectory, "legacy-json.lxp");
+ File.WriteAllText(sessionFileName, """
+ {
+ "MultiFile": true,
+ "MultiFileMaxDays": 7,
+ "MultiFilePattern": "*$J(.)"
+ }
+ """);
+
+ var loaded = Core.Classes.Persister.Persister.Load(sessionFileName);
+ var snapshot = SessionFileComposer.Decompose(loaded);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(loaded.MultiFileMaxFileAgeDays, Is.Zero);
+ Assert.That(snapshot.MultiFileMaxFileAgeDays, Is.Zero);
+ });
+ }
+
#endregion
#region Window filter (issue #666)
@@ -490,6 +514,8 @@ public void Decompose_LegacyXmlSessionFileOnDisk_TakesTheFirstFilterAsTheWindowF
Assert.That(snapshot.FilterParams.SearchText, Is.EqualTo("the first global filter"));
Assert.That(snapshot.FilterParams.SpreadBefore, Is.EqualTo(1),
"the restored entry must be the first one, whole — the second carries SpreadBefore 2");
+ Assert.That(snapshot.MultiFileMaxFileAgeDays, Is.Zero,
+ "legacy XML Session Files without the new field must keep age filtering disabled");
Assert.That(snapshot.FilterParams.Color, Is.EqualTo(Color.Black),
"Color is the field the legacy payload writes as \"Black\" — losing it means the entry was not really read");
}
diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index c9a2efa58..7109f7a3f 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -4435,11 +4435,29 @@ public static string MultiFileMaskDialog_UI_Label_FileNamePattern {
}
///
- /// Looks up a localized string similar to Max days:.
+ /// Looks up a localized string similar to Max day gap:.
///
- public static string MultiFileMaskDialog_UI_Label_MaxDays {
+ public static string MultiFileMaskDialog_UI_Label_MaxDayGap {
get {
- return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxDays", resourceCulture);
+ return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxDayGap", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Max file age (days):.
+ ///
+ public static string MultiFileMaskDialog_UI_Label_MaxFileAgeDays {
+ get {
+ return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxFileAgeDays", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to 0 = Off.
+ ///
+ public static string MultiFileMaskDialog_UI_Label_MaxFileAgeDaysHint {
+ get {
+ return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxFileAgeDaysHint", resourceCulture);
}
}
@@ -6184,11 +6202,47 @@ public static string SettingsDialog_UI_Label_labelLinesPerBlock {
}
///
- /// Looks up a localized string similar to Max days:.
+ /// Looks up a localized string similar to Max day gap:.
+ ///
+ public static string SettingsDialog_UI_Label_labelMaxDayGap {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxDayGap", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Max file age (days):.
+ ///
+ public static string SettingsDialog_UI_Label_labelMaxFileAgeDays {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxFileAgeDays", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to 0 = Off.
+ ///
+ public static string SettingsDialog_UI_Label_labelMaxFileAgeDaysHint {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxFileAgeDaysHint", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to For $D patterns: how many consecutive days without a log file to skip before giving up..
+ ///
+ public static string SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileDays {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileDays", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Maximum age of rollover files to load, in days. Set to 0 to disable the age limit..
///
- public static string SettingsDialog_UI_Label_labelMaxDays {
+ public static string SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileMaxFileAgeDays {
get {
- return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxDays", resourceCulture);
+ return ResourceManager.GetString("SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileMaxFileAgeDays", resourceCulture);
}
}
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index 2ef41f1cb..c27cab565 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -721,8 +721,20 @@ Damit die anderen Fenster beim Auswählen einer Zeile (Mausklick oder Pfeiltaste
Pattern:
-
- Maximale Tage:
+
+ Max. Tageslücke:
+
+
+ Max. Dateialter (Tage):
+
+
+ 0 = Aus
+
+
+ Bei $D-Mustern: Anzahl aufeinanderfolgender Tage ohne Protokolldatei, die vor dem Abbruch übersprungen werden.
+
+
+ Maximales Alter der zu ladenden Rollover-Dateien in Tagen. 0 deaktiviert die Altersgrenze.
Argumente:
@@ -1676,8 +1688,14 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools
Dateinamemuster:
-
- Max Tage:
+
+ Max. Tageslücke:
+
+
+ Max. Dateialter (Tage):
+
+
+ 0 = Aus
Muster syntax:
@@ -1696,7 +1714,11 @@ Beispiele:
DD = Tag
MM = Monat
YY[YY] = Jahr
-Alle anderen Zeichen werden benutzt wie sie angegeben sind
+Alle anderen Zeichen werden benutzt wie sie angegeben sind
+
+MultiFile-Grenzen:
+Max. Tageslücke = Bei $D-Mustern aufeinanderfolgende Tage ohne Protokolldatei, die vor dem Abbruch übersprungen werden
+Max. Dateialter (Tage) = Älteste zu ladende Rollover-Dateien in Tagen; 0 deaktiviert die Altersgrenze
Spalten
diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx
index 79a319f5b..20dce13fc 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -719,8 +719,20 @@ Checked tools will appear in the icon bar. All other tools are available in the
Arguments:
-
- Max days:
+
+ Max day gap:
+
+
+ Max file age (days):
+
+
+ 0 = Off
+
+
+ For $D patterns: how many consecutive days without a log file to skip before giving up.
+
+
+ Maximum age of rollover files to load, in days. Set to 0 to disable the age limit.
Pattern:
@@ -1739,8 +1751,14 @@ Checked tools will appear in the icon bar. All other tools are available in the
File name pattern:
-
- Max days:
+
+ Max day gap:
+
+
+ Max file age (days):
+
+
+ 0 = Off
Pattern syntax:
@@ -1759,7 +1777,11 @@ Examples:
DD = day
MM = month
YY[YY] = year
-all other chars will be used as given
+all other chars will be used as given
+
+MultiFile limits:
+Max day gap = For $D patterns, consecutive days without a log file to skip before giving up
+Max file age (days) = Oldest rollover files to load; 0 disables the age limit
Columns
diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx
index d8026b569..e3bffad1e 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -616,8 +616,20 @@
参数:
-
- 最大天数:
+
+ 最大日期间隔:
+
+
+ 最大文件年龄(天):
+
+
+ 0 = 关闭
+
+
+ 对于 $D 模式:放弃搜索前可跳过的连续无日志文件天数。
+
+
+ 要加载的滚动日志文件的最大天数。设置为 0 可关闭文件期限限制。
模式:
@@ -1545,8 +1557,14 @@
文件名模式:
-
- 最大天数:
+
+ 最大日期间隔:
+
+
+ 最大文件年龄(天):
+
+
+ 0 = 关闭
模式语法:
@@ -1565,7 +1583,11 @@ $J(<前缀>) = 类似 $J,但在非零时添加 <前缀>
DD = 日
MM = 月
YY[YY] = 年
-其它字符将按原样使用
+其它字符将按原样使用
+
+多文件限制:
+最大日期间隔 = 对于 $D 模式,放弃搜索前可跳过的连续无日志文件天数
+最大文件年龄(天) = 要加载的最旧滚动日志文件天数;0 表示关闭期限限制
列
@@ -2261,4 +2283,4 @@ YY[YY] = 年
无法应用隐藏规则,显示所有行:{0}
-
\ No newline at end of file
+
diff --git a/src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs
new file mode 100644
index 000000000..c56b796a5
--- /dev/null
+++ b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs
@@ -0,0 +1,48 @@
+using LogExpert.Core.Config;
+using LogExpert.Core.Entities;
+
+using Newtonsoft.Json;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.ConfigManagerTests;
+
+[TestFixture]
+public class PreferencesMultiFileOptionsTests
+{
+ [Test]
+ public void Deserialize_LegacyJsonWithoutMaxFileAgeDays_DefaultsToOff ()
+ {
+ const string legacyJson = """
+ {
+ "MultiFileOptions": {
+ "FormatPattern": "*$J(.)",
+ "MaxDayTry": 7
+ }
+ }
+ """;
+
+ var preferences = JsonConvert.DeserializeObject(legacyJson);
+
+ Assert.That(preferences, Is.Not.Null);
+ Assert.Multiple(() =>
+ {
+ Assert.That(preferences!.MultiFileOptions.MaxDayTry, Is.EqualTo(7));
+ Assert.That(preferences.MultiFileOptions.MaxFileAgeDays, Is.Zero);
+ });
+ }
+
+ [Test]
+ public void RoundTrip_PreservesMaxFileAgeDays ()
+ {
+ var preferences = new Preferences
+ {
+ MultiFileOptions = new MultiFileOptions { MaxFileAgeDays = 14 }
+ };
+
+ var restored = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(preferences));
+
+ Assert.That(restored, Is.Not.Null);
+ Assert.That(restored!.MultiFileOptions.MaxFileAgeDays, Is.EqualTo(14));
+ }
+}
diff --git a/src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs b/src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs
new file mode 100644
index 000000000..b3a3036b5
--- /dev/null
+++ b/src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs
@@ -0,0 +1,194 @@
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Log;
+using LogExpert.Core.Entities;
+using LogExpert.Core.Interfaces;
+
+using Moq;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests;
+
+[TestFixture]
+internal sealed class RolloverFilenameHandlerAgeTests
+{
+ private const string LOG_DIRECTORY = "/logs";
+ private static readonly DateTimeOffset _now = new(2026, 10, 19, 12, 0, 0, TimeSpan.Zero);
+
+ [TestCase("*$J(.)", "app.log.1", "app.log.2")]
+ [TestCase("*$J(.).log", "app.1.log", "app.2.log")]
+ public void GetNameList_MaxFileAge_StopsAtFirstOldIndexedFile (
+ string pattern,
+ string recentFileName,
+ string oldFileName)
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = pattern, MaxFileAgeDays = 5 },
+ (recentFileName, _now.AddDays(-2).UtcDateTime),
+ (oldFileName, _now.AddDays(-10).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath(recentFileName), GetPath("app.log") }));
+ }
+
+ [Test]
+ public void GetNameList_ZeroMaxFileAge_LoadsAllIndexedFiles ()
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 0 },
+ ("app.log.1", _now.AddDays(-10).UtcDateTime),
+ ("app.log.2", _now.AddDays(-20).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app.log.2"), GetPath("app.log.1"), GetPath("app.log") }));
+ }
+
+ [Test]
+ public void GetNameList_FirstRolloverIsOld_StopsBeforeNewerHigherIndex ()
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 5 },
+ ("app.log.1", _now.AddDays(-10).UtcDateTime),
+ ("app.log.2", _now.AddDays(-1).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app.log") }));
+ Assert.That(context.RequestedPaths, Does.Not.Contain(GetPath("app.log.2")));
+ });
+ }
+
+ [Test]
+ public void GetNameList_OpenedFileIsOld_StillIncludesOpenedFile ()
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 5 },
+ activeLastWriteTimeUtc: _now.AddDays(-10).UtcDateTime);
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app.log") }));
+ }
+
+ [Test]
+ public void GetNameList_DatePattern_SkipsConfiguredGapAndStopsAtFirstOldFile ()
+ {
+ var context = CreateContext(
+ "app_2026-10-19.log",
+ new MultiFileOptions
+ {
+ FormatPattern = "*$D(yyyy-MM-dd).log",
+ MaxDayTry = 3,
+ MaxFileAgeDays = 5
+ },
+ ("app_2026-10-17.log", _now.AddDays(-2).UtcDateTime),
+ ("app_2026-10-16.log", _now.AddDays(-10).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app_2026-10-17.log"), GetPath("app_2026-10-19.log") }));
+ }
+
+ [Test]
+ public void GetNameList_UnknownLastWriteTime_IncludesFile ()
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 5 },
+ ("app.log.1", null));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app.log.1"), GetPath("app.log") }));
+ }
+
+ [Test]
+ public void GetNameList_MixedPattern_OldIndexedFileStopsBeforeDateFallback ()
+ {
+ var previousDateFile = "app_2026-10-18.log";
+ var context = CreateContext(
+ "app_2026-10-19.log",
+ new MultiFileOptions
+ {
+ FormatPattern = "*$D(yyyy-MM-dd)$J(.).log",
+ MaxDayTry = 3,
+ MaxFileAgeDays = 5
+ },
+ ("app_2026-10-19.1.log", _now.AddDays(-10).UtcDateTime),
+ (previousDateFile, _now.AddDays(-1).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app_2026-10-19.log") }));
+ Assert.That(context.RequestedPaths, Does.Not.Contain(GetPath(previousDateFile)));
+ });
+ }
+
+ private static TestContext CreateContext (
+ string activeFileName,
+ MultiFileOptions options,
+ params (string FileName, DateTime? LastWriteTimeUtc)[] rolloverFiles)
+ {
+ return CreateContext(activeFileName, options, _now.UtcDateTime, rolloverFiles);
+ }
+
+ private static TestContext CreateContext (
+ string activeFileName,
+ MultiFileOptions options,
+ DateTime? activeLastWriteTimeUtc,
+ params (string FileName, DateTime? LastWriteTimeUtc)[] rolloverFiles)
+ {
+ var activeFile = CreateFileInfo(activeFileName, activeLastWriteTimeUtc);
+ var files = rolloverFiles.ToDictionary(file => GetPath(file.FileName),
+ file => CreateFileInfo(file.FileName, file.LastWriteTimeUtc));
+ List requestedPaths = [];
+
+ var fileSystem = new Mock();
+ _ = fileSystem.Setup(fs => fs.GetLogfileInfo(It.IsAny()))
+ .Returns((string path) =>
+ {
+ requestedPaths.Add(path);
+ return files.GetValueOrDefault(path)?.Object;
+ });
+
+ var registry = new Mock();
+ _ = registry.Setup(r => r.FindFileSystemForUri(It.IsAny())).Returns(fileSystem.Object);
+
+ RolloverFilenameHandler handler = new(activeFile.Object, options, new FixedTimeProvider(_now));
+ return new TestContext(handler, registry.Object, requestedPaths);
+ }
+
+ private static Mock CreateFileInfo (string fileName, DateTime? lastWriteTimeUtc)
+ {
+ var fileInfo = new Mock();
+ _ = fileInfo.SetupGet(file => file.FileName).Returns(fileName);
+ _ = fileInfo.SetupGet(file => file.DirectoryName).Returns(LOG_DIRECTORY);
+ _ = fileInfo.SetupGet(file => file.DirectorySeparatorChar).Returns('/');
+ _ = fileInfo.SetupGet(file => file.FileExists).Returns(true);
+ _ = fileInfo.SetupGet(file => file.LastWriteTimeUtc).Returns(lastWriteTimeUtc);
+ return fileInfo;
+ }
+
+ private static string GetPath (string fileName) => $"{LOG_DIRECTORY}/{fileName}";
+
+ private sealed record TestContext (
+ RolloverFilenameHandler Handler,
+ IPluginRegistry Registry,
+ List RequestedPaths);
+
+ private sealed class FixedTimeProvider (DateTimeOffset utcNow) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow () => utcNow;
+ }
+}
diff --git a/src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs b/src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs
new file mode 100644
index 000000000..a0a503774
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs
@@ -0,0 +1,107 @@
+using System.Reflection;
+
+using LogExpert.Core.Config;
+using LogExpert.Core.Entities;
+using LogExpert.Core.Interfaces;
+using LogExpert.Dialogs;
+using LogExpert.UI.Dialogs;
+
+using Moq;
+
+using NUnit.Framework;
+
+using UIStrings = LogExpert.Resources;
+
+namespace LogExpert.UI.Tests.Dialogs;
+
+[TestFixture]
+[Apartment(ApartmentState.STA)]
+public class MultiFileAgeSettingsTests
+{
+ [Test]
+ public void MultiFileMaskDialog_LoadsAndSavesBothLimits ()
+ {
+ using var dialog = new MultiFileMaskDialog("app.log")
+ {
+ FileNamePattern = "*$J(.)",
+ MaxDayGap = 3,
+ MaxFileAgeDays = 14
+ };
+
+ InvokePrivate(dialog, "OnMultiFileMaskDialogLoad");
+
+ var maxDayGap = Find(dialog, "upDownMaxDayGap");
+ var maxFileAgeDays = Find(dialog, "upDownMaxFileAgeDays");
+ Assert.Multiple(() =>
+ {
+ Assert.That(maxDayGap.Value, Is.EqualTo(3));
+ Assert.That(maxFileAgeDays.Value, Is.EqualTo(14));
+ Assert.That(Find