diff options
| author | Leitwolf11 | 2026-09-24 23:05:47 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-09-24 23:05:47 +0300 |
| commit | d79b294903e4d0f4b24fdf1cc50be4b0fec7f745 (patch) | |
| tree | 64f0c7c3dfbc08bfb70d9a37df5cccae3d52b526 | |
| parent | 9ad804cedf4cc4a85ef89fe097f97a3b44654360 (diff) | |
| download | catch3-0.2.0.tar.gz | |
Add snapshots and JUnit result serialization0.2.0
| -rw-r--r-- | MODULE.bazel | 2 | ||||
| -rw-r--r-- | docs/progmasoft-catch3.md | 41 | ||||
| -rw-r--r-- | docs/why-catch3.md | 5 | ||||
| -rw-r--r-- | premake5.lua | 23 | ||||
| -rw-r--r-- | src/CMakeLists.txt | 16 | ||||
| -rw-r--r-- | src/Progmasoft/BUILD.bazel | 12 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3.hpp | 3 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3/Assertions.hpp | 10 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3/Results.hpp | 57 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3/Snapshot.hpp | 55 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3/XmlWriter.hpp | 20 | ||||
| -rw-r--r-- | src/Progmasoft/Results.cpp | 73 | ||||
| -rw-r--r-- | src/Progmasoft/Snapshot.cpp | 265 | ||||
| -rw-r--r-- | src/Progmasoft/XmlWriter.cpp | 274 | ||||
| -rw-r--r-- | src/Progmasoft/meson.build | 15 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 6 | ||||
| -rw-r--r-- | tests/Progmasoft/BUILD.bazel | 6 | ||||
| -rw-r--r-- | tests/Progmasoft/Results.tests.cpp | 165 | ||||
| -rw-r--r-- | tests/Progmasoft/Snapshot.tests.cpp | 164 | ||||
| -rw-r--r-- | tests/meson.build | 6 | ||||
| -rw-r--r-- | xmake.lua | 15 |
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 { |