diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml index d3b36b2..62a97ce 100644 --- a/.github/codeql-config.yml +++ b/.github/codeql-config.yml @@ -16,3 +16,13 @@ query-filters: id: cs/path-combine paths: - src/DemaConsulting.FileAssert/PathHelpers.cs + # Exclude useless-assignment warnings in generated code under obj/ directories + - exclude: + id: cs/useless-assignment-to-local + paths: + - "**/obj/**" + # Exclude missed-ternary-operator warnings in generated code under obj/ directories + - exclude: + id: cs/missed-ternary-operator + paths: + - "**/obj/**" diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs index 0251a4b..c474b89 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs @@ -239,34 +239,45 @@ internal void Run(IContext context, IFileContainer container) { foreach (var entryPath in files) { - // Enforce size constraints when specified - if (MinSize.HasValue || MaxSize.HasValue) - { - var size = container.GetEntrySize(entryPath); - var displayPath = container.GetDisplayPath(entryPath); + RunEntryChecks(context, container, entryPath); + } + } + } - if (MinSize.HasValue && size < MinSize.Value) - { - context.WriteError( - $"File '{displayPath}' is {size} byte(s), which is less than the minimum {MinSize.Value} bytes"); - } + /// + /// Runs size and file-type assertions for a single matched entry, reporting violations. + /// + /// The context used for reporting errors. + /// The container that holds the entry. + /// The relative path of the entry within the container. + private void RunEntryChecks(IContext context, IFileContainer container, string entryPath) + { + // Enforce size constraints when specified + if (MinSize.HasValue || MaxSize.HasValue) + { + var size = container.GetEntrySize(entryPath); + var displayPath = container.GetDisplayPath(entryPath); - if (MaxSize.HasValue && size > MaxSize.Value) - { - context.WriteError( - $"File '{displayPath}' is {size} byte(s), which exceeds the maximum {MaxSize.Value} bytes"); - } - } + if (MinSize.HasValue && size < MinSize.Value) + { + context.WriteError( + $"File '{displayPath}' is {size} byte(s), which is less than the minimum {MinSize.Value} bytes"); + } - // Delegate to each file-type assert unit when declared - TextAssert?.Run(context, container, entryPath); - PdfAssert?.Run(context, container, entryPath); - XmlAssert?.Run(context, container, entryPath); - HtmlAssert?.Run(context, container, entryPath); - YamlAssert?.Run(context, container, entryPath); - JsonAssert?.Run(context, container, entryPath); - ZipAssert?.Run(context, container, entryPath); + if (MaxSize.HasValue && size > MaxSize.Value) + { + context.WriteError( + $"File '{displayPath}' is {size} byte(s), which exceeds the maximum {MaxSize.Value} bytes"); } } + + // Delegate to each file-type assert unit when declared + TextAssert?.Run(context, container, entryPath); + PdfAssert?.Run(context, container, entryPath); + XmlAssert?.Run(context, container, entryPath); + HtmlAssert?.Run(context, container, entryPath); + YamlAssert?.Run(context, container, entryPath); + JsonAssert?.Run(context, container, entryPath); + ZipAssert?.Run(context, container, entryPath); } } diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertPdfAssert.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertPdfAssert.cs index 1ee603f..113af30 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertPdfAssert.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertPdfAssert.cs @@ -24,6 +24,7 @@ using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Utilities; using UglyToad.PdfPig; +using UglyToad.PdfPig.Content; namespace DemaConsulting.FileAssert.Modeling; @@ -275,43 +276,70 @@ internal void Run(IContext context, IFileContainer container, string entryPath) using (document) { - // Apply metadata assertions to the document information - foreach (var rule in _metadata) + RunDocumentAssertions(context, displayPath, document); + } + } + + /// + /// Applies all configured metadata, page-count, and text assertions to an already-opened + /// PDF document, reporting violations via . + /// + /// The context used for reporting errors. + /// The display path of the file, used in error messages. + /// The opened PDF document to assert against. + private void RunDocumentAssertions(IContext context, string displayPath, PdfDocument document) + { + // Apply metadata assertions to the document information + foreach (var rule in _metadata) + { + var value = GetMetadataField(document, rule.Field); + rule.Apply(context, displayPath, value); + } + + // Skip page and text checks when no such constraints are configured + if (_pages == null && _text.Count == 0) + { + return; + } + + // When text rules are present, materialize pages once for both count and content. + // When only a page-count constraint is configured, enumerate without allocating Page objects. + if (_text.Count > 0) + { + var pageList = document.GetPages().ToList(); + _pages?.Apply(context, displayPath, pageList.Count); + var content = BuildPageText(pageList); + foreach (var rule in _text) { - var value = GetMetadataField(document, rule.Field); - rule.Apply(context, displayPath, value); + rule.Apply(context, displayPath, content); } + } + else + { + _pages?.Apply(context, displayPath, document.GetPages().Count()); + } + } - // Apply page count constraints and collect pages only when needed - if (_pages != null || _text.Count > 0) + /// + /// Concatenates the text from all pages into a single string, separating pages with newlines + /// so that text rules do not see words from adjacent pages merged together. + /// + /// The ordered list of pages from the PDF document. + /// A single string containing all page text joined with newline separators. + private static string BuildPageText(IReadOnlyList pages) + { + var sb = new StringBuilder(); + for (var i = 0; i < pages.Count; i++) + { + if (i > 0) { - var pageList = document.GetPages().ToList(); - _pages?.Apply(context, displayPath, pageList.Count); - - // Apply text rules to the extracted body text when rules are defined - if (_text.Count > 0) - { - var sb = new StringBuilder(); - for (var i = 0; i < pageList.Count; i++) - { - if (i > 0) - { - // Separate page text with newlines so that text rules don't - // see two adjacent words from different pages glued together. - sb.Append('\n'); - } - - sb.Append(pageList[i].Text); - } - - var content = sb.ToString(); - foreach (var rule in _text) - { - rule.Apply(context, displayPath, content); - } - } + sb.Append('\n'); } + + sb.Append(pages[i].Text); } + + return sb.ToString(); } /// diff --git a/test/DemaConsulting.FileAssert.Tests/Cli/CliTests.cs b/test/DemaConsulting.FileAssert.Tests/Cli/CliTests.cs index 70e9288..b176193 100644 --- a/test/DemaConsulting.FileAssert.Tests/Cli/CliTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Cli/CliTests.cs @@ -40,21 +40,20 @@ public void Cli_CreateContext_ParsesSilentValidateAndLogFlags() var logPath = tempDir.GetFilePath("out.log"); // Act - create a context with the silent, validate, and log flags - using (var context = Context.Create( + using var context = Context.Create( [ "--silent", "--validate", "--log", logPath - ])) - { - // Assert - all flags are reflected in the context properties - Assert.True(context.Silent); - Assert.True(context.Validate); - Assert.False(context.Version); - Assert.False(context.Help); - Assert.Equal(".fileassert.yaml", context.ConfigFile); - Assert.Equal(0, context.ExitCode); - } + ]); + + // Assert - all flags are reflected in the context properties + Assert.True(context.Silent); + Assert.True(context.Validate); + Assert.False(context.Version); + Assert.False(context.Help); + Assert.Equal(".fileassert.yaml", context.ConfigFile); + Assert.Equal(0, context.ExitCode); } @@ -137,8 +136,8 @@ public void Cli_OutputPipeline_WithoutSilentFlag_WritesMessagesToConsole() // Arrange var originalOut = Console.Out; var originalError = Console.Error; - var outWriter = new System.IO.StringWriter(); - var errorWriter = new System.IO.StringWriter(); + using var outWriter = new System.IO.StringWriter(); + using var errorWriter = new System.IO.StringWriter(); try { diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertHtmlAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertHtmlAssertTests.cs index 36d50b6..37b4714 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertHtmlAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertHtmlAssertTests.cs @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using System.Collections.ObjectModel; using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; @@ -471,7 +472,7 @@ private sealed class CapturingContext : IContext private readonly List _errors = []; /// Gets all error messages captured since this context was created. - public IReadOnlyList Errors => _errors.AsReadOnly(); + public ReadOnlyCollection Errors => _errors.AsReadOnly(); /// public void WriteLine(string message) { } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertJsonAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertJsonAssertTests.cs index 309f708..f5c9611 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertJsonAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertJsonAssertTests.cs @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using System.Collections.ObjectModel; using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; @@ -400,7 +401,7 @@ private sealed class CapturingContext : IContext private readonly List _errors = []; /// Gets all error messages captured since this context was created. - public IReadOnlyList Errors => _errors.AsReadOnly(); + public ReadOnlyCollection Errors => _errors.AsReadOnly(); /// public void WriteLine(string message) { } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertYamlAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertYamlAssertTests.cs index fd4cc89..27333f7 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertYamlAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertYamlAssertTests.cs @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using System.Collections.ObjectModel; using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; @@ -454,7 +455,7 @@ private sealed class CapturingContext : IContext private readonly List _errors = []; /// Gets all error messages captured since this context was created. - public IReadOnlyList Errors => _errors.AsReadOnly(); + public ReadOnlyCollection Errors => _errors.AsReadOnly(); /// public void WriteLine(string message) { } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs index 37c3a60..3cba266 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using System.Collections.ObjectModel; using System.IO.Compression; using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; @@ -83,8 +84,7 @@ private static void CreateZipFile(string path, IEnumerable entries) using var archive = ZipFile.Open(path, ZipArchiveMode.Create); foreach (var entry in entries) { - var archiveEntry = archive.CreateEntry(entry); - using var stream = archiveEntry.Open(); + using var stream = archive.CreateEntry(entry).Open(); // Write a single placeholder byte so the entry is not an empty-stream edge case stream.WriteByte(0x00); @@ -184,7 +184,7 @@ private sealed class CapturingContext : IContext private readonly List _errors = []; /// Gets all error messages captured since this context was created. - public IReadOnlyList Errors => _errors.AsReadOnly(); + public ReadOnlyCollection Errors => _errors.AsReadOnly(); /// public void WriteLine(string message) { } diff --git a/test/DemaConsulting.FileAssert.Tests/ProgramTests.cs b/test/DemaConsulting.FileAssert.Tests/ProgramTests.cs index 375e8ad..79e5a34 100644 --- a/test/DemaConsulting.FileAssert.Tests/ProgramTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/ProgramTests.cs @@ -203,7 +203,7 @@ public void Program_Run_ExplicitConfigMissing_WritesError() Program.Run(context); // Assert - var combined = outWriter.ToString() + errWriter.ToString(); + var combined = $"{outWriter}{errWriter}"; Assert.Contains("Configuration file not found", combined); Assert.Equal(1, context.ExitCode); }