Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-09-24 23:05:47 +0300
committerLeitwolf112026-09-24 23:05:47 +0300
commitd79b294903e4d0f4b24fdf1cc50be4b0fec7f745 (patch)
tree64f0c7c3dfbc08bfb70d9a37df5cccae3d52b526
parent9ad804cedf4cc4a85ef89fe097f97a3b44654360 (diff)
downloadcatch3-0.2.0.tar.gz
Add snapshots and JUnit result serialization0.2.0
-rw-r--r--MODULE.bazel2
-rw-r--r--docs/progmasoft-catch3.md41
-rw-r--r--docs/why-catch3.md5
-rw-r--r--premake5.lua23
-rw-r--r--src/CMakeLists.txt16
-rw-r--r--src/Progmasoft/BUILD.bazel12
-rw-r--r--src/Progmasoft/Catch3.hpp3
-rw-r--r--src/Progmasoft/Catch3/Assertions.hpp10
-rw-r--r--src/Progmasoft/Catch3/Results.hpp57
-rw-r--r--src/Progmasoft/Catch3/Snapshot.hpp55
-rw-r--r--src/Progmasoft/Catch3/XmlWriter.hpp20
-rw-r--r--src/Progmasoft/Results.cpp73
-rw-r--r--src/Progmasoft/Snapshot.cpp265
-rw-r--r--src/Progmasoft/XmlWriter.cpp274
-rw-r--r--src/Progmasoft/meson.build15
-rw-r--r--tests/CMakeLists.txt6
-rw-r--r--tests/Progmasoft/BUILD.bazel6
-rw-r--r--tests/Progmasoft/Results.tests.cpp165
-rw-r--r--tests/Progmasoft/Snapshot.tests.cpp164
-rw-r--r--tests/meson.build6
-rw-r--r--xmake.lua15
21 files changed, 1216 insertions, 17 deletions
diff --git a/MODULE.bazel b/MODULE.bazel
index 8bff2332..fcc80d58 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -1,4 +1,4 @@
-module(name = "catch3", version = "0.1.0")
+module(name = "catch3", version = "0.2.0")
bazel_dep(name = "bazel_skylib", version = "1.9.0")
bazel_dep(name = "rules_cc", version = "0.2.17")
diff --git a/docs/progmasoft-catch3.md b/docs/progmasoft-catch3.md
index 96989479..43a73f03 100644
--- a/docs/progmasoft-catch3.md
+++ b/docs/progmasoft-catch3.md
@@ -58,9 +58,48 @@ state-machine commands, parallel trials, and adaptive shrinking are deliberately
not implied by this first module; they need their own deterministic contracts and
tests before being added.
+## Snapshots
+
+`<Progmasoft/Catch3/Snapshot.hpp>` provides file-backed text snapshots through
+`CompareSnapshot(name, actual, options)`. The directory is supplied explicitly;
+names are restricted to portable ASCII filename characters and cannot contain
+path separators. CRLF and CR are normalized to LF by default, with no trimming
+or other content rewriting.
+
+Ordinary comparisons never write files. `SnapshotUpdateMode::kCreateMissing`
+creates only absent snapshots, while `kAlways` explicitly permits replacement.
+The result reports a match, create/update, mismatch with a one-based first
+difference location, invalid input, or I/O failure. `CATCH3_CHECK_SNAPSHOT`
+combines this result with Catch2's normal assertion reporting. Keep snapshots
+under a directory owned by the test suite and enable replacement only for an
+intentional update run.
+
+```cpp
+const Progmasoft::Catch3::SnapshotOptions options{
+ .Directory = "tests/snapshots",
+};
+CATCH3_CHECK_SNAPSHOT("rendered-report", RenderReport(), options);
+```
+
+## Structured results and XML
+
+`<Progmasoft/Catch3/Results.hpp>` defines a runner-neutral hierarchy of
+`TestRunResult`, `TestSuiteResult`, and `TestCaseResult`. `SummarizeTestRun`
+validates finite, non-negative durations and computes pass/failure/error/skip
+counts. `<Progmasoft/Catch3/XmlWriter.hpp>` serializes that model as deterministic
+JUnit-style XML through `JUnitXmlWriter::Write` or `ToString`. It escapes XML
+markup, replaces malformed UTF-8 and XML-forbidden control characters, and
+omits timestamps so identical results produce identical output. Catch2's own
+`--reporter xml` and `--reporter junit` remain the direct reporters for results
+produced by its runner; this writer is for Progmasoft's explicit result model.
+
+The snapshot and result/XML APIs have compiled C++ implementations in the
+`Progmasoft::Catch3` library target. Generator templates and convenience macros
+remain in headers where their types or call-site expansion require it.
+
## Build and integration
-The CMake build exposes `Progmasoft::Catch3`, a C++20 interface target linked to
+The CMake build exposes `Progmasoft::Catch3`, a compiled C++20 library linked to
`Catch2::Catch2`. The existing Catch2 target retains its C++14 baseline. After
installation, consumers can use:
diff --git a/docs/why-catch3.md b/docs/why-catch3.md
index 0b62402f..30dd5159 100644
--- a/docs/why-catch3.md
+++ b/docs/why-catch3.md
@@ -40,7 +40,10 @@ future Catch2 upstream change will be adopted unchanged or that all downstream
projects have already been validated.
The split also applies to implementation and packaging: Catch2 remains the
-engine dependency, while Progmasoft's header-only API is a separate target.
+engine dependency, while Progmasoft's compiled C++20 library is a separate
+target. Template-based generators and call-site assertion macros remain in
+headers; snapshot I/O, result aggregation, and XML serialization are compiled
+implementations.
Keep tests for the two areas independently identifiable so additions do not
silently alter the upstream compatibility baseline.
diff --git a/premake5.lua b/premake5.lua
index a03b8734..9c2115d2 100644
--- a/premake5.lua
+++ b/premake5.lua
@@ -54,6 +54,25 @@ project "Catch2WithMain"
includedirs { "src", generated_include }
links { "Catch2" }
+project "ProgmasoftCatch3"
+ kind "StaticLib"
+ language "C++"
+ cppdialect "C++20"
+ targetdir "build/premake/bin/%{cfg.buildcfg}"
+ objdir "build/premake/obj/ProgmasoftCatch3/%{cfg.buildcfg}"
+ files {
+ "src/Progmasoft/Results.cpp",
+ "src/Progmasoft/Results.hpp",
+ "src/Progmasoft/Snapshot.cpp",
+ "src/Progmasoft/Snapshot.hpp",
+ "src/Progmasoft/XmlWriter.cpp",
+ "src/Progmasoft/XmlWriter.hpp",
+ "src/Progmasoft/Catch3/**.hpp",
+ "src/Progmasoft/Catch3.hpp",
+ }
+ includedirs { "src", generated_include }
+ links { "Catch2" }
+
project "ProgmasoftPropertyTests"
kind "ConsoleApp"
language "C++"
@@ -62,7 +81,9 @@ project "ProgmasoftPropertyTests"
objdir "build/premake/obj/ProgmasoftPropertyTests/%{cfg.buildcfg}"
files {
"tests/Progmasoft/Property.tests.cpp",
+ "tests/Progmasoft/Results.tests.cpp",
+ "tests/Progmasoft/Snapshot.tests.cpp",
"src/Progmasoft/**.hpp",
}
includedirs { "src", generated_include }
- links { "Catch2WithMain", "Catch2" }
+ links { "ProgmasoftCatch3", "Catch2WithMain", "Catch2" }
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index cb8c339d..5eec18e9 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -360,18 +360,22 @@ if(CATCH_ENABLE_REPRODUCIBLE_BUILD)
endif()
add_library(Catch2::Catch2 ALIAS Catch2)
-# Progmasoft's C++20 additions are a separate target so the Catch2
-# compatibility engine can keep its existing, broader C++14 baseline.
-add_library(ProgmasoftCatch3 INTERFACE)
+# Progmasoft's compiled C++20 utilities are separate from Catch2's
+# compatibility engine, which retains its existing C++14 baseline.
+add_library(ProgmasoftCatch3 STATIC
+ "${CATCH_DIR}/src/Progmasoft/Results.cpp"
+ "${CATCH_DIR}/src/Progmasoft/Snapshot.cpp"
+ "${CATCH_DIR}/src/Progmasoft/XmlWriter.cpp"
+)
add_library(Progmasoft::Catch3 ALIAS ProgmasoftCatch3)
set_target_properties(ProgmasoftCatch3 PROPERTIES EXPORT_NAME Catch3)
-target_link_libraries(ProgmasoftCatch3 INTERFACE Catch2::Catch2)
+target_link_libraries(ProgmasoftCatch3 PUBLIC Catch2::Catch2)
target_include_directories(ProgmasoftCatch3
- INTERFACE
+ PUBLIC
$<BUILD_INTERFACE:${CATCH_DIR}/src>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
-target_compile_features(ProgmasoftCatch3 INTERFACE cxx_std_20)
+target_compile_features(ProgmasoftCatch3 PUBLIC cxx_std_20)
if(ANDROID)
target_link_libraries(Catch2 PRIVATE log)
diff --git a/src/Progmasoft/BUILD.bazel b/src/Progmasoft/BUILD.bazel
index 9927aafa..642bc158 100644
--- a/src/Progmasoft/BUILD.bazel
+++ b/src/Progmasoft/BUILD.bazel
@@ -6,6 +6,11 @@ package(default_visibility = ["//visibility:public"])
cc_library(
name = "catch3",
+ srcs = [
+ "Results.cpp",
+ "Snapshot.cpp",
+ "XmlWriter.cpp",
+ ],
hdrs = [
"Catch3.hpp",
"Catch3/Assertions.hpp",
@@ -14,7 +19,14 @@ cc_library(
"Catch3/Generators/Integer.hpp",
"Catch3/Property.hpp",
"Catch3/Random.hpp",
+ "Catch3/Results.hpp",
+ "Catch3/Snapshot.hpp",
+ "Catch3/XmlWriter.hpp",
],
+ copts = select({
+ "@platforms//os:windows": ["/std:c++20"],
+ "//conditions:default": ["-std=c++20"],
+ }),
strip_include_prefix = "/src",
deps = ["//:catch2"],
)
diff --git a/src/Progmasoft/Catch3.hpp b/src/Progmasoft/Catch3.hpp
index 1ae73e53..d2d34e74 100644
--- a/src/Progmasoft/Catch3.hpp
+++ b/src/Progmasoft/Catch3.hpp
@@ -11,3 +11,6 @@
#include <Progmasoft/Catch3/Generators/Integer.hpp>
#include <Progmasoft/Catch3/Property.hpp>
#include <Progmasoft/Catch3/Random.hpp>
+#include <Progmasoft/Catch3/Results.hpp>
+#include <Progmasoft/Catch3/Snapshot.hpp>
+#include <Progmasoft/Catch3/XmlWriter.hpp>
diff --git a/src/Progmasoft/Catch3/Assertions.hpp b/src/Progmasoft/Catch3/Assertions.hpp
index 19ac13ef..668c086d 100644
--- a/src/Progmasoft/Catch3/Assertions.hpp
+++ b/src/Progmasoft/Catch3/Assertions.hpp
@@ -6,6 +6,7 @@
#include <catch2/catch_test_macros.hpp>
#include <Progmasoft/Catch3/Property.hpp>
+#include <Progmasoft/Catch3/Snapshot.hpp>
/// Runs a property and reports its replay seed and minimized counterexample.
#define CATCH3_CHECK_PROPERTY( ... ) \
@@ -15,3 +16,12 @@
INFO( catch3PropertyResult.Describe() ); \
CHECK( catch3PropertyResult.Succeeded() ); \
} while ( false )
+
+/// Compares a value with a named snapshot and reports the first difference.
+#define CATCH3_CHECK_SNAPSHOT( name, actual, options ) \
+ do { \
+ const auto catch3SnapshotResult = \
+ ::Progmasoft::Catch3::CompareSnapshot( name, actual, options ); \
+ INFO( catch3SnapshotResult.Message ); \
+ CHECK( catch3SnapshotResult.Succeeded() ); \
+ } while ( false )
diff --git a/src/Progmasoft/Catch3/Results.hpp b/src/Progmasoft/Catch3/Results.hpp
new file mode 100644
index 00000000..b0b69b55
--- /dev/null
+++ b/src/Progmasoft/Catch3/Results.hpp
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <cstddef>
+#include <string>
+#include <vector>
+
+namespace Progmasoft::Catch3 {
+
+ enum class TestCaseStatus : unsigned char {
+ kPassed,
+ kFailed,
+ kErrored,
+ kSkipped,
+ };
+
+ /// Framework-neutral result for one completed test case.
+ struct TestCaseResult final {
+ std::string Name;
+ std::string ClassName;
+ std::string SourceFile;
+ TestCaseStatus Status = TestCaseStatus::kPassed;
+ double DurationSeconds = 0.0;
+ std::string Message;
+ std::string Details;
+ std::string StandardOutput;
+ std::string StandardError;
+ };
+
+ struct TestSuiteResult final {
+ std::string Name;
+ std::vector<TestCaseResult> TestCases;
+ };
+
+ struct TestRunResult final {
+ std::string Name;
+ std::vector<TestSuiteResult> TestSuites;
+ };
+
+ struct TestRunSummary final {
+ std::size_t Tests = 0;
+ std::size_t Failures = 0;
+ std::size_t Errors = 0;
+ std::size_t Skipped = 0;
+ double DurationSeconds = 0.0;
+
+ [[nodiscard]] std::size_t Passed() const noexcept;
+ };
+
+ /// Validates durations and statuses while aggregating nested test results.
+ [[nodiscard]] TestRunSummary
+ SummarizeTestSuite( const TestSuiteResult& result );
+ [[nodiscard]] TestRunSummary
+ SummarizeTestRun( const TestRunResult& result );
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Catch3/Snapshot.hpp b/src/Progmasoft/Catch3/Snapshot.hpp
new file mode 100644
index 00000000..09863c1c
--- /dev/null
+++ b/src/Progmasoft/Catch3/Snapshot.hpp
@@ -0,0 +1,55 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <cstddef>
+#include <filesystem>
+#include <string>
+#include <string_view>
+
+namespace Progmasoft::Catch3 {
+
+ /// Updates are deliberately explicit so normal test runs never rewrite
+ /// files.
+ enum class SnapshotUpdateMode : unsigned char {
+ kNever,
+ kCreateMissing,
+ kAlways,
+ };
+
+ enum class SnapshotStatus : unsigned char {
+ kMatched,
+ kCreated,
+ kUpdated,
+ kMismatched,
+ kInvalidName,
+ kInvalidOptions,
+ kIoError,
+ };
+
+ struct SnapshotOptions final {
+ std::filesystem::path Directory;
+ SnapshotUpdateMode UpdateMode = SnapshotUpdateMode::kNever;
+ bool NormalizeLineEndings = true;
+ };
+
+ /// Structured comparison result; DifferenceLine/Column are one-based.
+ struct SnapshotResult final {
+ SnapshotStatus Status = SnapshotStatus::kInvalidOptions;
+ std::filesystem::path File;
+ std::string Expected;
+ std::string Actual;
+ std::string Message;
+ std::size_t DifferenceLine = 0;
+ std::size_t DifferenceColumn = 0;
+
+ [[nodiscard]] bool Succeeded() const noexcept;
+ };
+
+ /// Compare against `<Directory>/<name>.snap`; names cannot contain paths.
+ [[nodiscard]] SnapshotResult
+ CompareSnapshot( std::string_view name,
+ std::string_view actual,
+ const SnapshotOptions& options );
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Catch3/XmlWriter.hpp b/src/Progmasoft/Catch3/XmlWriter.hpp
new file mode 100644
index 00000000..3216b5ae
--- /dev/null
+++ b/src/Progmasoft/Catch3/XmlWriter.hpp
@@ -0,0 +1,20 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <iosfwd>
+#include <string>
+
+namespace Progmasoft::Catch3 {
+
+ struct TestRunResult;
+
+ /// Emits deterministic JUnit-style XML without timestamps or global state.
+ class JUnitXmlWriter final {
+ public:
+ static void Write( const TestRunResult& result, std::ostream& output );
+ [[nodiscard]] static std::string
+ ToString( const TestRunResult& result );
+ };
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Results.cpp b/src/Progmasoft/Results.cpp
new file mode 100644
index 00000000..69a819a7
--- /dev/null
+++ b/src/Progmasoft/Results.cpp
@@ -0,0 +1,73 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <Progmasoft/Catch3/Results.hpp>
+
+#include <cmath>
+#include <limits>
+#include <stdexcept>
+
+namespace Progmasoft::Catch3 {
+
+ namespace {
+
+ void AccumulateCases( TestRunSummary& summary,
+ const std::vector<TestCaseResult>& testCases ) {
+ for ( const TestCaseResult& testCase : testCases ) {
+ if ( !std::isfinite( testCase.DurationSeconds ) ||
+ testCase.DurationSeconds < 0.0 ) {
+ throw std::invalid_argument(
+ "test duration must be finite and non-negative" );
+ }
+ if ( summary.Tests ==
+ std::numeric_limits<std::size_t>::max() ) {
+ throw std::overflow_error( "test count overflow" );
+ }
+
+ ++summary.Tests;
+ summary.DurationSeconds += testCase.DurationSeconds;
+ if ( !std::isfinite( summary.DurationSeconds ) ) {
+ throw std::overflow_error( "test duration total overflow" );
+ }
+
+ switch ( testCase.Status ) {
+ case TestCaseStatus::kPassed:
+ break;
+ case TestCaseStatus::kFailed:
+ ++summary.Failures;
+ break;
+ case TestCaseStatus::kErrored:
+ ++summary.Errors;
+ break;
+ case TestCaseStatus::kSkipped:
+ ++summary.Skipped;
+ break;
+ default:
+ throw std::invalid_argument( "unknown test case status" );
+ }
+ }
+ }
+
+ } // namespace
+
+ std::size_t TestRunSummary::Passed() const noexcept {
+ if ( Failures > Tests || Errors > Tests - Failures ) { return 0; }
+ const std::size_t failuresAndErrors = Failures + Errors;
+ if ( Skipped > Tests - failuresAndErrors ) { return 0; }
+ return Tests - failuresAndErrors - Skipped;
+ }
+
+ TestRunSummary SummarizeTestSuite( const TestSuiteResult& result ) {
+ TestRunSummary summary;
+ AccumulateCases( summary, result.TestCases );
+ return summary;
+ }
+
+ TestRunSummary SummarizeTestRun( const TestRunResult& result ) {
+ TestRunSummary summary;
+ for ( const TestSuiteResult& suite : result.TestSuites ) {
+ AccumulateCases( summary, suite.TestCases );
+ }
+ return summary;
+ }
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Snapshot.cpp b/src/Progmasoft/Snapshot.cpp
new file mode 100644
index 00000000..7278eee3
--- /dev/null
+++ b/src/Progmasoft/Snapshot.cpp
@@ -0,0 +1,265 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <Progmasoft/Catch3/Snapshot.hpp>
+
+#include <atomic>
+#include <chrono>
+#include <cstdint>
+#include <fstream>
+#include <iterator>
+#include <system_error>
+
+#if defined( _WIN32 )
+# if !defined( NOMINMAX )
+# define NOMINMAX
+# endif
+# include <Windows.h>
+#endif
+#include <utility>
+
+namespace Progmasoft::Catch3 {
+
+ namespace {
+
+ bool IsSafeSnapshotName( std::string_view name ) noexcept {
+ if ( name.empty() || name == "." || name == ".." ) { return false; }
+ for ( const unsigned char character : name ) {
+ const bool isLetter =
+ ( character >= 'a' && character <= 'z' ) ||
+ ( character >= 'A' && character <= 'Z' );
+ const bool isDigit = character >= '0' && character <= '9';
+ if ( !isLetter && !isDigit && character != '_' &&
+ character != '-' && character != '.' ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ std::string NormalizeSnapshotText( std::string_view text,
+ bool normalizeLineEndings ) {
+ if ( !normalizeLineEndings ) { return std::string( text ); }
+
+ std::string normalized;
+ normalized.reserve( text.size() );
+ for ( std::size_t index = 0; index < text.size(); ++index ) {
+ if ( text[index] == '\r' ) {
+ if ( index + 1 < text.size() && text[index + 1] == '\n' ) {
+ ++index;
+ }
+ normalized.push_back( '\n' );
+ } else {
+ normalized.push_back( text[index] );
+ }
+ }
+ return normalized;
+ }
+
+ void LocateDifference( SnapshotResult& result ) noexcept {
+ const std::size_t commonLength =
+ result.Expected.size() < result.Actual.size()
+ ? result.Expected.size()
+ : result.Actual.size();
+ std::size_t index = 0;
+ while ( index < commonLength &&
+ result.Expected[index] == result.Actual[index] ) {
+ ++index;
+ }
+
+ result.DifferenceLine = 1;
+ result.DifferenceColumn = 1;
+ for ( std::size_t offset = 0; offset < index; ++offset ) {
+ if ( result.Actual[offset] == '\n' ) {
+ ++result.DifferenceLine;
+ result.DifferenceColumn = 1;
+ } else {
+ ++result.DifferenceColumn;
+ }
+ }
+ }
+
+ bool WriteAtomically( const std::filesystem::path& destination,
+ std::string_view contents,
+ bool replaceExisting,
+ std::error_code& error ) {
+ // Write beside the destination so replacement stays on one volume
+ // and readers never observe a partially written snapshot.
+ static std::atomic<std::uint64_t> nextTemporaryFile{ 0 };
+ const auto clockValue =
+ std::chrono::steady_clock::now().time_since_epoch().count();
+ const auto sequence =
+ nextTemporaryFile.fetch_add( 1, std::memory_order_relaxed );
+ std::filesystem::path temporary = destination;
+ temporary += ".tmp." + std::to_string( clockValue ) + "." +
+ std::to_string( sequence );
+
+ {
+ std::ofstream output( temporary,
+ std::ios::binary | std::ios::trunc );
+ if ( !output ) {
+ error = std::make_error_code( std::errc::io_error );
+ return false;
+ }
+ output.write( contents.data(),
+ static_cast<std::streamsize>( contents.size() ) );
+ output.flush();
+ if ( !output ) {
+ error = std::make_error_code( std::errc::io_error );
+ output.close();
+ std::error_code ignored;
+ std::filesystem::remove( temporary, ignored );
+ return false;
+ }
+ }
+
+ if ( replaceExisting ) {
+#if defined( _WIN32 )
+ if ( MoveFileExW( temporary.c_str(),
+ destination.c_str(),
+ MOVEFILE_REPLACE_EXISTING |
+ MOVEFILE_WRITE_THROUGH ) == 0 ) {
+ error = std::error_code( static_cast<int>( GetLastError() ),
+ std::system_category() );
+ }
+#else
+ std::filesystem::rename( temporary, destination, error );
+#endif
+ } else {
+ // A hard-link create is atomic and refuses a concurrent file.
+ std::filesystem::create_hard_link(
+ temporary, destination, error );
+ std::error_code ignored;
+ std::filesystem::remove( temporary, ignored );
+ }
+ if ( error ) {
+ std::error_code ignored;
+ std::filesystem::remove( temporary, ignored );
+ return false;
+ }
+ return true;
+ }
+
+ } // namespace
+
+ bool SnapshotResult::Succeeded() const noexcept {
+ return Status == SnapshotStatus::kMatched ||
+ Status == SnapshotStatus::kCreated ||
+ Status == SnapshotStatus::kUpdated;
+ }
+
+ SnapshotResult CompareSnapshot( std::string_view name,
+ std::string_view actual,
+ const SnapshotOptions& options ) {
+ SnapshotResult result;
+ if ( !IsSafeSnapshotName( name ) ) {
+ result.Status = SnapshotStatus::kInvalidName;
+ result.Message =
+ "snapshot name must be a non-empty ASCII filename component";
+ return result;
+ }
+ if ( options.Directory.empty() ||
+ ( options.UpdateMode != SnapshotUpdateMode::kNever &&
+ options.UpdateMode != SnapshotUpdateMode::kCreateMissing &&
+ options.UpdateMode != SnapshotUpdateMode::kAlways ) ) {
+ result.Status = SnapshotStatus::kInvalidOptions;
+ result.Message = "snapshot directory or update mode is invalid";
+ return result;
+ }
+
+ result.File = options.Directory / ( std::string( name ) + ".snap" );
+ result.Actual =
+ NormalizeSnapshotText( actual, options.NormalizeLineEndings );
+
+ std::error_code error;
+ const std::filesystem::file_status fileStatus =
+ std::filesystem::symlink_status( result.File, error );
+ if ( error && error != std::errc::no_such_file_or_directory ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message =
+ "could not inspect snapshot file: " + error.message();
+ return result;
+ }
+
+ const bool exists = !error && fileStatus.type() !=
+ std::filesystem::file_type::not_found;
+ if ( exists && std::filesystem::is_symlink( fileStatus ) ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "snapshot files must not be symbolic links";
+ return result;
+ }
+ if ( exists && !std::filesystem::is_regular_file( fileStatus ) ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "snapshot path is not a regular file";
+ return result;
+ }
+
+ if ( !exists ) {
+ if ( options.UpdateMode == SnapshotUpdateMode::kNever ) {
+ result.Status = SnapshotStatus::kMismatched;
+ result.Message = "snapshot file does not exist";
+ LocateDifference( result );
+ return result;
+ }
+
+ std::filesystem::create_directories( options.Directory, error );
+ if ( error || !WriteAtomically(
+ result.File, result.Actual, false, error ) ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message =
+ "could not create snapshot file: " + error.message();
+ return result;
+ }
+ result.Status = SnapshotStatus::kCreated;
+ result.Message = "snapshot created";
+ return result;
+ }
+
+ std::ifstream input( result.File, std::ios::binary );
+ if ( !input ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "could not open snapshot file for reading";
+ return result;
+ }
+ const std::string fileContents{ std::istreambuf_iterator<char>( input ),
+ std::istreambuf_iterator<char>() };
+ if ( input.bad() ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "could not read snapshot file";
+ return result;
+ }
+ input.close();
+ if ( input.fail() ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "could not close snapshot file after reading";
+ return result;
+ }
+ result.Expected =
+ NormalizeSnapshotText( fileContents, options.NormalizeLineEndings );
+
+ if ( result.Expected == result.Actual ) {
+ result.Status = SnapshotStatus::kMatched;
+ result.Message = "snapshot matched";
+ return result;
+ }
+
+ LocateDifference( result );
+ if ( options.UpdateMode == SnapshotUpdateMode::kAlways ) {
+ if ( !WriteAtomically( result.File, result.Actual, true, error ) ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message =
+ "could not update snapshot file: " + error.message();
+ return result;
+ }
+ result.Status = SnapshotStatus::kUpdated;
+ result.Message = "snapshot updated";
+ return result;
+ }
+
+ result.Status = SnapshotStatus::kMismatched;
+ result.Message = "snapshot differs at line " +
+ std::to_string( result.DifferenceLine ) + ", column " +
+ std::to_string( result.DifferenceColumn );
+ return result;
+ }
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/XmlWriter.cpp b/src/Progmasoft/XmlWriter.cpp
new file mode 100644
index 00000000..b3a5f9b1
--- /dev/null
+++ b/src/Progmasoft/XmlWriter.cpp
@@ -0,0 +1,274 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <Progmasoft/Catch3/Results.hpp>
+#include <Progmasoft/Catch3/XmlWriter.hpp>
+
+#include <cmath>
+#include <iomanip>
+#include <locale>
+#include <ostream>
+#include <sstream>
+#include <stdexcept>
+#include <string_view>
+
+namespace Progmasoft::Catch3 {
+
+ namespace {
+
+ struct DecodedCharacter final {
+ char32_t Value = 0;
+ std::size_t Length = 0;
+ };
+
+ DecodedCharacter DecodeUtf8( std::string_view text,
+ std::size_t offset ) noexcept {
+ const auto first = static_cast<unsigned char>( text[offset] );
+ if ( first < 0x80 ) { return { first, 1 }; }
+
+ std::size_t length = 0;
+ char32_t value = 0;
+ char32_t minimum = 0;
+ if ( first >= 0xC2 && first <= 0xDF ) {
+ length = 2;
+ value = first & 0x1F;
+ minimum = 0x80;
+ } else if ( first >= 0xE0 && first <= 0xEF ) {
+ length = 3;
+ value = first & 0x0F;
+ minimum = 0x800;
+ } else if ( first >= 0xF0 && first <= 0xF4 ) {
+ length = 4;
+ value = first & 0x07;
+ minimum = 0x10000;
+ } else {
+ return { 0xFFFD, 0 };
+ }
+
+ if ( offset + length > text.size() ) { return { 0xFFFD, 0 }; }
+ for ( std::size_t index = 1; index < length; ++index ) {
+ const auto continuation =
+ static_cast<unsigned char>( text[offset + index] );
+ if ( ( continuation & 0xC0 ) != 0x80 ) { return { 0xFFFD, 0 }; }
+ value = static_cast<char32_t>( ( value << 6 ) |
+ ( continuation & 0x3F ) );
+ }
+ if ( value < minimum || value > 0x10FFFF ||
+ ( value >= 0xD800 && value <= 0xDFFF ) ) {
+ return { 0xFFFD, 0 };
+ }
+ return { value, length };
+ }
+
+ bool IsXmlCharacter( char32_t value ) noexcept {