Repository: asciidoctor/asciidoctor-maven-plugin
Pull request #1032 (2025-03-08 20:30:15)

Add failFast option to allow displaying all errors in build
Thank you for opening a pull request and contributing to asciidoctor-maven-plugin!

**What kind of change does this PR introduce?** (check at least one)
<!-- Update "[ ]" to "[x]" to check a box -->

- [ ] Bugfix
- [x] Feature
- [ ] Documentation
- [ ] Refactor
- [ ] Build improvement
- [ ] Other (please describe)


**What is the goal of this pull request?**

Allows to see all errors in all files before reporting a build failure. Previously, build failed on the first error found.

See https://github.com/asciidoctor/asciidoctor-maven-plugin/issues/1023

**Are there any alternative ways to implement this?**

The approach opts to extend the current features for minimal changes.
See the whole conversation in the linked issue for context.

**Are there any implications of this pull request? Anything a user must know?**

No.
The default behavior remains the same.

**Is it related to an existing issue?**
<!-- If Yes, please add a line of the form: `Fixes #Issue` --> 
- [x] Yes
- [ ] No

*Finally, please add a corresponding entry to CHANGELOG.adoc*



Patch:
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index 4f231ac..8f7cd0a 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -13,6 +13,9 @@ For a detailed view of what has changed, refer to the {uri-repo}/commits/main[co
 
 == Unreleased
 
+Improvements (for all modules)::
+
+ * Add failFast option to logHandler to allow displaying all errors in build (#1032)
 
 == v3.1.1 (2024-11-14)
 
diff --git a/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/CapturedLogRecord.java b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/CapturedLogRecord.java
new file mode 100644
index 0000000..ff95d5c
--- /dev/null
+++ b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/CapturedLogRecord.java
@@ -0,0 +1,67 @@
+package org.asciidoctor.maven.log;
+
+import java.io.File;
+
+import org.asciidoctor.ast.Cursor;
+import org.asciidoctor.log.LogRecord;
+
+/**
+ * {@link LogRecord} proxy that allows capturing the source file being
+ * processed.
+ * Important: the {@link #sourceFile} and the actual source where an error is present
+ * may not be the same. For example if the source is being included.
+ *
+ * @since 3.2.0
+ */
+final class CapturedLogRecord extends LogRecord {
+
+    private final File sourceFile;
+
+    CapturedLogRecord(LogRecord record, File sourceFile) {
+        super(record.getSeverity(), record.getCursor(), record.getMessage(), record.getSourceFileName(), record.getSourceMethodName());
+        this.sourceFile = sourceFile;
+    }
+
+    public Cursor getCursor() {
+        if (super.getCursor() != null) {
+            return super.getCursor();
+        }
+        if (sourceFile != null) {
+            return new FileCursor(sourceFile);
+        }
+        return null;
+    }
+
+    public File getSourceFile() {
+        return sourceFile;
+    }
+
+    class FileCursor implements Cursor {
+
+        private final File file;
+
+        public FileCursor(File file) {
+            this.file = file;
+        }
+
+        @Override
+        public int getLineNumber() {
+            return 0;
+        }
+
+        @Override
+        public String getPath() {
+            return file.getName();
+        }
+
+        @Override
+        public String getDir() {
+            return file.getParent();
+        }
+
+        @Override
+        public String getFile() {
+            return file.getAbsolutePath();
+        }
+    }
+}
diff --git a/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/LogHandler.java b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/LogHandler.java
index 6cc3e8c..1cbdbd9 100644
--- a/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/LogHandler.java
+++ b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/LogHandler.java
@@ -7,11 +7,18 @@ import static org.asciidoctor.maven.commons.StringUtils.isNotBlank;
  * POJO for Maven XML mapping.
  *
  * @author abelsromero
+ * @since 1.5.7
  */
 public class LogHandler {
 
     private Boolean outputToConsole;
     private FailIf failIf;
+    private Boolean failFast;
+
+    public LogHandler() {
+        outputToConsole = Boolean.TRUE;
+        failFast = Boolean.TRUE;
+    }
 
     public Boolean getOutputToConsole() {
         return outputToConsole;
@@ -37,4 +44,11 @@ public class LogHandler {
         return failIf != null && isNotBlank(failIf.getContainsText());
     }
 
+    public Boolean getFailFast() {
+        return failFast;
+    }
+
+    public void setFailFast(Boolean failFast) {
+        this.failFast = failFast;
+    }
 }
diff --git a/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/LogRecordsProcessors.java b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/LogRecordsProcessors.java
index 0f5b673..c0f0603 100644
--- a/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/LogRecordsProcessors.java
+++ b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/LogRecordsProcessors.java
@@ -2,6 +2,7 @@ package org.asciidoctor.maven.log;
 
 import java.io.File;
 import java.util.List;
+import java.util.Optional;
 import java.util.function.Consumer;
 
 import org.asciidoctor.log.LogRecord;
@@ -23,35 +24,27 @@ public class LogRecordsProcessors {
     }
 
     public void processLogRecords(MemoryLogHandler memoryLogHandler) throws Exception {
-        if (logHandler.isSeveritySet() && logHandler.isContainsTextNotBlank()) {
-            final Severity severity = logHandler.getFailIf().getSeverity();
-            final String textToSearch = logHandler.getFailIf().getContainsText();
+        if (logHandler.isSeveritySet() || logHandler.isContainsTextNotBlank()) {
+            final Severity severity = Optional.ofNullable(logHandler.getFailIf()).map(FailIf::getSeverity).orElse(null);
+            final String textToSearch = Optional.ofNullable(logHandler.getFailIf()).map(FailIf::getContainsText).orElse(null);
 
             final List<LogRecord> records = memoryLogHandler.filter(severity, textToSearch);
             if (records.size() > 0) {
                 for (LogRecord record : records) {
                     errorMessageConsumer.accept(LogRecordFormatter.format(record, sourceDirectory));
                 }
-                throw new Exception(String.format("Found %s issue(s) matching severity %s or higher and text '%s'", records.size(), severity, textToSearch));
+                throw new Exception(getMessage(records, severity, textToSearch));
             }
+        }
+    }
+
+    private String getMessage(List<LogRecord> records, Severity severity, String textToSearch) {
+        if (logHandler.isSeveritySet() && logHandler.isContainsTextNotBlank()) {
+            return String.format("Found %s issue(s) matching severity %s or higher and text '%s'", records.size(), severity, textToSearch);
         } else if (logHandler.isSeveritySet()) {
-            final Severity severity = logHandler.getFailIf().getSeverity();
-            final List<LogRecord> records = memoryLogHandler.filter(severity);
-            if (records.size() > 0) {
-                for (LogRecord record : records) {
-                    errorMessageConsumer.accept(LogRecordFormatter.format(record, sourceDirectory));
-                }
-                throw new Exception(String.format("Found %s issue(s) of severity %s or higher during conversion", records.size(), severity));
-            }
-        } else if (logHandler.isContainsTextNotBlank()) {
-            final String textToSearch = logHandler.getFailIf().getContainsText();
-            final List<LogRecord> records = memoryLogHandler.filter(textToSearch);
-            if (records.size() > 0) {
-                for (LogRecord record : records) {
-                    errorMessageConsumer.accept(LogRecordFormatter.format(record, sourceDirectory));
-                }
-                throw new Exception(String.format("Found %s issue(s) containing '%s'", records.size(), textToSearch));
-            }
+            return String.format("Found %s issue(s) of severity %s or higher during conversion", records.size(), severity);
+        } else {
+            return String.format("Found %s issue(s) containing '%s'", records.size(), textToSearch);
         }
     }
 
diff --git a/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/MemoryLogHandler.java b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/MemoryLogHandler.java
index 1c3d90c..c46bdcc 100644
--- a/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/MemoryLogHandler.java
+++ b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/log/MemoryLogHandler.java
@@ -1,5 +1,6 @@
 package org.asciidoctor.maven.log;
 
+import java.io.File;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.function.Consumer;
@@ -8,6 +9,7 @@ import java.util.stream.Collectors;
 import org.asciidoctor.log.LogHandler;
 import org.asciidoctor.log.LogRecord;
 import org.asciidoctor.log.Severity;
+import org.asciidoctor.maven.commons.StringUtils;
 
 
 /**
@@ -23,6 +25,14 @@ public class MemoryLogHandler implements LogHandler {
     private final Boolean outputToConsole;
     private final Consumer<LogRecord> recordConsumer;
 
+    /**
+     * Provides simple way to inject the current file being processes.
+     * Will need re-work in concurrent scenarios.
+     *
+     * @since 3.2.0
+     */
+    private File currentFile;
+
     public MemoryLogHandler(Boolean outputToConsole, Consumer<LogRecord> recordConsumer) {
         this.outputToConsole = outputToConsole == null ? Boolean.FALSE : outputToConsole;
         this.recordConsumer = recordConsumer;
@@ -30,9 +40,11 @@ public class MemoryLogHandler implements LogHandler {
 
     @Override
     public void log(LogRecord logRecord) {
-        records.add(logRecord);
+        final CapturedLogRecord record = new CapturedLogRecord(logRecord, currentFile);
+
+        records.add(record);
         if (outputToConsole)
-            recordConsumer.accept(logRecord);
+            recordConsumer.accept(record);
     }
 
     public void clear() {
@@ -46,9 +58,7 @@ public class MemoryLogHandler implements LogHandler {
      * @return list of filtered logRecords
      */
     public List<LogRecord> filter(Severity severity) {
-        return this.records.stream()
-            .filter(record -> severityIsHigher(record, severity))
-            .collect(Collectors.toList());
+        return filter(severity, null);
     }
 
     /**
@@ -58,16 +68,14 @@ public class MemoryLogHandler implements LogHandler {
      * @return list of filtered logRecords
      */
     public List<LogRecord> filter(String text) {
-        return this.records.stream()
-            .filter(record -> messageContains(record, text))
-            .collect(Collectors.toList());
+        return filter(null, text);
     }
 
     /**
      * Returns LogRecords that are equal or above the severity level and whose message contains text.
      *
-     * @param severity Asciidoctor's severity level
-     * @param text     text to search for in the LogRecords
+     * @param severity Asciidoctor's severity level (no filter applied when null)
+     * @param text     text to search for in the LogRecords (no filter applied when null)
      * @return list of filtered logRecords
      */
     public List<LogRecord> filter(Severity severity, String text) {
@@ -96,11 +104,23 @@ public class MemoryLogHandler implements LogHandler {
     }
 
     private static boolean severityIsHigher(LogRecord record, Severity severity) {
-        return record.getSeverity().ordinal() >= severity.ordinal();
+        if (severity == null) {
+            return true;
+        } else {
+            return record.getSeverity().ordinal() >= severity.ordinal();
+        }
     }
 
     private static boolean messageContains(LogRecord record, String text) {
-        return record.getMessage().contains(text);
+        if (StringUtils.isBlank(text)) {
+            return true;
+        } else {
+            return record.getMessage().contains(text);
+        }
+    }
+
+    public void setCurrentFile(File currentFile) {
+        this.currentFile = currentFile;
     }
 
 }
diff --git a/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/site/SiteLogHandlerDeserializer.java b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/site/SiteLogHandlerDeserializer.java
index a12a8bd..ca0cb75 100644
--- a/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/site/SiteLogHandlerDeserializer.java
+++ b/asciidoctor-maven-commons/src/main/java/org/asciidoctor/maven/site/SiteLogHandlerDeserializer.java
@@ -20,18 +20,15 @@ public class SiteLogHandlerDeserializer {
         if (logHandlerNode == null || !logHandlerNode.getName().equals(NODE_NAME))
             return logHandler;
 
-        logHandler.setOutputToConsole(deserializeOutputToConsole(logHandlerNode));
+        logHandler.setOutputToConsole(getBoolean(logHandlerNode, "outputToConsole"));
+        logHandler.setFailFast(getBoolean(logHandlerNode, "failFast"));
 
         deserializeFailIf(logHandlerNode.getChild("failIf"))
-                .ifPresent(logHandler::setFailIf);
+            .ifPresent(logHandler::setFailIf);
 
         return logHandler;
     }
 
-    private Boolean deserializeOutputToConsole(Xpp3Dom node) {
-        return getBoolean(node, "outputToConsole");
-    }
-
     private Optional<FailIf> deserializeFailIf(Xpp3Dom node) {
         if (node == null)
             return Optional.empty();
@@ -58,18 +55,18 @@ public class SiteLogHandlerDeserializer {
         return Optional.ofNullable(failIf);
     }
 
-    private String sanitizeString(Xpp3Dom severity) {
-        String value = severity.getValue();
-        return value == null ? "" : value.trim();
-    }
-
     private LogHandler defaultLogHandler() {
         LogHandler logHandler = new LogHandler();
         logHandler.setOutputToConsole(Boolean.TRUE);
         return logHandler;
     }
 
-    private Boolean getBoolean(Xpp3Dom node, String name) {
+    private static String sanitizeString(Xpp3Dom node) {
+        final String value = node.getValue();
+        return value == null ? "" : value.trim();
+    }
+
+    private static Boolean getBoolean(Xpp3Dom node, String name) {
         final Xpp3Dom child = node.getChild(name);
         if (child == null) {
             return Boolean.TRUE;
diff --git a/asciidoctor-maven-plugin/src/main/java/org/asciidoctor/maven/AsciidoctorMojo.java b/asciidoctor-maven-plugin/src/main/java/org/asciidoctor/maven/AsciidoctorMojo.java
index ef48d04..34ad57d 100644
--- a/asciidoctor-maven-plugin/src/main/java/org/asciidoctor/maven/AsciidoctorMojo.java
+++ b/asciidoctor-maven-plugin/src/main/java/org/asciidoctor/maven/AsciidoctorMojo.java
@@ -13,6 +13,8 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.logging.Logger;
 
+import static org.asciidoctor.maven.process.SourceDirectoryFinder.DEFAULT_SOURCE_DIR;
+
 import org.apache.commons.io.FilenameUtils;
 import org.apache.maven.plugin.AbstractMojo;
 import org.apache.maven.plugin.MojoExecutionException;
@@ -35,8 +37,6 @@ import org.asciidoctor.maven.process.ResourcesProcessor;
 import org.asciidoctor.maven.process.SourceDirectoryFinder;
 import org.asciidoctor.maven.process.SourceDocumentFinder;
 
-import static org.asciidoctor.maven.process.SourceDirectoryFinder.DEFAULT_SOURCE_DIR;
-
 
 /**
  * Basic maven plugin goal to convert AsciiDoc files using AsciidoctorJ.
@@ -234,15 +234,15 @@ public class AsciidoctorMojo extends AbstractMojo {
         resourcesProcessor.process(sourceDir, outputDirectory, this);
 
         // register LogHandler to capture asciidoctor messages
-        final Boolean outputToConsole = logHandler.getOutputToConsole() == null ? Boolean.TRUE : logHandler.getOutputToConsole();
-        final MemoryLogHandler memoryLogHandler = new MemoryLogHandler(outputToConsole,
+        final MemoryLogHandler memoryLogHandler = new MemoryLogHandler(logHandler.getOutputToConsole(),
             logRecord -> getLog().info(LogRecordFormatter.format(logRecord, sourceDir)));
         asciidoctor.registerLogHandler(memoryLogHandler);
         // disable default console output of AsciidoctorJ
         Logger.getLogger("asciidoctor").setUseParentHandlers(false);
 
         final Set<File> uniquePaths = new HashSet<>();
-        for (final File source : sourceFiles) {
+        for (int i = 0; i < sourceFiles.size(); i++) {
+            final File source = sourceFiles.get(i);
             final Destination destination = setDestinationPaths(source, optionsBuilder, sourceDir, this);
             final File destinationPath = destination.path;
             if (!uniquePaths.add(destinationPath)) {
@@ -254,15 +254,18 @@ public class AsciidoctorMojo extends AbstractMojo {
                 getLog().warn("Duplicated destination found: overwriting file: " + destinationFile);
             }
 
-            convertFile(asciidoctor, optionsBuilder.build(), source);
+            boolean processLogRecords = logHandler.getFailFast() || (i == (sourceFiles.size() - 1));
+            convertFile(asciidoctor, optionsBuilder.build(), source, sourceDir, memoryLogHandler, processLogRecords);
+        }
+    }
 
-            try {
-                // process log messages according to mojo configuration
-                new LogRecordsProcessors(logHandler, sourceDir, errorMessage -> getLog().error(errorMessage))
-                    .processLogRecords(memoryLogHandler);
-            } catch (Exception exception) {
-                throw new MojoExecutionException(exception.getMessage());
-            }
+    private void processLogRecords(File sourceDir, MemoryLogHandler memoryLogHandler) throws MojoExecutionException {
+        try {
+            // process log messages according to mojo configuration
+            new LogRecordsProcessors(logHandler, sourceDir, errorMessage -> getLog().error(errorMessage))
+                .processLogRecords(memoryLogHandler);
+        } catch (Exception exception) {
+            throw new MojoExecutionException(exception.getMessage());
         }
     }
 
@@ -350,9 +353,13 @@ public class AsciidoctorMojo extends AbstractMojo {
             finder.find(sourceDirectoryPath, sourceDocumentExtensions);
     }
 
-    protected void convertFile(Asciidoctor asciidoctor, Options options, File f) {
+    private void convertFile(Asciidoctor asciidoctor, Options options, File f, File sourceDir, MemoryLogHandler memoryLogHandler, boolean processLogRecords) throws MojoExecutionException {
+        memoryLogHandler.setCurrentFile(f);
         asciidoctor.convertFile(f, options);
         logConvertedFile(f);
+        if (processLogRecords) {
+            processLogRecords(sourceDir, memoryLogHandler);
+        }
     }
 
     protected void logConvertedFile(File f) {
diff --git a/asciidoctor-maven-plugin/src/main/java/org/asciidoctor/maven/process/SourceDocumentFinder.java b/asciidoctor-maven-plugin/src/main/java/org/asciidoctor/maven/process/SourceDocumentFinder.java
index 62bc071..4856c3f 100644
--- a/asciidoctor-maven-plugin/src/main/java/org/asciidoctor/maven/process/SourceDocumentFinder.java
+++ b/asciidoctor-maven-plugin/src/main/java/org/asciidoctor/maven/process/SourceDocumentFinder.java
@@ -79,6 +79,7 @@ public class SourceDocumentFinder {
                         return true;
                     })
                     .map(Path::toFile)
+                    .sorted()
                     .collect(Collectors.toList());
         } catch (IOException e) {
             return Collections.emptyList();
diff --git a/docs/modules/plugin/partials/process-asciidoc-mojo-parameters.adoc b/docs/modules/plugin/partials/process-asciidoc-mojo-parameters.adoc
index f0e6bbc..0d5fcf3 100644
--- a/docs/modules/plugin/partials/process-asciidoc-mojo-parameters.adoc
+++ b/docs/modules/plugin/partials/process-asciidoc-mojo-parameters.adoc
@@ -205,6 +205,9 @@ Options are:
 
 * `outputToConsole`: `Boolean`, defaults to `true`.
 Redirects all Asciidoctor messages to Maven's console logger as INFO during conversion.
+* `failFast`: `Boolean`, defaults to `true`.
+Fail the build on the first source with errors found (`true`), or after attempting to convert all sources (`false`).
+When `false`, errors (matching the conditions in `failIf`) from all sources will be shown.
 * `failIf`: build fail conditions, disabled by default.
 Allows setting one or many conditions that when met, abort the Maven build with `BUILD FAILURE` status.
 +


Test patch:
diff --git a/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/CapturedLogRecordTest.java b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/CapturedLogRecordTest.java
new file mode 100644
index 0000000..7d30bfc
--- /dev/null
+++ b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/CapturedLogRecordTest.java
@@ -0,0 +1,60 @@
+package org.asciidoctor.maven.log;
+
+import java.io.File;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.asciidoctor.ast.Cursor;
+import org.asciidoctor.log.LogRecord;
+import org.asciidoctor.log.Severity;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class CapturedLogRecordTest {
+
+    @Nested
+    class WhenLogRecordsContainsCursor {
+
+        @Test
+        void shouldReturnCursorFile() {
+            final var cursorFile = new File("uno", "dos");
+            final var recordFile = new File("tres", "quatre");
+            final var cursor = new TestCursor(cursorFile.getAbsolutePath(), 0, null, null);
+
+            final var logRecord = new CapturedLogRecord(getLogRecord(cursor), recordFile);
+            final var capturedLogRecord = new CapturedLogRecord(logRecord, cursorFile);
+
+            assertThat(capturedLogRecord.getCursor().getFile()).isEqualTo(cursorFile.getAbsolutePath());
+        }
+
+    }
+
+    @Nested
+    class WhenLogRecordsDoesNotContainsCursor {
+
+        @Test
+        void shouldReturnLogRecordFileWhenCursorFileIsSet() {
+            final var cursorFile = new File("uno", "dos");
+            final var recordFile = new File("tres", "quatre");
+
+            final var logRecord = new CapturedLogRecord(getLogRecord(null), recordFile);
+            final var capturedLogRecord = new CapturedLogRecord(logRecord, cursorFile);
+
+            assertThat(capturedLogRecord.getCursor().getFile()).isEqualTo(recordFile.getAbsolutePath());
+        }
+
+        @Test
+        void shouldReturnNullWhenCursorFileIsNoSet() {
+            final var logRecord = new CapturedLogRecord(getLogRecord(null), null);
+            final var capturedLogRecord = new CapturedLogRecord(logRecord, null);
+
+            assertThat(capturedLogRecord.getCursor()).isNull();
+        }
+
+    }
+
+    private LogRecord getLogRecord(Cursor cursor) {
+        return new LogRecord(Severity.INFO, cursor, "a message");
+    }
+
+}
diff --git a/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/LogRecordFormatterTest.java b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/LogRecordFormatterTest.java
index a0ef736..93f9410 100644
--- a/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/LogRecordFormatterTest.java
+++ b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/LogRecordFormatterTest.java
@@ -3,15 +3,15 @@ package org.asciidoctor.maven.log;
 import java.io.File;
 import java.io.IOException;
 
+import static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+
 import org.asciidoctor.ast.Cursor;
 import org.asciidoctor.log.LogRecord;
 import org.asciidoctor.log.Severity;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
-import static java.lang.String.format;
-import static org.assertj.core.api.Assertions.assertThat;
-
 class LogRecordFormatterTest {
 
     private static final String PROJECT_NAME = "asciidoctor-maven-commons";
@@ -117,38 +117,4 @@ class LogRecordFormatterTest {
         return formattedLogRecord.replaceAll("\\\\", "/");
     }
 
-    class TestCursor implements Cursor {
-
-        private final int lineNumber;
-        private final String file;
-        private final String path;
-        private final String dir;
-
-        TestCursor(String file, int lineNumber, String path, String dir) {
-            this.file = file;
-            this.lineNumber = lineNumber;
-            this.path = path;
-            this.dir = dir;
-        }
-
-        @Override
-        public int getLineNumber() {
-            return lineNumber;
-        }
-
-        @Override
-        public String getPath() {
-            return path;
-        }
-
-        @Override
-        public String getDir() {
-            return dir;
-        }
-
-        @Override
-        public String getFile() {
-            return file;
-        }
-    }
 }
diff --git a/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/LogRecordsProcessorsTest.java b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/LogRecordsProcessorsTest.java
new file mode 100644
index 0000000..490d4dd
--- /dev/null
+++ b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/LogRecordsProcessorsTest.java
@@ -0,0 +1,259 @@
+package org.asciidoctor.maven.log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.asciidoctor.log.Severity.*;
+import static org.asciidoctor.maven.log.TestLogRecords.*;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class LogRecordsProcessorsTest {
+
+    @Test
+    void should_not_fail_with_empty_data() {
+        final var config = new org.asciidoctor.maven.log.LogHandler();
+        final var recordsProcessor = new LogRecordsProcessors(config, null, null);
+
+        final var logHandler = new MemoryLogHandler(null, null);
+        Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+        assertThat(throwable).isNull();
+    }
+
+    @Nested
+    class Severity {
+
+        @Test
+        void should_report_messages_when_severity_is_error() {
+            final var config = logHandlerConfig(ERROR, null);
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages)
+                .containsExactlyInAnyOrder(
+                    "asciidoctor: ERROR: error message 1",
+                    "asciidoctor: ERROR: error message 2",
+                    "asciidoctor: ERROR: error message 3");
+            assertThat(throwable)
+                .hasMessage("Found 3 issue(s) of severity ERROR or higher during conversion");
+        }
+
+        @Test
+        void should_report_messages_when_severity_is_info() {
+            final var config = logHandlerConfig(INFO, null);
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages)
+                .containsExactlyInAnyOrder(
+                    "asciidoctor: ERROR: error message 1",
+                    "asciidoctor: ERROR: error message 2",
+                    "asciidoctor: ERROR: error message 3",
+                    "asciidoctor: INFO: info message 1",
+                    "asciidoctor: INFO: info message 2",
+                    "asciidoctor: INFO: info message 3",
+                    "asciidoctor: WARN: warning message 1",
+                    "asciidoctor: WARN: warning message 2",
+                    "asciidoctor: WARN: warning message 3");
+            assertThat(throwable)
+                .hasMessage("Found 9 issue(s) of severity INFO or higher during conversion");
+        }
+
+        @Test
+        void should_report_no_messages_when_severity_is_fatal() {
+            final var config = logHandlerConfig(FATAL, null);
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages).isEmpty();
+            assertThat(throwable).isNull();
+        }
+    }
+
+    @Nested
+    class Text {
+
+        @Test
+        void should_report_messages_when_text_contains_error() {
+            final var config = logHandlerConfig(null, "error");
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages)
+                .containsExactlyInAnyOrder(
+                    "asciidoctor: ERROR: error message 1",
+                    "asciidoctor: ERROR: error message 2",
+                    "asciidoctor: ERROR: error message 3");
+            assertThat(throwable)
+                .hasMessage("Found 3 issue(s) containing 'error'");
+        }
+
+        @Test
+        void should_report_messages_when_text_contains_info() {
+            final var config = logHandlerConfig(null, "info");
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages)
+                .containsExactlyInAnyOrder(
+                    "asciidoctor: INFO: info message 1",
+                    "asciidoctor: INFO: info message 2",
+                    "asciidoctor: INFO: info message 3");
+            assertThat(throwable)
+                .hasMessage("Found 3 issue(s) containing 'info'");
+        }
+
+        @Test
+        void should_report_messages_when_text_contains_warn() {
+            final var config = logHandlerConfig(null, "warn");
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages)
+                .containsExactlyInAnyOrder(
+                    "asciidoctor: WARN: warning message 1",
+                    "asciidoctor: WARN: warning message 2",
+                    "asciidoctor: WARN: warning message 3");
+            assertThat(throwable)
+                .hasMessage("Found 3 issue(s) containing 'warn'");
+        }
+
+        @Test
+        void should_report_messages_when_text_does_not_match() {
+            final var config = logHandlerConfig(null, "not present");
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages).isEmpty();
+            assertThat(throwable).isNull();
+        }
+    }
+
+    @Nested
+    class SeverityAndText {
+
+        @Test
+        void should_report_messages_when_severity_and_text_contains_error() {
+            final var config = logHandlerConfig(ERROR, "error");
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages)
+                .containsExactlyInAnyOrder(
+                    "asciidoctor: ERROR: error message 1",
+                    "asciidoctor: ERROR: error message 2",
+                    "asciidoctor: ERROR: error message 3");
+            assertThat(throwable)
+                .hasMessage("Found 3 issue(s) matching severity ERROR or higher and text 'error'");
+        }
+
+        @Test
+        void should_report_messages_when_severity_and_text_contains_info() {
+            final var config = logHandlerConfig(INFO, "info");
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages)
+                .containsExactlyInAnyOrder(
+                    "asciidoctor: INFO: info message 1",
+                    "asciidoctor: INFO: info message 2",
+                    "asciidoctor: INFO: info message 3");
+            assertThat(throwable)
+                .hasMessage("Found 3 issue(s) matching severity INFO or higher and text 'info'");
+        }
+
+        @Test
+        void should_report_messages_when_severity_and_text_contains_warn() {
+            final var config = logHandlerConfig(WARN, "warn");
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages)
+                .containsExactlyInAnyOrder(
+                    "asciidoctor: WARN: warning message 1",
+                    "asciidoctor: WARN: warning message 2",
+                    "asciidoctor: WARN: warning message 3");
+            assertThat(throwable)
+                .hasMessage("Found 3 issue(s) matching severity WARN or higher and text 'warn'");
+        }
+
+
+        @Test
+        void should_report_messages_when_text_return_none() {
+            final var config = logHandlerConfig(FATAL, "not present");
+            final List<String> messages = new ArrayList();
+
+            final var recordsProcessor = new LogRecordsProcessors(config, null, messages::add);
+
+            final var logHandler = testMemoryLogHandler();
+            Throwable throwable = catchThrowable(() -> recordsProcessor.processLogRecords(logHandler));
+
+            assertThat(messages).isEmpty();
+            assertThat(throwable).isNull();
+        }
+    }
+
+    private static LogHandler logHandlerConfig(org.asciidoctor.log.Severity severity, String text) {
+        final var config = new LogHandler();
+        FailIf failIf = new FailIf();
+        failIf.setSeverity(severity);
+        failIf.setContainsText(text);
+        config.setFailIf(failIf);
+        return config;
+    }
+
+    private static MemoryLogHandler testMemoryLogHandler() {
+        final var memoryLogHandler = new MemoryLogHandler(null, null);
+        for (int i = 1; i < 4; i++) {
+            memoryLogHandler.log(errorMessage(i));
+            memoryLogHandler.log(getInfoMessage(i));
+            memoryLogHandler.log(warningMessage(i));
+        }
+        return memoryLogHandler;
+    }
+
+}
diff --git a/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/MemoryLogHandlerTest.java b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/MemoryLogHandlerTest.java
new file mode 100644
index 0000000..10c58b5
--- /dev/null
+++ b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/MemoryLogHandlerTest.java
@@ -0,0 +1,166 @@
+package org.asciidoctor.maven.log;
+
+import java.util.List;
+
+import static org.asciidoctor.log.Severity.*;
+import static org.asciidoctor.maven.log.TestLogRecords.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.asciidoctor.log.LogRecord;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class MemoryLogHandlerTest {
+
+    @Test
+    void should_not_fail_when_filtering_without_records() {
+        final var memoryLogHandler = new MemoryLogHandler(null, null);
+
+        assertThat(memoryLogHandler.filter(ERROR)).hasSize(0);
+        assertThat(memoryLogHandler.filter("any text")).hasSize(0);
+        assertThat(memoryLogHandler.filter(INFO, "any text")).hasSize(0);
+
+        assertThat(memoryLogHandler.filter((org.asciidoctor.log.Severity) null)).hasSize(0);
+        assertThat(memoryLogHandler.filter((String) null)).hasSize(0);
+        assertThat(memoryLogHandler.filter(null, null)).hasSize(0);
+    }
+
+    @Test
+    void should_return_all_by_default() {
+        final var memoryLogHandler = testMemoryLogHandler();
+
+        assertThat(memoryLogHandler.filter((org.asciidoctor.log.Severity) null)).hasSize(3);
+        assertThat(memoryLogHandler.filter((String) null)).hasSize(3);
+        assertThat(memoryLogHandler.filter(null, null)).hasSize(3);
+    }
+
+    @Test
+    void should_return_non_empty() {
+        final var memoryLogHandler = new MemoryLogHandler(null, null);
+        assertThat(memoryLogHandler.isEmpty()).isTrue();
+    }
+
+    @Test
+    void should_return_empty() {
+        final var memoryLogHandler = testMemoryLogHandler();
+        assertThat(memoryLogHandler.isEmpty()).isFalse();
+    }
+
+    @Nested
+    class Severity {
+
+        @Test
+        void should_filter_by_error_severity() {
+            final var memoryLogHandler = testMemoryLogHandler();
+
+            List<LogRecord> filter = memoryLogHandler.filter(ERROR);
+
+            assertThat(filter)
+                .usingRecursiveFieldByFieldElementComparator()
+                .contains(errorMessage())
+                .hasSize(1);
+        }
+
+        @Test
+        void should_filter_by_info_severity() {
+            final var memoryLogHandler = testMemoryLogHandler();
+
+            List<LogRecord> filter = memoryLogHandler.filter(INFO);
+
+            assertThat(filter)
+                .usingRecursiveFieldByFieldElementComparator()
+                .contains(errorMessage())
+                .contains(getInfoMessage())
+                .contains(warningMessage())
+                .hasSize(3);
+        }
+
+        @Test
+        void should_filter_by_warn_severity() {
+            final var memoryLogHandler = testMemoryLogHandler();
+
+            List<LogRecord> filter = memoryLogHandler.filter(WARN);
+
+            assertThat(filter)
+                .usingRecursiveFieldByFieldElementComparator()
+                .contains(errorMessage())
+                .contains(warningMessage())
+                .hasSize(2);
+        }
+    }
+
+    @Nested
+    class Text {
+
+        @Test
+        void should_filter_by_text_and_return_none() {
+            final var memoryLogHandler = testMemoryLogHandler();
+
+            List<LogRecord> filter = memoryLogHandler.filter("nothing");
+
+            assertThat(filter).hasSize(0);
+        }
+
+        @Test
+        void should_filter_by_text_and_return_some_record() {
+            final var memoryLogHandler = testMemoryLogHandler();
+
+            List<LogRecord> filter = memoryLogHandler.filter("error");
+
+            assertThat(filter)
+                .usingRecursiveFieldByFieldElementComparator()
+                .contains(errorMessage())
+                .hasSize(1);
+        }
+
+        @Test
+        void should_filter_by_text_and_return_all_records() {
+            final var memoryLogHandler = testMemoryLogHandler();
+
+            List<LogRecord> filter = memoryLogHandler.filter("message");
+
+            assertThat(filter)
+                .usingRecursiveFieldByFieldElementComparator()
+                .contains(errorMessage())
+                .contains(getInfoMessage())
+                .contains(warningMessage())
+                .hasSize(3);
+        }
+    }
+
+    @Nested
+    class SeverityAndText {
+
+        @Test
+        void should_filter_by_severity_and_test_and_return_none() {
+            final var memoryLogHandler = testMemoryLogHandler();
+
+            List<LogRecord> filter = memoryLogHandler.filter(INFO, "nothing");
+
+            assertThat(filter).hasSize(0);
+        }
+
+        @Test
+        void should_filter_by_severity_and_test_and_return_all() {
+            final var memoryLogHandler = testMemoryLogHandler();
+
+            List<LogRecord> filter = memoryLogHandler.filter(INFO, "message");
+
+            assertThat(filter)
+                .usingRecursiveFieldByFieldElementComparator()
+                .contains(errorMessage())
+                .contains(getInfoMessage())
+                .contains(warningMessage())
+                .hasSize(3);
+        }
+    }
+
+    private static MemoryLogHandler testMemoryLogHandler() {
+        final var memoryLogHandler = new MemoryLogHandler(null, null);
+        memoryLogHandler.log(errorMessage());
+        memoryLogHandler.log(getInfoMessage());
+        memoryLogHandler.log(warningMessage());
+        return memoryLogHandler;
+    }
+
+}
diff --git a/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/TestCursor.java b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/TestCursor.java
new file mode 100644
index 0000000..eaf96e0
--- /dev/null
+++ b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/TestCursor.java
@@ -0,0 +1,38 @@
+package org.asciidoctor.maven.log;
+
+import org.asciidoctor.ast.Cursor;
+
+class TestCursor implements Cursor {
+
+    private final int lineNumber;
+    private final String file;
+    private final String path;
+    private final String dir;
+
+    TestCursor(String file, int lineNumber, String path, String dir) {
+        this.file = file;
+        this.lineNumber = lineNumber;
+        this.path = path;
+        this.dir = dir;
+    }
+
+    @Override
+    public int getLineNumber() {
+        return lineNumber;
+    }
+
+    @Override
+    public String getPath() {
+        return path;
+    }
+
+    @Override
+    public String getDir() {
+        return dir;
+    }
+
+    @Override
+    public String getFile() {
+        return file;
+    }
+}
diff --git a/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/TestLogRecords.java b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/TestLogRecords.java
new file mode 100644
index 0000000..26dc8ce
--- /dev/null
+++ b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/log/TestLogRecords.java
@@ -0,0 +1,43 @@
+package org.asciidoctor.maven.log;
+
+import java.util.Optional;
+
+import static org.asciidoctor.log.Severity.*;
+
+import org.asciidoctor.log.LogRecord;
+
+class TestLogRecords {
+
+    static LogRecord errorMessage() {
+        return errorMessage(null);
+    }
+
+    static LogRecord errorMessage(Integer index) {
+        LogRecord logRecord = new LogRecord(ERROR, buildMessage("error", index));
+        return new CapturedLogRecord(logRecord, null);
+    }
+
+    static LogRecord getInfoMessage() {
+        return getInfoMessage(null);
+    }
+
+    static LogRecord getInfoMessage(Integer index) {
+        LogRecord logRecord = new LogRecord(INFO, buildMessage("info", index));
+        return new CapturedLogRecord(logRecord, null);
+    }
+
+    static LogRecord warningMessage() {
+        return warningMessage(null);
+    }
+
+    static LogRecord warningMessage(Integer index) {
+        LogRecord logRecord = new LogRecord(WARN, buildMessage("warning", index));
+        return new CapturedLogRecord(logRecord, null);
+    }
+
+    private static String buildMessage(String type, Integer index) {
+        return Optional.ofNullable(index)
+            .map(i -> type + " message " + index)
+            .orElse(type + " message");
+    }
+}
diff --git a/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/site/SiteLogHandlerDeserializerTest.java b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/site/SiteLogHandlerDeserializerTest.java
index 6c135ad..5f2d049 100644
--- a/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/site/SiteLogHandlerDeserializerTest.java
+++ b/asciidoctor-maven-commons/src/test/java/org/asciidoctor/maven/site/SiteLogHandlerDeserializerTest.java
@@ -1,13 +1,17 @@
 package org.asciidoctor.maven.site;
 
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
 import org.asciidoctor.log.Severity;
 import org.asciidoctor.maven.log.FailIf;
 import org.asciidoctor.maven.log.LogHandler;
 import org.codehaus.plexus.util.xml.Xpp3Dom;
 import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.catchThrowable;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 class SiteLogHandlerDeserializerTest {
 
@@ -39,17 +43,33 @@ class SiteLogHandlerDeserializerTest {
     }
 
     @Test
-    void should_deserialize_valid_outputToConsole() {
+    void should_deserialize_empty_outputToConsole() {
+        // given
+        final Xpp3Dom logHandlerConfig = Xpp3DoomBuilder.logHandler()
+            .addChild("outputToConsole")
+            .build();
+        // when
+        LogHandler logHandler = new SiteLogHandlerDeserializer()
+            .deserialize(logHandlerConfig);
+        // then
+        assertThat(logHandler)
+            .usingRecursiveComparison()
+            .isEqualTo(defaultLogHandler());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    void should_deserialize_valid_outputToConsole(Boolean actual) {
         // given
         final Xpp3Dom logHandlerConfig = Xpp3DoomBuilder.logHandler()
-                .addChild("outputToConsole", "false")
+                .addChild("outputToConsole", actual.toString())
                 .build();
         // when
         LogHandler logHandler = new SiteLogHandlerDeserializer()
                 .deserialize(logHandlerConfig);
         // then
         LogHandler expected = new LogHandler();
-        expected.setOutputToConsole(Boolean.FALSE);
+        expected.setOutputToConsole(actual);
         assertThat(logHandler)
                 .usingRecursiveComparison()
                 .isEqualTo(expected);
@@ -66,7 +86,7 @@ class SiteLogHandlerDeserializerTest {
                 .deserialize(logHandlerConfig);
         // then: set false as default (same as Maven does for a normal Mojo)
         LogHandler expected = new LogHandler();
-        expected.setOutputToConsole(Boolean.FALSE);
+        expected.setOutputToConsole(FALSE);
         assertThat(logHandler)
                 .usingRecursiveComparison()
                 .isEqualTo(expected);
@@ -201,9 +221,54 @@ class SiteLogHandlerDeserializerTest {
                 .isEqualTo(expected);
     }
 
-    private LogHandler defaultLogHandler() {
+    @Test
+    void should_deserialize_empty_failFast() {
+        final Xpp3Dom logHandlerConfig = Xpp3DoomBuilder.logHandler()
+            .addChild("failFast")
+            .build();
+
+        LogHandler logHandler = new SiteLogHandlerDeserializer()
+            .deserialize(logHandlerConfig);
+
+        assertThat(logHandler)
+            .usingRecursiveComparison()
+            .isEqualTo(defaultLogHandler());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    void should_deserialize_failFast(Boolean actual) {
+        final Xpp3Dom logHandlerConfig = Xpp3DoomBuilder.logHandler()
+            .addChild("failFast", actual.toString())
+            .build();
+
+        LogHandler logHandler = new SiteLogHandlerDeserializer()
+            .deserialize(logHandlerConfig);
+
+        LogHandler expected = defaultLogHandler();
+        expected.setFailFast(actual);
+        assertThat(logHandler)
+            .usingRecursiveComparison()
+            .isEqualTo(expected);
+    }
+
+    @Test
+    void should_deserialize_invalid_failFast() {
+        final Xpp3Dom logHandlerConfig = Xpp3DoomBuilder.logHandler()
+            .addChild("failFast", "not-a-boolean")
+            .build();
+
+        LogHandler logHandler = new SiteLogHandlerDeserializer()
+            .deserialize(logHandlerConfig);
+        // set false as default (same as Maven does for a normal Mojo)
         LogHandler expected = new LogHandler();
-        expected.setOutputToConsole(Boolean.TRUE);
-        return expected;
+        expected.setFailFast(FALSE);
+        assertThat(logHandler)
+            .usingRecursiveComparison()
+            .isEqualTo(expected);
+    }
+
+    private LogHandler defaultLogHandler() {
+        return new LogHandler();
     }
 }
diff --git a/asciidoctor-maven-plugin/src/test/java/org/asciidoctor/maven/AsciidoctorMojoLogHandlerTest.java b/asciidoctor-maven-plugin/src/test/java/org/asciidoctor/maven/AsciidoctorMojoLogHandlerTest.java
index 6a9c3ae..ba358bb 100644
--- a/asciidoctor-maven-plugin/src/test/java/org/asciidoctor/maven/AsciidoctorMojoLogHandlerTest.java
+++ b/asciidoctor-maven-plugin/src/test/java/org/asciidoctor/maven/AsciidoctorMojoLogHandlerTest.java
@@ -6,21 +6,22 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
+import static org.asciidoctor.log.Severity.*;
+import static org.asciidoctor.maven.io.TestFilesHelper.newOutputTestDirectory;
+import static org.asciidoctor.maven.test.TestUtils.mockAsciidoctorMojo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
 import org.apache.maven.plugin.MojoExecutionException;
 import org.apache.maven.plugin.MojoFailureException;
+import org.asciidoctor.log.Severity;
 import org.asciidoctor.maven.io.ConsoleHolder;
 import org.asciidoctor.maven.log.FailIf;
 import org.asciidoctor.maven.log.LogHandler;
 import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 
-import static org.asciidoctor.log.Severity.ERROR;
-import static org.asciidoctor.log.Severity.WARN;
-import static org.asciidoctor.maven.io.TestFilesHelper.newOutputTestDirectory;
-import static org.asciidoctor.maven.test.TestUtils.mockAsciidoctorMojo;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.catchThrowable;
-
 
 class AsciidoctorMojoLogHandlerTest {
 
@@ -211,9 +212,7 @@ class AsciidoctorMojoLogHandlerTest {
         File srcDir = new File(DEFAULT_SOURCE_DIRECTORY);
         File outputDir = newOutputTestDirectory("logHandler");
         final LogHandler logHandler = new LogHandler();
-        FailIf failIf = new FailIf();
-        failIf.setSeverity(WARN);
-        logHandler.setFailIf(failIf);
+        logHandler.setFailIf(failIf(WARN));
 
         // when
         AsciidoctorMojo mojo = mockAsciidoctorMojo(logHandler);
@@ -237,9 +236,7 @@ class AsciidoctorMojoLogHandlerTest {
         File srcDir = new File(DEFAULT_SOURCE_DIRECTORY);
         File outputDir = newOutputTestDirectory("logHandler");
         final LogHandler logHandler = new LogHandler();
-        FailIf failIf = new FailIf();
-        failIf.setSeverity(ERROR);
-        logHandler.setFailIf(failIf);
+        logHandler.setFailIf(failIf(ERROR));
 
         // when
         AsciidoctorMojo mojo = mockAsciidoctorMojo(logHandler);
@@ -393,6 +390,111 @@ class AsciidoctorMojoLogHandlerTest {
         consoleHolder.release();
     }
 
+    @Nested
+    class WithFailOn {
+
+        @Test
+        void should_fail_and_print_messages_for_all_sources_when_failFast_is_false() {
+            // setup
+            final ConsoleHolder consoleHolder = ConsoleHolder.start();
+
+            File srcDir = new File(DEFAULT_SOURCE_DIRECTORY, "errors");
+            File outputDir = newOutputTestDirectory("logHandler");
+            LogHandler logHandler = new LogHandler();
+            logHandler.setOutputToConsole(false);
+            logHandler.setFailFast(false);
+            logHandler.setFailIf(failIf(DEBUG));
+
+            // when
+            AsciidoctorMojo mojo = mockAsciidoctorMojo(logHandler);
+            mojo.backend = "html";
+            mojo.sourceDirectory = srcDir;
+            mojo.outputDirectory = outputDir;
+            Throwable throwable = catchThrowable(mojo::execute);
+
+            // then
+            assertThat(throwable)
+                .isInstanceOf(MojoExecutionException.class)
+                .hasMessageContaining("Found 6 issue(s) of severity DEBUG or higher during conversion");
+
+            long convertedFiles = Arrays.stream(consoleHolder.getOutput().split("\n"))
+                .filter(line -> line.contains("Converted"))
+                .count();
+            assertThat(convertedFiles).isEqualTo(3);
+
+            List<String> errorMessages = Arrays.stream(consoleHolder.getError().split("\n"))
+                .filter(line -> line.contains("asciidoctor:"))
+                .collect(Collectors.toList());
+
+            assertThat(errorMessages)
+                .hasSize(6);
+            assertThat(errorMessages.get(0))
+                .contains(fixOsSeparator("[error] asciidoctor: INFO: document-with-invalid-reference.adoc: possible invalid reference:"));
+            assertThat(errorMessages.get(1))
+                .contains(fixOsSeparator("[error] asciidoctor: INFO: document-with-invalid-reference.adoc: possible invalid reference: section-id"));
+            assertThat(errorMessages.get(2))
+                .contains(fixOsSeparator("[error] asciidoctor: ERROR: document-with-missing-include.adoc: line 3: include file not found:"));
+            assertThat(errorMessages.get(3))
+                .contains(fixOsSeparator("[error] asciidoctor: ERROR: document-with-missing-include.adoc: line 5: include file not found:"));
+            assertThat(errorMessages.get(4))
+                .contains(fixOsSeparator("[error] asciidoctor: ERROR: document-with-missing-include.adoc: line 9: include file not found:"));
+            assertThat(errorMessages.get(5))
+                .contains(fixOsSeparator("[error] asciidoctor: WARN: document-with-missing-include.adoc: line 25: no callout found for <1>"));
+            // cleanup
+            consoleHolder.release();
+        }
+
+        @Test
+        void should_fail_and_print_messages_for_all_sources_when_failFast_is_true() {
+            // setup
+            final ConsoleHolder consoleHolder = ConsoleHolder.start();
+
+            File srcDir = new File(DEFAULT_SOURCE_DIRECTORY, "errors");
+            File outputDir = newOutputTestDirectory("logHandler");
+            LogHandler logHandler = new LogHandler();
+            logHandler.setOutputToConsole(false);
+            logHandler.setFailFast(true);
+            logHandler.setFailIf(failIf(DEBUG));
+
+            // when
+            AsciidoctorMojo mojo = mockAsciidoctorMojo(logHandler);
+            mojo.backend = "html";
+            mojo.sourceDirectory = srcDir;
+            mojo.outputDirectory = outputDir;
+            Throwable throwable = catchThrowable(mojo::execute);
+
+            // then
+            assertThat(throwable)
+                .isInstanceOf(MojoExecutionException.class)
+                .hasMessageContaining("Found 2 issue(s) of severity DEBUG or higher during conversion");
+
+            long convertedFiles = Arrays.stream(consoleHolder.getOutput().split("\n"))
+                .filter(line -> line.contains("Converted"))
+                .count();
+            assertThat(convertedFiles).isEqualTo(1);
+
+            List<String> asciidoctorMessages = Arrays.stream(consoleHolder.getError().split("\n"))
+                .filter(line -> line.contains("asciidoctor:"))
+                .collect(Collectors.toList());
+
+            assertThat(asciidoctorMessages)
+                .hasSize(2);
+            assertThat(asciidoctorMessages.get(0))
+                .contains(fixOsSeparator("[error] asciidoctor: INFO: document-with-invalid-reference.adoc: possible invalid reference:"));
+            assertThat(asciidoctorMessages.get(1))
+                .contains(fixOsSeparator("[error] asciidoctor: INFO: document-with-invalid-reference.adoc: possible invalid reference: section-id"));
+            // cleanup
+            consoleHolder.release();
+        }
+
+    }
+
+    private static FailIf failIf(Severity severity) {
+        FailIf failIf = new FailIf();
+        failIf.setSeverity(severity);
+        return failIf;
+    }
+
     private List<String> getOutputInfoLines(ConsoleHolder consoleHolder) {
         final String lineSeparator = lineSeparator();
         return Arrays.stream(consoleHolder.getOutput().split(lineSeparator))
diff --git a/asciidoctor-maven-plugin/src/test/resources/src/asciidoctor/errors/valid.adoc b/asciidoctor-maven-plugin/src/test/resources/src/asciidoctor/errors/valid.adoc
new file mode 100644
index 0000000..51bf399
--- /dev/null
+++ b/asciidoctor-maven-plugin/src/test/resources/src/asciidoctor/errors/valid.adoc
@@ -0,0 +1,7 @@
+= Document Title
+
+This a valid document.
+
+== Section A
+
+No errors to be seen.