From 276b07df2fafdc9d05442fb2f39227c6e110af11 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 20:14:56 +0200 Subject: [PATCH 01/29] Adding clang-tidy for analyse, linting and security --- .clang-tidy | 7 +++ .gitignore | 1 + CMakePresets.json | 43 +++++++++++++ Makefile | 33 +++++++++- makefile | 11 ---- scripts/ci/run_clang_tidy.sh | 115 +++++++++++++++++++++++++++++++++++ tempify-registry | 1 + tempify-templates | 1 + 8 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 .clang-tidy create mode 100644 CMakePresets.json delete mode 100644 makefile create mode 100755 scripts/ci/run_clang_tidy.sh create mode 160000 tempify-registry create mode 160000 tempify-templates diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..ad558cd --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,7 @@ +--- +Checks: '-*' +WarningsAsErrors: '*' +HeaderFilterRegex: '(src/main/(cpp|include)|tests/(support|tempify))/.*' +FormatStyle: none +ExtraArgsBefore: + - '-std=c++23' diff --git a/.gitignore b/.gitignore index c7757b7..86736a5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ /compile_commands.json /scripts/ci/__pycache__/ /tests/test_workspace/ +/build-cmake/ diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..234d083 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,43 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 31, + "patch": 0 + }, + "configurePresets": [ + { + "name": "dev", + "displayName": "Dev Ninja", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + } + }, + { + "name": "tidy", + "displayName": "Clang-Tidy Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/tidy", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "BUILD_TESTING": "ON" + } + } + ], + "buildPresets": [ + { + "name": "dev", + "configurePreset": "dev" + }, + { + "name": "tidy-core", + "configurePreset": "tidy", + "targets": ["tempify_core", "tempify", "tempify_tests"] + } + ] +} diff --git a/Makefile b/Makefile index a2c352e..9806571 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,14 @@ -.PHONY: configure build test clean +.PHONY: configure build start run test clean analyze lint security static-analysis BUILD_DIR ?= build +CLANG_TIDY_BUILD_DIR := build-cmake/tidy + +ifdef VCPKG_ROOT +CMAKE_VCPKG_ARGS := -DCMAKE_TOOLCHAIN_FILE=$(VCPKG_ROOT)/scripts/buildsystems/vcpkg.cmake +ifdef VCPKG_TARGET_TRIPLET +CMAKE_VCPKG_ARGS += -DVCPKG_TARGET_TRIPLET=$(VCPKG_TARGET_TRIPLET) +endif +endif configure: cmake -S . -B $(BUILD_DIR) -G Ninja @@ -8,8 +16,29 @@ configure: build: configure cmake --build $(BUILD_DIR) +start: build + +run: build + ./$(BUILD_DIR)/tempify + test: build ctest --test-dir $(BUILD_DIR) --output-on-failure +analyze: + chmod +x scripts/ci/run_clang_tidy.sh + ./scripts/ci/run_clang_tidy.sh analyze $(CMAKE_VCPKG_ARGS) + +lint: + chmod +x scripts/ci/run_clang_tidy.sh + ./scripts/ci/run_clang_tidy.sh lint $(CMAKE_VCPKG_ARGS) + +security: + chmod +x scripts/ci/run_clang_tidy.sh + ./scripts/ci/run_clang_tidy.sh security $(CMAKE_VCPKG_ARGS) + +static-analysis: + chmod +x scripts/ci/run_clang_tidy.sh + ./scripts/ci/run_clang_tidy.sh all $(CMAKE_VCPKG_ARGS) + clean: - rm -rf $(BUILD_DIR) + rm -rf $(BUILD_DIR) build-cmake diff --git a/makefile b/makefile deleted file mode 100644 index a5941c7..0000000 --- a/makefile +++ /dev/null @@ -1,11 +0,0 @@ -BUILD_DIR ?= build - -start: - cmake -S . -B $(BUILD_DIR) -G Ninja - cmake --build $(BUILD_DIR) - -run: start - ./$(BUILD_DIR)/tempify - -test: start - ctest --test-dir $(BUILD_DIR) --output-on-failure diff --git a/scripts/ci/run_clang_tidy.sh b/scripts/ci/run_clang_tidy.sh new file mode 100755 index 0000000..7ec6cfa --- /dev/null +++ b/scripts/ci/run_clang_tidy.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${CLANG_TIDY_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +BUILD_DIR="${CLANG_TIDY_BUILD_DIR:-$ROOT/build-cmake/tidy}" +CMAKE_PRESET="${CLANG_TIDY_PRESET:-tidy}" +BUILD_PRESET="${CLANG_TIDY_BUILD_PRESET:-tidy-core}" + +ANALYZE_CHECKS='clang-analyzer-*,-clang-analyzer-optin.performance.Padding' +LINT_CHECKS='bugprone-*,-bugprone-easily-swappable-parameters,misc-throw-by-value-catch-by-reference,misc-use-after-move,performance-for-range-copy,performance-implicit-conversion-in-loop,performance-inefficient-vector-operation,performance-move-const-arg,performance-no-int-to-ptr,performance-noexcept-move-constructor,performance-unnecessary-copy-initialization,performance-unnecessary-value-param,readability-container-contains,readability-redundant-control-flow,readability-redundant-string-init,readability-suspicious-call-argument,modernize-avoid-bind,modernize-make-unique,modernize-make-shared,modernize-deprecated-headers,portability-simd-intrinsics' +SECURITY_CHECKS='cert-*,-cert-env33-c,concurrency-*,-concurrency-mt-unsafe,cppcoreguidelines-pro-type-reinterpret-cast,cppcoreguidelines-pro-type-cstyle-cast,cppcoreguidelines-pro-bounds-constant-array-index' + +resolve_run_clang_tidy() { + if command -v run-clang-tidy >/dev/null 2>&1; then + printf '%s\n' run-clang-tidy + return 0 + fi + + local candidate + for candidate in run-clang-tidy-22 run-clang-tidy-21 run-clang-tidy-20 run-clang-tidy-19 run-clang-tidy-18; do + if command -v "$candidate" >/dev/null 2>&1; then + printf '%s\n' "$candidate" + return 0 + fi + done + + printf 'run-clang-tidy not found; install clang-tidy\n' >&2 + return 1 +} + +ensure_build() { + if [[ ! -f "$BUILD_DIR/compile_commands.json" ]]; then + cmake --preset "$CMAKE_PRESET" "$@" + fi + + if ! cmake --build --preset "$BUILD_PRESET" --parallel; then + if [[ ! -f "$BUILD_DIR/compile_commands.json" ]]; then + printf 'Build failed and compile_commands.json is missing\n' >&2 + return 1 + fi + printf 'Build failed; continuing clang-tidy with existing compile_commands.json\n' >&2 + fi +} + +run_profile() { + local profile=$1 + local checks=$2 + local runner + runner=$(resolve_run_clang_tidy) + + printf 'Running clang-tidy profile: %s\n' "$profile" + + local output="" + local status=0 + output=$("$runner" -p "$BUILD_DIR" -checks="$checks" -quiet 2>&1) || status=$? + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" + fi + + if echo "$output" | grep -qE '(^|[^[])-warnings-as-errors\]|clang-diagnostic-error'; then + printf 'clang-tidy profile "%s" failed\n' "$profile" >&2 + return 1 + fi + + if [[ $status -ne 0 ]]; then + return "$status" + fi +} + +usage() { + cat < [extra cmake configure args...] + +Profiles: + analyze Static analyzer checks (clang-analyzer-*) + lint Bug-prone, performance, and modernization checks + security CERT, concurrency, and bounds checks + all Run analyze, lint, and security +EOF +} + +main() { + local profile=${1:-} + if [[ -z "$profile" ]]; then + usage >&2 + return 2 + fi + shift + + ensure_build "$@" + + case "$profile" in + analyze) + run_profile analyze "$ANALYZE_CHECKS" + ;; + lint) + run_profile lint "$LINT_CHECKS" + ;; + security) + run_profile security "$SECURITY_CHECKS" + ;; + all) + run_profile analyze "$ANALYZE_CHECKS" + run_profile lint "$LINT_CHECKS" + run_profile security "$SECURITY_CHECKS" + ;; + *) + usage >&2 + return 2 + ;; + esac +} + +main "$@" diff --git a/tempify-registry b/tempify-registry new file mode 160000 index 0000000..201dc5b --- /dev/null +++ b/tempify-registry @@ -0,0 +1 @@ +Subproject commit 201dc5b7ce07e4c68c161345ab87c0366dd1cec6 diff --git a/tempify-templates b/tempify-templates new file mode 160000 index 0000000..4fb0b80 --- /dev/null +++ b/tempify-templates @@ -0,0 +1 @@ +Subproject commit 4fb0b809eb9862cba36e872a6bf3467dbc1c6c17 From 37d0190bede1c5892ecad99d4a6ec30a7f67312e Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 20:17:05 +0200 Subject: [PATCH 02/29] adding formatter and formating code --- .clang-format | 11 + Makefile | 10 +- scripts/ci/run_clang_format.sh | 85 +++ src/main/cpp/app/TempifyApp.cpp | 373 ++++----- src/main/cpp/app/TempifyAppSupport.cpp | 72 +- src/main/cpp/build/BuildDiffReport.cpp | 213 +++--- src/main/cpp/build/BuildExecutor.cpp | 53 +- src/main/cpp/build/BuildPlanReport.cpp | 94 +-- src/main/cpp/build/BuildPlanner.cpp | 62 +- src/main/cpp/build/GenerationLock.cpp | 90 +-- src/main/cpp/build/ReapplySerialization.cpp | 100 +-- src/main/cpp/cli/CliHelp.cpp | 456 ++++++----- src/main/cpp/cli/CliParser.cpp | 109 ++- src/main/cpp/cli/ShellCompletion.cpp | 6 +- src/main/cpp/config/TempifyConfig.cpp | 42 +- src/main/cpp/frontend/PlainCliFrontend.cpp | 14 +- src/main/cpp/frontend/WizardFrontend.cpp | 14 +- src/main/cpp/hook/HookTrustStore.cpp | 59 +- src/main/cpp/lua/LuaEngine.cpp | 78 +- src/main/cpp/lua/LuaHookRuntime.cpp | 175 ++--- src/main/cpp/lua/LuaManifestLoader.cpp | 106 ++- src/main/cpp/lua/LuaQuestionRuntime.cpp | 27 +- src/main/cpp/lua/LuaSupport.cpp | 75 +- src/main/cpp/main.cpp | 74 +- src/main/cpp/prebyte/PrebyteCommandRunner.cpp | 4 +- src/main/cpp/prebyte/PrebyteRenderer.cpp | 22 +- src/main/cpp/question/AnswerFile.cpp | 49 +- src/main/cpp/question/QuestionGroupFlow.cpp | 32 +- src/main/cpp/question/QuestionProcessor.cpp | 326 ++++---- src/main/cpp/question/QuestionSupport.cpp | 78 +- src/main/cpp/store/AvailableTemplateCache.cpp | 34 +- src/main/cpp/store/LocalTemplateStore.cpp | 86 ++- src/main/cpp/support/EnvLoader.cpp | 9 +- src/main/cpp/support/Paths.cpp | 63 +- src/main/cpp/support/Slug.cpp | 2 +- src/main/cpp/template/TemplateInspector.cpp | 29 +- src/main/cpp/template/TemplateLinter.cpp | 38 +- src/main/cpp/template/TemplateLoader.cpp | 23 +- src/main/cpp/template/TemplateMerge.cpp | 119 ++- src/main/cpp/template/TemplateTestRunner.cpp | 271 +++---- src/main/cpp/template/TemplateValidator.cpp | 30 +- src/main/include/tempify/app/TempifyApp.h | 6 +- .../include/tempify/build/BuildDiffReport.h | 12 +- .../include/tempify/build/BuildExecutor.h | 18 +- .../include/tempify/build/BuildPlanReport.h | 26 +- src/main/include/tempify/build/BuildPlanner.h | 15 +- .../include/tempify/build/GenerationLock.h | 6 +- .../tempify/build/ReapplySerialization.h | 13 +- src/main/include/tempify/cli/CliParser.h | 8 +- .../include/tempify/cli/ShellCompletion.h | 2 +- .../include/tempify/config/TempifyConfig.h | 6 +- src/main/include/tempify/domain/BuildPlan.h | 2 +- src/main/include/tempify/domain/CliRequest.h | 2 +- src/main/include/tempify/domain/Question.h | 2 +- .../include/tempify/domain/RenderOptions.h | 2 +- .../include/tempify/domain/TemplateManifest.h | 2 +- .../tempify/frontend/IQuestionFrontend.h | 10 +- .../tempify/frontend/PlainCliFrontend.h | 10 +- .../include/tempify/frontend/WizardFrontend.h | 10 +- .../include/tempify/hook/HookTrustStore.h | 14 +- src/main/include/tempify/lua/LuaEngine.h | 29 +- .../tempify/prebyte/PrebyteCommandRunner.h | 6 +- .../include/tempify/prebyte/PrebyteRenderer.h | 19 +- .../include/tempify/question/AnswerFile.h | 8 +- .../tempify/question/QuestionProcessor.h | 25 +- .../tempify/store/AvailableTemplateCache.h | 10 +- .../tempify/store/LocalTemplateStore.h | 18 +- src/main/include/tempify/support/EnvLoader.h | 2 +- src/main/include/tempify/support/Errors.h | 27 +- src/main/include/tempify/support/Paths.h | 6 +- .../tempify/template/TemplateInspector.h | 2 +- .../include/tempify/template/TemplateLinter.h | 9 +- .../include/tempify/template/TemplateLoader.h | 34 +- .../tempify/template/TemplateTestRunner.h | 37 +- .../tempify/template/TemplateValidator.h | 6 +- tests/support/TempifyAppTestSupport.h | 1 - tests/support/TempifyTestSupport.h | 291 +++---- tests/support/TestHarness.cpp | 32 +- tests/support/TestHarness.h | 55 +- tests/support/TestMain.cpp | 12 +- tests/tempify/AdvancedHooksLayoutTests.cpp | 46 +- tests/tempify/BuildExecutorTests.cpp | 59 +- tests/tempify/BuildPlannerTests.cpp | 41 +- tests/tempify/CliParserTests.cpp | 38 +- tests/tempify/LocalTemplateStoreTests.cpp | 113 ++- .../tempify/LuaEngineManifestAndHookTests.cpp | 74 +- tests/tempify/LuaEngineQuestionTests.cpp | 9 +- tests/tempify/QuestionProcessorTests.cpp | 199 ++--- .../SharedTemplateStoreIntegrationTests.cpp | 56 +- .../TempifyAppCliCatalogIntegrationTests.cpp | 143 ++-- .../TempifyAppConfigPlanIntegrationTests.cpp | 308 ++++---- .../TempifyAppDiffIntegrationTests.cpp | 520 ++++++++----- .../TempifyAppHooksIntegrationTests.cpp | 87 ++- ...TempifyAppQuestionsTuiIntegrationTests.cpp | 33 +- .../TempifyAppReapplyIntegrationTests.cpp | 721 +++++++++++------- .../TempifyAppRenderIntegrationTests.cpp | 139 ++-- ...AppTemplateTestCommandIntegrationTests.cpp | 54 +- ...pValidationInspectLintIntegrationTests.cpp | 17 +- tests/tempify/TemplateLoaderTests.cpp | 54 +- tests/tempify/TemplateTestRunnerTests.cpp | 41 +- 100 files changed, 3716 insertions(+), 3444 deletions(-) create mode 100644 .clang-format create mode 100755 scripts/ci/run_clang_format.sh diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..aa85184 --- /dev/null +++ b/.clang-format @@ -0,0 +1,11 @@ +--- +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 120 +BreakBeforeBraces: Attach +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +NamespaceIndentation: None +SortIncludes: CaseSensitive +IncludeBlocks: Regroup diff --git a/Makefile b/Makefile index 9806571..3fb4444 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: configure build start run test clean analyze lint security static-analysis +.PHONY: configure build start run test clean analyze lint security static-analysis format format-check BUILD_DIR ?= build CLANG_TIDY_BUILD_DIR := build-cmake/tidy @@ -40,5 +40,13 @@ static-analysis: chmod +x scripts/ci/run_clang_tidy.sh ./scripts/ci/run_clang_tidy.sh all $(CMAKE_VCPKG_ARGS) +format: + chmod +x scripts/ci/run_clang_format.sh + ./scripts/ci/run_clang_format.sh format + +format-check: + chmod +x scripts/ci/run_clang_format.sh + ./scripts/ci/run_clang_format.sh check + clean: rm -rf $(BUILD_DIR) build-cmake diff --git a/scripts/ci/run_clang_format.sh b/scripts/ci/run_clang_format.sh new file mode 100755 index 0000000..0f84499 --- /dev/null +++ b/scripts/ci/run_clang_format.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${CLANG_FORMAT_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +resolve_clang_format() { + if command -v clang-format >/dev/null 2>&1; then + printf '%s\n' clang-format + return 0 + fi + + local candidate + for candidate in clang-format-22 clang-format-21 clang-format-20 clang-format-19 clang-format-18; do + if command -v "$candidate" >/dev/null 2>&1; then + printf '%s\n' "$candidate" + return 0 + fi + done + + printf 'clang-format not found; install clang-format\n' >&2 + return 1 +} + +collect_source_files() { + if git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$ROOT" ls-files -z -- \ + 'src/main/cpp/*.cpp' \ + 'src/main/include/*.h' \ + 'tests/support/*.cpp' \ + 'tests/support/*.h' \ + 'tests/tempify/*.cpp' + return 0 + fi + + find "$ROOT/src/main/cpp" "$ROOT/src/main/include" "$ROOT/tests/support" "$ROOT/tests/tempify" \ + \( -name '*.cpp' -o -name '*.h' \) -print0 2>/dev/null || true +} + +usage() { + cat < + +Commands: + format Apply clang-format to project sources + check Verify formatting without modifying files +EOF +} + +main() { + local mode=${1:-} + if [[ -z "$mode" ]]; then + usage >&2 + return 2 + fi + + local formatter + formatter=$(resolve_clang_format) + + local -a files=() + while IFS= read -r -d '' file; do + files+=("$file") + done < <(collect_source_files) + + if [[ ${#files[@]} -eq 0 ]]; then + printf 'No source files found for clang-format\n' >&2 + return 1 + fi + + case "$mode" in + format) + printf 'Formatting %d files with %s\n' "${#files[@]}" "$formatter" + "$formatter" -i "${files[@]}" + ;; + check) + printf 'Checking format of %d files with %s\n' "${#files[@]}" "$formatter" + "$formatter" --dry-run --Werror "${files[@]}" + ;; + *) + usage >&2 + return 2 + ;; + esac +} + +main "$@" diff --git a/src/main/cpp/app/TempifyApp.cpp b/src/main/cpp/app/TempifyApp.cpp index 11275fd..fe0bcea 100644 --- a/src/main/cpp/app/TempifyApp.cpp +++ b/src/main/cpp/app/TempifyApp.cpp @@ -1,11 +1,10 @@ #include "tempify/app/TempifyApp.h" #include "TempifyAppInternal.h" - -#include "tempify/build/BuildExecutor.h" #include "tempify/build/BuildDiffReport.h" -#include "tempify/build/BuildPlanner.h" +#include "tempify/build/BuildExecutor.h" #include "tempify/build/BuildPlanReport.h" +#include "tempify/build/BuildPlanner.h" #include "tempify/build/GenerationLock.h" #include "tempify/build/ReapplySerialization.h" #include "tempify/cli/CliParser.h" @@ -23,29 +22,29 @@ #include "tempify/support/Errors.h" #include "tempify/support/Paths.h" #include "tempify/support/Version.h" -#include "tempify/template/TemplateLoader.h" #include "tempify/template/TemplateInspector.h" #include "tempify/template/TemplateLinter.h" +#include "tempify/template/TemplateLoader.h" #include "tempify/template/TemplateTestRunner.h" #include "tempify/template/TemplateValidator.h" -#include -#include -#include -#include -#include #include +#include #include +#include #include +#include #include +#include +#include +#include +#include #include -#include #include +#include #include #include #include -#include -#include #if defined(_WIN32) #include @@ -63,23 +62,35 @@ bool stdin_is_tty() { #endif } -std::string json_escape(const std::string& value) { +std::string json_escape(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; } -std::string join_values(const std::vector& values) { +std::string join_values(const std::vector &values) { std::ostringstream stream; for (std::size_t index = 0; index < values.size(); ++index) { if (index > 0) { @@ -90,36 +101,29 @@ std::string join_values(const std::vector& values) { return stream.str(); } -bool has_hooks(const tempify::BuildPlan& plan) { - return plan.pre_hook_path.has_value() - || plan.before_render_hook_path.has_value() - || plan.after_render_hook_path.has_value() - || plan.post_hook_path.has_value(); +bool has_hooks(const tempify::BuildPlan &plan) { + return plan.pre_hook_path.has_value() || plan.before_render_hook_path.has_value() || + plan.after_render_hook_path.has_value() || plan.post_hook_path.has_value(); } std::string normalize_prompt_value(std::string value) { - const auto first = std::ranges::find_if(value, [](const unsigned char ch) { - return !std::isspace(ch); - }); + const auto first = std::ranges::find_if(value, [](const unsigned char ch) { return !std::isspace(ch); }); const auto last = std::ranges::find_if(value | std::views::reverse, [](const unsigned char ch) { - return !std::isspace(ch); - }).base(); + return !std::isspace(ch); + }).base(); if (first >= last) { return {}; } value = std::string(first, last); - std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { - return static_cast(std::tolower(ch)); - }); + std::transform(value.begin(), value.end(), value.begin(), + [](const unsigned char ch) { return static_cast(std::tolower(ch)); }); return value; } -bool hooks_disabled_for(const tempify::CliRequest& request, - const tempify::BuildPlan& plan, - const tempify::TemplateManifest& manifest, - const tempify::HookTrustStore& trust_store, - tempify::IQuestionFrontend& frontend) { +bool hooks_disabled_for(const tempify::CliRequest &request, const tempify::BuildPlan &plan, + const tempify::TemplateManifest &manifest, const tempify::HookTrustStore &trust_store, + tempify::IQuestionFrontend &frontend) { if (!has_hooks(plan)) { return false; } @@ -133,7 +137,7 @@ bool hooks_disabled_for(const tempify::CliRequest& request, return true; case tempify::HookAcceptance::Ask: const bool force_prompt = [] { - if (const char* value = std::getenv("TEMPIFY_FORCE_HOOK_PROMPT")) { + if (const char *value = std::getenv("TEMPIFY_FORCE_HOOK_PROMPT")) { return std::string_view(value) == "1"; } return false; @@ -192,17 +196,15 @@ bool hooks_disabled_for(const tempify::CliRequest& request, return false; } -bool entry_has_reapply_action(const tempify::BuildDiffEntry& entry, - const tempify::BuildReapplyAction action) { +bool entry_has_reapply_action(const tempify::BuildDiffEntry &entry, const tempify::BuildReapplyAction action) { return entry.reapply_action == action; } -tempify::BuildPlan build_reapply_write_plan(const tempify::BuildPlan& plan, - const tempify::BuildDiffReport& report) { +tempify::BuildPlan build_reapply_write_plan(const tempify::BuildPlan &plan, const tempify::BuildDiffReport &report) { std::set writable_paths; - for (const auto& entry : report.entries) { - if (entry.reapply_action == tempify::BuildReapplyAction::Create - || entry.reapply_action == tempify::BuildReapplyAction::Update) { + for (const auto &entry : report.entries) { + if (entry.reapply_action == tempify::BuildReapplyAction::Create || + entry.reapply_action == tempify::BuildReapplyAction::Update) { writable_paths.insert(entry.relative_path.generic_string()); } } @@ -213,28 +215,28 @@ tempify::BuildPlan build_reapply_write_plan(const tempify::BuildPlan& plan, writable_plan.before_render_hook_path.reset(); writable_plan.after_render_hook_path.reset(); writable_plan.post_hook_path.reset(); - std::erase_if(writable_plan.files, [&](const tempify::PlannedFile& file) { + std::erase_if(writable_plan.files, [&](const tempify::PlannedFile &file) { const auto relative_path = std::filesystem::relative(file.output_path, plan.build_root).generic_string(); return !writable_paths.contains(relative_path); }); return writable_plan; } -void validate_reapply_directory_paths(const tempify::BuildPlan& plan) { +void validate_reapply_directory_paths(const tempify::BuildPlan &plan) { if (std::filesystem::exists(plan.build_root) && !std::filesystem::is_directory(plan.build_root)) { throw tempify::TempifyError("Reapply target must be existing directory: " + plan.build_root.string()); } - for (const auto& directory : plan.directories) { + for (const auto &directory : plan.directories) { if (std::filesystem::exists(directory) && !std::filesystem::is_directory(directory)) { - throw tempify::TempifyError("Reapply blocked: required directory path exists as file: " + directory.string()); + throw tempify::TempifyError("Reapply blocked: required directory path exists as file: " + + directory.string()); } } } -void apply_reapply_deletes(const std::filesystem::path& build_root, - const tempify::BuildDiffReport& report) { - for (const auto& entry : report.entries) { +void apply_reapply_deletes(const std::filesystem::path &build_root, const tempify::BuildDiffReport &report) { + for (const auto &entry : report.entries) { if (!entry_has_reapply_action(entry, tempify::BuildReapplyAction::Delete)) { continue; } @@ -245,11 +247,11 @@ void apply_reapply_deletes(const std::filesystem::path& build_root, } } -std::map build_reapply_lock_hashes(const tempify::BuildPlan& plan, - const tempify::BuildDiffReport& report, - const tempify::GenerationLockRecord& previous_lock) { +std::map build_reapply_lock_hashes(const tempify::BuildPlan &plan, + const tempify::BuildDiffReport &report, + const tempify::GenerationLockRecord &previous_lock) { std::map hashes = tempify::build_generation_lock_managed_file_hashes(plan); - for (const auto& entry : report.entries) { + for (const auto &entry : report.entries) { if (!entry_has_reapply_action(entry, tempify::BuildReapplyAction::Keep)) { continue; } @@ -263,15 +265,15 @@ std::map build_reapply_lock_hashes(const tempify::Buil return hashes; } -void write_resolved_answers_file(const std::optional& answers_path, - const tempify::TemplateManifest& manifest, - const std::map& values) { +void write_resolved_answers_file(const std::optional &answers_path, + const tempify::TemplateManifest &manifest, + const std::map &values) { if (!answers_path.has_value()) { return; } std::map answer_values; - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (const auto it = values.find(question.key); it != values.end()) { answer_values[question.key] = it->second; } @@ -279,7 +281,7 @@ void write_resolved_answers_file(const std::optional& ans tempify::write_answer_file(*answers_path, answer_values); } -void ensure_reapply_ready(const tempify::BuildDiffReport& report) { +void ensure_reapply_ready(const tempify::BuildDiffReport &report) { if (!report.origin.detected) { throw tempify::TempifyError("`--reapply` requires existing .tempify-lock.json in target directory."); } @@ -291,9 +293,7 @@ void ensure_reapply_ready(const tempify::BuildDiffReport& report) { throw tempify::build_reapply_blocked_error(report); } -void append_text_field(std::ostringstream& stream, - const std::string& label, - const std::string& value, +void append_text_field(std::ostringstream &stream, const std::string &label, const std::string &value, const bool full) { if (!full && value.empty()) { return; @@ -302,10 +302,7 @@ void append_text_field(std::ostringstream& stream, stream << " " << label << ": " << (value.empty() ? "" : value) << '\n'; } -void append_text_bool(std::ostringstream& stream, - const std::string& label, - const bool value, - const bool full) { +void append_text_bool(std::ostringstream &stream, const std::string &label, const bool value, const bool full) { if (!full && !value) { return; } @@ -313,10 +310,8 @@ void append_text_bool(std::ostringstream& stream, stream << " " << label << ": " << (value ? "yes" : "no") << '\n'; } -void append_text_string_list(std::ostringstream& stream, - const std::string& label, - const std::vector& values, - const bool full) { +void append_text_string_list(std::ostringstream &stream, const std::string &label, + const std::vector &values, const bool full) { if (!full && values.empty()) { return; } @@ -324,12 +319,11 @@ void append_text_string_list(std::ostringstream& stream, stream << " " << label << ": " << (values.empty() ? "[]" : join_values(values)) << '\n'; } -std::string format_questions_text(const tempify::TemplateManifest& manifest, - const bool full) { +std::string format_questions_text(const tempify::TemplateManifest &manifest, const bool full) { std::ostringstream stream; std::vector group_order = manifest.question_group_order; if (group_order.empty()) { - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (std::ranges::find(group_order, question.group) == group_order.end()) { group_order.push_back(question.group); } @@ -347,9 +341,9 @@ std::string format_questions_text(const tempify::TemplateManifest& manifest, return stream.str(); } - for (const auto& group_name : group_order) { + for (const auto &group_name : group_order) { stream << "\n[" << group_name << "]\n"; - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (question.group != group_name) { continue; } @@ -359,10 +353,8 @@ std::string format_questions_text(const tempify::TemplateManifest& manifest, append_text_bool(stream, "optional", question.optional, full); append_text_bool(stream, "sensitive", question.sensitive, full); append_text_field(stream, "help", question.help, full); - append_text_bool(stream, - "condition", - question.condition_is_function || question.condition_value.has_value(), - full); + append_text_bool(stream, "condition", + question.condition_is_function || question.condition_value.has_value(), full); append_text_bool(stream, "validate", question.validate_is_function, full); append_text_string_list(stream, "aliases", question.aliases, full); append_text_string_list(stream, "choices", question.choices, full); @@ -373,7 +365,7 @@ std::string format_questions_text(const tempify::TemplateManifest& manifest, return stream.str(); } -std::size_t count_hook_phases(const tempify::TemplateManifest& manifest) { +std::size_t count_hook_phases(const tempify::TemplateManifest &manifest) { std::size_t count = 0; if (manifest.pre_hook_path.has_value()) { ++count; @@ -390,7 +382,7 @@ std::size_t count_hook_phases(const tempify::TemplateManifest& manifest) { return count; } -std::string format_template_info_text(const tempify::TemplateManifest& manifest) { +std::string format_template_info_text(const tempify::TemplateManifest &manifest) { std::ostringstream stream; stream << manifest.info.id; if (!manifest.info.name.empty()) { @@ -416,7 +408,7 @@ std::string format_template_info_text(const tempify::TemplateManifest& manifest) return stream.str(); } -std::string format_template_info_json(const tempify::TemplateManifest& manifest) { +std::string format_template_info_json(const tempify::TemplateManifest &manifest) { std::ostringstream stream; stream << "{\n"; stream << " \"template_id\": \"" << json_escape(manifest.info.id) << "\",\n"; @@ -451,14 +443,14 @@ std::string format_template_info_json(const tempify::TemplateManifest& manifest) return stream.str(); } -std::size_t count_direct_template_dirs(const std::optional& workspace_templates_root) { +std::size_t count_direct_template_dirs(const std::optional &workspace_templates_root) { std::error_code error; if (!workspace_templates_root.has_value() || !std::filesystem::is_directory(*workspace_templates_root, error)) { return 0; } std::size_t count = 0; - for (const auto& entry : std::filesystem::directory_iterator(*workspace_templates_root, error)) { + for (const auto &entry : std::filesystem::directory_iterator(*workspace_templates_root, error)) { if (error) { break; } @@ -469,13 +461,12 @@ std::size_t count_direct_template_dirs(const std::optional& workspace_templates_root, - const std::filesystem::path& global_config_path, - const std::optional& workspace_config_path, - const tempify::LocalTemplateStore& store, - const std::string& shared_index_status, - const std::string& catalog_status) { +std::string format_doctor_text(const std::filesystem::path &data_root, + const std::optional &workspace_templates_root, + const std::filesystem::path &global_config_path, + const std::optional &workspace_config_path, + const tempify::LocalTemplateStore &store, const std::string &shared_index_status, + const std::string &catalog_status) { std::ostringstream stream; stream << "Tempify Doctor\n"; stream << "Data root: " << data_root.string() << '\n'; @@ -504,21 +495,22 @@ std::string format_doctor_text(const std::filesystem::path& data_root, return stream.str(); } -std::string format_doctor_json(const std::filesystem::path& data_root, - const std::optional& workspace_templates_root, - const std::filesystem::path& global_config_path, - const std::optional& workspace_config_path, - const tempify::LocalTemplateStore& store, - const std::string& shared_index_status, - const std::string& catalog_status) { +std::string format_doctor_json(const std::filesystem::path &data_root, + const std::optional &workspace_templates_root, + const std::filesystem::path &global_config_path, + const std::optional &workspace_config_path, + const tempify::LocalTemplateStore &store, const std::string &shared_index_status, + const std::string &catalog_status) { std::ostringstream stream; stream << "{\n"; stream << " \"data_root\": \"" << json_escape(data_root.string()) << "\",\n"; stream << " \"global_config\": \"" << json_escape(global_config_path.string()) << "\",\n"; - stream << " \"global_config_exists\": " << (std::filesystem::is_regular_file(global_config_path) ? "true" : "false") << ",\n"; + stream << " \"global_config_exists\": " + << (std::filesystem::is_regular_file(global_config_path) ? "true" : "false") << ",\n"; stream << " \"shared_templates_root\": \"" << json_escape(store.templates_root().string()) << "\",\n"; stream << " \"shared_index\": \"" << json_escape(store.index_file().string()) << "\",\n"; - stream << " \"shared_index_exists\": " << (std::filesystem::is_regular_file(store.index_file()) ? "true" : "false") << ",\n"; + stream << " \"shared_index_exists\": " << (std::filesystem::is_regular_file(store.index_file()) ? "true" : "false") + << ",\n"; stream << " \"shared_index_status\": \"" << json_escape(shared_index_status) << "\",\n"; stream << " \"workspace_config\": "; if (workspace_config_path.has_value()) { @@ -540,15 +532,15 @@ std::string format_doctor_json(const std::filesystem::path& data_root, return stream.str(); } -std::string format_catalog_json(const tempify::app_internal::TemplateCatalog& catalog) { +std::string format_catalog_json(const tempify::app_internal::TemplateCatalog &catalog) { std::ostringstream stream; stream << "{\n"; stream << " \"total\": " << catalog.visible.size() << ",\n"; stream << " \"templates\": [\n"; for (std::size_t index = 0; index < catalog.visible.size(); ++index) { - const auto& record = catalog.visible[index]; - const auto& info = record.info; - const char* status = "available"; + const auto &record = catalog.visible[index]; + const auto &info = record.info; + const char *status = "available"; switch (record.status) { case tempify::app_internal::VisibleTemplateStatus::Workspace: status = "workspace"; @@ -560,11 +552,9 @@ std::string format_catalog_json(const tempify::app_internal::TemplateCatalog& ca status = "available"; break; } - stream << " {\"id\": \"" << json_escape(info.id) - << "\", \"name\": \"" << json_escape(info.name) - << "\", \"description\": \"" << json_escape(info.description) - << "\", \"version\": \"" << json_escape(info.version) - << "\", \"installed\": " << (record.installed ? "true" : "false") + stream << " {\"id\": \"" << json_escape(info.id) << "\", \"name\": \"" << json_escape(info.name) + << "\", \"description\": \"" << json_escape(info.description) << "\", \"version\": \"" + << json_escape(info.version) << "\", \"installed\": " << (record.installed ? "true" : "false") << ", \"status\": \"" << status << "\", \"root\": "; if (info.root.empty()) { stream << "null"; @@ -582,7 +572,7 @@ std::string format_catalog_json(const tempify::app_internal::TemplateCatalog& ca return stream.str(); } -std::string format_available_template_info_text(const tempify::AvailableTemplateRecord& record) { +std::string format_available_template_info_text(const tempify::AvailableTemplateRecord &record) { std::ostringstream stream; stream << record.id; if (!record.name.empty()) { @@ -599,7 +589,7 @@ std::string format_available_template_info_text(const tempify::AvailableTemplate return stream.str(); } -std::string format_available_template_info_json(const tempify::AvailableTemplateRecord& record) { +std::string format_available_template_info_json(const tempify::AvailableTemplateRecord &record) { std::ostringstream stream; stream << "{\n"; stream << " \"template_id\": \"" << json_escape(record.id) << "\",\n"; @@ -615,8 +605,7 @@ std::string format_available_template_info_json(const tempify::AvailableTemplate return stream.str(); } -std::string format_refresh_json(const std::size_t count, - const std::filesystem::path& index_file) { +std::string format_refresh_json(const std::size_t count, const std::filesystem::path &index_file) { std::ostringstream stream; stream << "{\n"; stream << " \"refreshed\": " << count << ",\n"; @@ -625,7 +614,7 @@ std::string format_refresh_json(const std::size_t count, return stream.str(); } -std::string format_validate_json(const tempify::TemplateManifest& manifest) { +std::string format_validate_json(const tempify::TemplateManifest &manifest) { std::ostringstream stream; stream << "{\n"; stream << " \"template_id\": \"" << json_escape(manifest.info.id) << "\",\n"; @@ -634,7 +623,7 @@ std::string format_validate_json(const tempify::TemplateManifest& manifest) { return stream.str(); } -std::string format_inspect_json(const tempify::TemplateManifest& manifest) { +std::string format_inspect_json(const tempify::TemplateManifest &manifest) { std::ostringstream stream; stream << "{\n"; stream << " \"template_id\": \"" << json_escape(manifest.info.id) << "\",\n"; @@ -643,9 +632,9 @@ std::string format_inspect_json(const tempify::TemplateManifest& manifest) { stream << " \"output\": \"" << json_escape(manifest.output_path_template) << "\",\n"; stream << " \"source_roots\": [\n"; for (std::size_t index = 0; index < manifest.source_roots.size(); ++index) { - const auto& source_root = manifest.source_roots[index]; - stream << " {\"template_id\": \"" << json_escape(source_root.template_id) - << "\", \"path\": \"" << json_escape(source_root.path.string()) << "\"}"; + const auto &source_root = manifest.source_roots[index]; + stream << " {\"template_id\": \"" << json_escape(source_root.template_id) << "\", \"path\": \"" + << json_escape(source_root.path.string()) << "\"}"; if (index + 1 < manifest.source_roots.size()) { stream << ','; } @@ -663,10 +652,10 @@ std::string format_inspect_json(const tempify::TemplateManifest& manifest) { stream << " ],\n"; stream << " \"files\": [\n"; for (std::size_t index = 0; index < manifest.files.size(); ++index) { - const auto& file = manifest.files[index]; - stream << " {\"relative_path\": \"" << json_escape(file.relative_path) - << "\", \"source_template_id\": \"" << json_escape(file.source_template_id) - << "\", \"source_path\": \"" << json_escape(file.source_path.string()) + const auto &file = manifest.files[index]; + stream << " {\"relative_path\": \"" << json_escape(file.relative_path) << "\", \"source_template_id\": \"" + << json_escape(file.source_template_id) << "\", \"source_path\": \"" + << json_escape(file.source_path.string()) << "\", \"render\": " << (file.render_with_prebyte ? "true" : "false") << ", \"excluded\": " << (file.excluded ? "true" : "false") << '}'; if (index + 1 < manifest.files.size()) { @@ -677,13 +666,12 @@ std::string format_inspect_json(const tempify::TemplateManifest& manifest) { stream << " ],\n"; stream << " \"questions\": [\n"; for (std::size_t index = 0; index < manifest.questions.size(); ++index) { - const auto& question = manifest.questions[index]; - stream << " {\"key\": \"" << json_escape(question.key) - << "\", \"type\": \"" << json_escape(question.type) - << "\", \"group\": \"" << json_escape(question.group) - << "\", \"sensitive\": " << (question.sensitive ? "true" : "false") - << "\", \"source_path\": \"" << json_escape(question.source_path.string()) - << "\", \"source_index\": " << question.source_index << '}'; + const auto &question = manifest.questions[index]; + stream << " {\"key\": \"" << json_escape(question.key) << "\", \"type\": \"" << json_escape(question.type) + << "\", \"group\": \"" << json_escape(question.group) + << "\", \"sensitive\": " << (question.sensitive ? "true" : "false") << "\", \"source_path\": \"" + << json_escape(question.source_path.string()) << "\", \"source_index\": " << question.source_index + << '}'; if (index + 1 < manifest.questions.size()) { stream << ','; } @@ -692,23 +680,21 @@ std::string format_inspect_json(const tempify::TemplateManifest& manifest) { stream << " ],\n"; stream << " \"layout_rules\": [\n"; for (std::size_t index = 0; index < manifest.layout_rules.size(); ++index) { - const auto& rule = manifest.layout_rules[index]; - stream << " {\"source\": \"" << json_escape(rule.source) - << "\", \"target\": "; + const auto &rule = manifest.layout_rules[index]; + stream << " {\"source\": \"" << json_escape(rule.source) << "\", \"target\": "; if (rule.target.has_value()) { stream << "\"" << json_escape(*rule.target) << "\""; } else { stream << "null"; } - stream << ", \"exclude\": " << (rule.exclude ? "true" : "false") - << ", \"render\": "; + stream << ", \"exclude\": " << (rule.exclude ? "true" : "false") << ", \"render\": "; if (rule.render.has_value()) { stream << (*rule.render ? "true" : "false"); } else { stream << "null"; } - stream << ", \"source_template_id\": \"" << json_escape(rule.source_template_id) - << "\", \"source_path\": \"" << json_escape(rule.source_path.string()) << "\"}"; + stream << ", \"source_template_id\": \"" << json_escape(rule.source_template_id) << "\", \"source_path\": \"" + << json_escape(rule.source_path.string()) << "\"}"; if (index + 1 < manifest.layout_rules.size()) { stream << ','; } @@ -717,9 +703,9 @@ std::string format_inspect_json(const tempify::TemplateManifest& manifest) { stream << " ],\n"; stream << " \"scripts\": [\n"; for (std::size_t index = 0; index < manifest.scripts.size(); ++index) { - const auto& script = manifest.scripts[index]; - stream << " {\"name\": \"" << json_escape(script.name) - << "\", \"path\": \"" << json_escape(script.path.string()) << "\"}"; + const auto &script = manifest.scripts[index]; + stream << " {\"name\": \"" << json_escape(script.name) << "\", \"path\": \"" + << json_escape(script.path.string()) << "\"}"; if (index + 1 < manifest.scripts.size()) { stream << ','; } @@ -727,7 +713,8 @@ std::string format_inspect_json(const tempify::TemplateManifest& manifest) { } stream << " ],\n"; stream << " \"hooks\": {\n"; - auto append_hook_path = [&](const std::string& name, const std::optional& path, const bool trailing_comma) { + auto append_hook_path = [&](const std::string &name, const std::optional &path, + const bool trailing_comma) { stream << " \"" << name << "\": "; if (path.has_value()) { stream << "\"" << json_escape(path->string()) << "\""; @@ -748,8 +735,7 @@ std::string format_inspect_json(const tempify::TemplateManifest& manifest) { return stream.str(); } -std::string format_lint_json(const std::string& template_id, - const std::vector& warnings) { +std::string format_lint_json(const std::string &template_id, const std::vector &warnings) { std::ostringstream stream; stream << "{\n"; stream << " \"template_id\": \"" << json_escape(template_id) << "\",\n"; @@ -767,7 +753,7 @@ std::string format_lint_json(const std::string& template_id, return stream.str(); } -void write_text_file(const std::filesystem::path& path, const std::string& content) { +void write_text_file(const std::filesystem::path &path, const std::string &content) { if (path.has_parent_path()) { std::filesystem::create_directories(path.parent_path()); } @@ -779,8 +765,7 @@ void write_text_file(const std::filesystem::path& path, const std::string& conte output << content; } -bool has_cli_assignment(const std::vector& args, - const std::vector& flags) { +bool has_cli_assignment(const std::vector &args, const std::vector &flags) { for (std::size_t index = 0; index < args.size(); ++index) { for (const auto flag : flags) { if (args[index] == flag) { @@ -794,13 +779,11 @@ bool has_cli_assignment(const std::vector& args, return false; } -tempify::CliRequest apply_config_defaults(const tempify::CliRequest& request, - const std::vector& raw_args, - const tempify::TempifyConfig& config) { +tempify::CliRequest apply_config_defaults(const tempify::CliRequest &request, const std::vector &raw_args, + const tempify::TempifyConfig &config) { tempify::CliRequest effective = request; - if (config.hook_acceptance.has_value() && - !has_cli_assignment(raw_args, {"--accept-hooks", "--no-hooks"})) { + if (config.hook_acceptance.has_value() && !has_cli_assignment(raw_args, {"--accept-hooks", "--no-hooks"})) { effective.hook_acceptance = *config.hook_acceptance; } @@ -808,8 +791,7 @@ tempify::CliRequest apply_config_defaults(const tempify::CliRequest& request, effective.hook_timeout_ms = *config.hook_timeout_ms; } - if (config.existing_path_behavior.has_value() && - !effective.existing_path_behavior_override.has_value() && + if (config.existing_path_behavior.has_value() && !effective.existing_path_behavior_override.has_value() && !has_cli_assignment(raw_args, {"-f", "--overwrite-if-exists", "-s", "--skip-if-file-exists"})) { effective.existing_path_behavior_override = config.existing_path_behavior; } @@ -817,11 +799,11 @@ tempify::CliRequest apply_config_defaults(const tempify::CliRequest& request, return effective; } -} +} // namespace namespace tempify { -int TempifyApp::run(const std::vector& args) const { +int TempifyApp::run(const std::vector &args) const { CliParser parser; CliRequest request = parser.parse(args); @@ -878,39 +860,31 @@ int TempifyApp::run(const std::vector& args) const { std::string shared_index_status = "ok"; try { static_cast(store.list_templates()); - } catch (const TempifyError& error) { + } catch (const TempifyError &error) { shared_index_status = error.what(); } std::string catalog_status; try { - const app_internal::TemplateCatalog catalog = app_internal::build_catalog(workspace_templates_root, store, available_cache, loader); + const app_internal::TemplateCatalog catalog = + app_internal::build_catalog(workspace_templates_root, store, available_cache, loader); catalog_status = std::to_string(catalog.infos.size()) + " templates visible"; - } catch (const TempifyError& error) { + } catch (const TempifyError &error) { catalog_status = error.what(); } if (request.doctor_json) { - std::cout << format_doctor_json(data_root, - workspace_templates_root, - global_config_path, - workspace_config_path, - store, - shared_index_status, - catalog_status); + std::cout << format_doctor_json(data_root, workspace_templates_root, global_config_path, + workspace_config_path, store, shared_index_status, catalog_status); } else { - std::cout << format_doctor_text(data_root, - workspace_templates_root, - global_config_path, - workspace_config_path, - store, - shared_index_status, - catalog_status); + std::cout << format_doctor_text(data_root, workspace_templates_root, global_config_path, + workspace_config_path, store, shared_index_status, catalog_status); } return 0; } - const app_internal::TemplateCatalog catalog = app_internal::build_catalog(workspace_templates_root, store, available_cache, loader); + const app_internal::TemplateCatalog catalog = + app_internal::build_catalog(workspace_templates_root, store, available_cache, loader); if (request.mode == CliMode::TemplateList) { if (request.list_json) { @@ -925,7 +899,7 @@ int TempifyApp::run(const std::vector& args) const { std::optional template_root; try { template_root = app_internal::resolve_template_root(request, catalog, store); - } catch (const TempifyError& error) { + } catch (const TempifyError &error) { if (std::string_view(error.what()).find("Template not found: ") != 0) { throw; } @@ -998,24 +972,24 @@ int TempifyApp::run(const std::vector& args) const { const TemplateTestRunner tester(lua_engine, renderer); if (request.test_list_fixtures) { if (request.test_json) { - std::cout << format_template_fixture_listing_json(manifest.info.id, - tester.list_fixtures(manifest, request.test_fixture_name)); + std::cout << format_template_fixture_listing_json( + manifest.info.id, tester.list_fixtures(manifest, request.test_fixture_name)); } else { - for (const auto& name : tester.list_fixture_names(manifest, request.test_fixture_name)) { + for (const auto &name : tester.list_fixture_names(manifest, request.test_fixture_name)) { std::cout << name << '\n'; } } return 0; } const TemplateTestReport report = request.test_update_snapshots - ? tester.update_snapshots(manifest, request.test_fixture_name) - : tester.run(manifest, request.test_fixture_name); + ? tester.update_snapshots(manifest, request.test_fixture_name) + : tester.run(manifest, request.test_fixture_name); if (request.test_json) { std::cout << format_template_test_report_json(report); } else { std::cout << format_template_test_report(report); } - for (const auto& fixture : report.fixtures) { + for (const auto &fixture : report.fixtures) { if (fixture.failure_message.has_value()) { return 1; } @@ -1045,12 +1019,7 @@ int TempifyApp::run(const std::vector& args) const { QuestionProcessor question_processor(lua_engine, *frontend); const std::map values = question_processor.collect( - manifest, - request.variables, - config.defaults, - imported_values, - request.non_interactive, - request.strict, + manifest, request.variables, config.defaults, imported_values, request.non_interactive, request.strict, request.use_tui && !request.non_interactive); const PrebyteRenderer renderer; @@ -1081,10 +1050,11 @@ int TempifyApp::run(const std::vector& args) const { } if (request.reapply) { - const std::optional previous_lock = load_generation_lock(plan.build_root / ".tempify-lock.json"); + const std::optional previous_lock = + load_generation_lock(plan.build_root / ".tempify-lock.json"); if (!previous_lock.has_value()) { - throw TempifyError("`--reapply` requires existing .tempify-lock.json in target directory: " - + (plan.build_root / ".tempify-lock.json").string()); + throw TempifyError("`--reapply` requires existing .tempify-lock.json in target directory: " + + (plan.build_root / ".tempify-lock.json").string()); } const BuildDiffReport report = build_diff_report(plan, manifest, values, renderer); @@ -1109,11 +1079,7 @@ int TempifyApp::run(const std::vector& args) const { write_resolved_answers_file(request.write_answers_file, manifest, values); write_text_file(plan.build_root / ".tempify-lock.json", - format_generation_lock_json(manifest, - plan, - values, - request.hook_acceptance, - true, + format_generation_lock_json(manifest, plan, values, request.hook_acceptance, true, build_reapply_lock_hashes(plan, report, *previous_lock))); if (request.diff_json) { @@ -1126,9 +1092,10 @@ int TempifyApp::run(const std::vector& args) const { const HookTrustStore trust_store(default_hook_trust_store_path(data_root)); const bool disable_hooks = hooks_disabled_for(request, plan, manifest, trust_store, *frontend); - const std::optional hook_timeout = request.hook_timeout_ms == 0 - ? std::nullopt - : std::optional(std::chrono::milliseconds(request.hook_timeout_ms)); + const std::optional hook_timeout = + request.hook_timeout_ms == 0 + ? std::nullopt + : std::optional(std::chrono::milliseconds(request.hook_timeout_ms)); const BuildExecutor executor(renderer, lua_engine); executor.execute(plan, manifest, values, disable_hooks, hook_timeout); @@ -1136,14 +1103,10 @@ int TempifyApp::run(const std::vector& args) const { write_resolved_answers_file(request.write_answers_file, manifest, values); write_text_file(plan.build_root / ".tempify-lock.json", - format_generation_lock_json(manifest, - plan, - values, - request.hook_acceptance, - disable_hooks)); + format_generation_lock_json(manifest, plan, values, request.hook_acceptance, disable_hooks)); std::cout << "Generated " << manifest.info.id << " -> " << plan.build_root.string() << '\n'; return 0; } -} +} // namespace tempify diff --git a/src/main/cpp/app/TempifyAppSupport.cpp b/src/main/cpp/app/TempifyAppSupport.cpp index 7501033..34f3bf3 100644 --- a/src/main/cpp/app/TempifyAppSupport.cpp +++ b/src/main/cpp/app/TempifyAppSupport.cpp @@ -1,5 +1,4 @@ #include "TempifyAppInternal.h" - #include "tempify/frontend/IQuestionFrontend.h" #include "tempify/frontend/PlainCliFrontend.h" #include "tempify/frontend/WizardFrontend.h" @@ -17,8 +16,8 @@ namespace tempify::app_internal { namespace { -bool visible_record_beats(const tempify::app_internal::VisibleTemplateRecord& candidate, - const tempify::app_internal::VisibleTemplateRecord& current) { +bool visible_record_beats(const tempify::app_internal::VisibleTemplateRecord &candidate, + const tempify::app_internal::VisibleTemplateRecord ¤t) { using tempify::app_internal::VisibleTemplateStatus; auto rank = [](const VisibleTemplateStatus status) { @@ -36,14 +35,14 @@ bool visible_record_beats(const tempify::app_internal::VisibleTemplateRecord& ca return rank(candidate.status) > rank(current.status); } -std::vector scan_template_roots(const std::filesystem::path& templates_root) { +std::vector scan_template_roots(const std::filesystem::path &templates_root) { std::vector roots; std::error_code error; if (!std::filesystem::is_directory(templates_root, error)) { return roots; } - for (const auto& entry : std::filesystem::directory_iterator(templates_root, error)) { + for (const auto &entry : std::filesystem::directory_iterator(templates_root, error)) { if (error) { break; } @@ -56,21 +55,20 @@ std::vector scan_template_roots(const std::filesystem::pa return roots; } -} +} // namespace -TemplateCatalog build_catalog(const std::optional& workspace_templates_root, - const LocalTemplateStore& store, - const AvailableTemplateCache& available_cache, - const TemplateLoader& loader) { +TemplateCatalog build_catalog(const std::optional &workspace_templates_root, + const LocalTemplateStore &store, const AvailableTemplateCache &available_cache, + const TemplateLoader &loader) { TemplateCatalog catalog; std::map visible_by_id; if (workspace_templates_root.has_value()) { - for (const auto& root : scan_template_roots(*workspace_templates_root)) { + for (const auto &root : scan_template_roots(*workspace_templates_root)) { TemplateInfo info; try { info = loader.summarize(root); - } catch (const TempifyError&) { + } catch (const TempifyError &) { continue; } if (catalog.index.contains(info.id)) { @@ -78,7 +76,7 @@ TemplateCatalog build_catalog(const std::optional& worksp } catalog.index[info.id] = root; catalog.infos.push_back(std::move(info)); - const TemplateInfo& stored = catalog.infos.back(); + const TemplateInfo &stored = catalog.infos.back(); visible_by_id[stored.id] = VisibleTemplateRecord{ .info = stored, .status = VisibleTemplateStatus::Workspace, @@ -88,13 +86,14 @@ TemplateCatalog build_catalog(const std::optional& worksp } std::set shared_ids; - for (const auto& entry : store.list_templates()) { + for (const auto &entry : store.list_templates()) { if (!shared_ids.insert(entry.id).second) { throw TempifyError("Duplicate shared template id found in index: " + entry.id); } std::error_code error; - if (!std::filesystem::is_directory(entry.path, error) || !std::filesystem::is_regular_file(entry.path / "template.lua", error)) { + if (!std::filesystem::is_directory(entry.path, error) || + !std::filesystem::is_regular_file(entry.path / "template.lua", error)) { continue; } @@ -118,16 +117,17 @@ TemplateCatalog build_catalog(const std::optional& worksp }; } - for (const auto& entry : available_cache.list_templates()) { + for (const auto &entry : available_cache.list_templates()) { catalog.available_index[entry.id] = entry; VisibleTemplateRecord candidate{ - .info = TemplateInfo{ - .id = entry.id, - .name = entry.name, - .description = entry.description, - .version = entry.version, - .root = {}, - }, + .info = + TemplateInfo{ + .id = entry.id, + .name = entry.name, + .description = entry.description, + .version = entry.version, + .root = {}, + }, .status = VisibleTemplateStatus::Available, .installed = false, .available = entry, @@ -142,22 +142,20 @@ TemplateCatalog build_catalog(const std::optional& worksp } std::ranges::sort(catalog.infos, {}, &TemplateInfo::id); - for (const auto& [id, record] : visible_by_id) { + for (const auto &[id, record] : visible_by_id) { static_cast(id); catalog.visible.push_back(record); } - std::ranges::sort(catalog.visible, {}, [](const VisibleTemplateRecord& record) { - return record.info.id; - }); + std::ranges::sort(catalog.visible, {}, [](const VisibleTemplateRecord &record) { return record.info.id; }); return catalog; } -std::filesystem::path resolve_template_root(const CliRequest& request, - const TemplateCatalog& catalog, - const LocalTemplateStore& store) { +std::filesystem::path resolve_template_root(const CliRequest &request, const TemplateCatalog &catalog, + const LocalTemplateStore &store) { const std::filesystem::path candidate = request.template_ref; std::error_code error; - if (std::filesystem::is_directory(candidate, error) && std::filesystem::is_regular_file(candidate / "template.lua", error)) { + if (std::filesystem::is_directory(candidate, error) && + std::filesystem::is_regular_file(candidate / "template.lua", error)) { return std::filesystem::absolute(candidate); } @@ -168,8 +166,8 @@ std::filesystem::path resolve_template_root(const CliRequest& request, if (const auto shared = store.find_template(request.template_ref)) { std::error_code shared_error; - if (std::filesystem::is_directory(shared->path, shared_error) - && std::filesystem::is_regular_file(shared->path / "template.lua", shared_error)) { + if (std::filesystem::is_directory(shared->path, shared_error) && + std::filesystem::is_regular_file(shared->path / "template.lua", shared_error)) { return std::filesystem::absolute(shared->path); } throw TempifyError("Shared template '" + request.template_ref + "' is missing on disk. Run `tempify refresh`."); @@ -178,14 +176,14 @@ std::filesystem::path resolve_template_root(const CliRequest& request, throw TempifyError("Template not found: " + request.template_ref); } -void print_catalog(const TemplateCatalog& catalog) { +void print_catalog(const TemplateCatalog &catalog) { if (catalog.visible.empty()) { std::cout << "No compatible Tempify templates found\n"; return; } - for (const auto& record : catalog.visible) { - const auto& info = record.info; + for (const auto &record : catalog.visible) { + const auto &info = record.info; std::cout << info.id; if (!info.name.empty()) { std::cout << "\t" << info.name; @@ -216,4 +214,4 @@ std::unique_ptr make_frontend(const bool use_tui) { return std::make_unique(); } -} +} // namespace tempify::app_internal diff --git a/src/main/cpp/build/BuildDiffReport.cpp b/src/main/cpp/build/BuildDiffReport.cpp index 563eaaa..2adc683 100644 --- a/src/main/cpp/build/BuildDiffReport.cpp +++ b/src/main/cpp/build/BuildDiffReport.cpp @@ -1,14 +1,12 @@ #include "tempify/build/BuildDiffReport.h" +#include "PrebyteEngine.h" #include "tempify/build/GenerationLock.h" - #include "tempify/prebyte/PrebyteRenderer.h" #include "tempify/support/Errors.h" -#include "PrebyteEngine.h" - -#include #include +#include #include #include #include @@ -21,7 +19,7 @@ namespace tempify { namespace { -std::string read_file(const std::filesystem::path& path) { +std::string read_file(const std::filesystem::path &path) { std::ifstream input(path, std::ios::binary); if (!input) { throw TempifyError("Could not read file: " + path.string()); @@ -31,9 +29,7 @@ std::string read_file(const std::filesystem::path& path) { return stream.str(); } -std::string render_planned_file(const PlannedFile& file, - const PrebyteRenderer& renderer, - prebyte::Prebyte& engine) { +std::string render_planned_file(const PlannedFile &file, const PrebyteRenderer &renderer, prebyte::Prebyte &engine) { static_cast(renderer); if (file.render_with_prebyte) { return renderer.render_string(engine, read_file(file.source_path)); @@ -41,17 +37,29 @@ std::string render_planned_file(const PlannedFile& file, return read_file(file.source_path); } -std::string json_escape(const std::string& value) { +std::string json_escape(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; @@ -153,7 +161,7 @@ std::string update_policy_action_name(const BuildUpdatePolicyAction action) { return "unavailable"; } -std::string template_label(const std::string& template_id, const std::string& template_version) { +std::string template_label(const std::string &template_id, const std::string &template_version) { if (template_id.empty() && template_version.empty()) { return ""; } @@ -166,7 +174,7 @@ std::string template_label(const std::string& template_id, const std::string& te return template_id + "@" + template_version; } -std::string version_label(const std::string& version) { +std::string version_label(const std::string &version) { return version.empty() ? std::string{""} : version; } @@ -238,9 +246,8 @@ std::optional parse_semver(std::string_view version) { std::size_t start = 0; while (start <= version.size()) { const std::size_t end = version.find('.', start); - const std::string_view token = end == std::string_view::npos - ? version.substr(start) - : version.substr(start, end - start); + const std::string_view token = + end == std::string_view::npos ? version.substr(start) : version.substr(start, end - start); if (token.empty()) { return std::nullopt; } @@ -266,8 +273,8 @@ std::optional parse_semver(std::string_view version) { while (build_start <= build.size()) { const std::size_t build_end = build.find('.', build_start); const std::string_view token = build_end == std::string_view::npos - ? build.substr(build_start) - : build.substr(build_start, build_end - build_start); + ? build.substr(build_start) + : build.substr(build_start, build_end - build_start); if (token.empty()) { return std::nullopt; } @@ -288,8 +295,8 @@ std::optional parse_semver(std::string_view version) { while (prerelease_start <= prerelease.size()) { const std::size_t prerelease_end = prerelease.find('.', prerelease_start); const std::string_view token = prerelease_end == std::string_view::npos - ? prerelease.substr(prerelease_start) - : prerelease.substr(prerelease_start, prerelease_end - prerelease_start); + ? prerelease.substr(prerelease_start) + : prerelease.substr(prerelease_start, prerelease_end - prerelease_start); if (token.empty()) { return std::nullopt; } @@ -319,8 +326,7 @@ std::optional parse_semver(std::string_view version) { return parsed; } -int compare_version_components(const std::vector& left, - const std::vector& right) { +int compare_version_components(const std::vector &left, const std::vector &right) { const std::size_t size = std::max(left.size(), right.size()); for (std::size_t index = 0; index < size; ++index) { const int left_value = index < left.size() ? left[index] : 0; @@ -335,8 +341,8 @@ int compare_version_components(const std::vector& left, return 0; } -int compare_prerelease_identifiers(const std::vector& left, - const std::vector& right) { +int compare_prerelease_identifiers(const std::vector &left, + const std::vector &right) { const std::size_t size = std::max(left.size(), right.size()); for (std::size_t index = 0; index < size; ++index) { if (index >= left.size()) { @@ -346,8 +352,8 @@ int compare_prerelease_identifiers(const std::vector& le return 1; } - const auto& left_identifier = left[index]; - const auto& right_identifier = right[index]; + const auto &left_identifier = left[index]; + const auto &right_identifier = right[index]; if (left_identifier.numeric && right_identifier.numeric) { if (left_identifier.numeric_value < right_identifier.numeric_value) { return -1; @@ -372,8 +378,7 @@ int compare_prerelease_identifiers(const std::vector& le return 0; } -int compare_semver(const ParsedSemVer& left, - const ParsedSemVer& right) { +int compare_semver(const ParsedSemVer &left, const ParsedSemVer &right) { const int core_comparison = compare_version_components(left.core, right.core); if (core_comparison != 0) { return core_comparison; @@ -394,12 +399,11 @@ int compare_semver(const ParsedSemVer& left, return compare_prerelease_identifiers(left.prerelease, right.prerelease); } -int semver_core_component(const ParsedSemVer& version, - const std::size_t index) { +int semver_core_component(const ParsedSemVer &version, const std::size_t index) { return index < version.core.size() ? version.core[index] : 0; } -BuildUpdatePolicy build_update_policy(const BuildUpdateInfo& update) { +BuildUpdatePolicy build_update_policy(const BuildUpdateInfo &update) { switch (update.kind) { case BuildUpdateKind::NoOrigin: return BuildUpdatePolicy{ @@ -412,7 +416,8 @@ BuildUpdatePolicy build_update_policy(const BuildUpdateInfo& update) { return BuildUpdatePolicy{ .action = BuildUpdatePolicyAction::Allow, .reason = "equivalent_version_change", - .next_step = "Equivalent SemVer precedence detected. Review diff, then reapply ready actions if metadata-only version drift is expected.", + .next_step = "Equivalent SemVer precedence detected. Review diff, then reapply ready actions if " + "metadata-only version drift is expected.", }; } return BuildUpdatePolicy{ @@ -432,28 +437,32 @@ BuildUpdatePolicy build_update_policy(const BuildUpdateInfo& update) { return BuildUpdatePolicy{ .action = BuildUpdatePolicyAction::Review, .reason = "major_version_upgrade", - .next_step = "Major version upgrade detected. Automatic reapply blocked; review compatibility and managed-file changes before continuing.", + .next_step = "Major version upgrade detected. Automatic reapply blocked; review compatibility and " + "managed-file changes before continuing.", }; } if (from_major == 0 && to_major == 0 && to_minor > from_minor) { return BuildUpdatePolicy{ .action = BuildUpdatePolicyAction::Review, .reason = "pre_1_0_minor_upgrade", - .next_step = "Pre-1.0 minor upgrade detected. Automatic reapply blocked; review compatibility before continuing.", + .next_step = "Pre-1.0 minor upgrade detected. Automatic reapply blocked; review compatibility " + "before continuing.", }; } } return BuildUpdatePolicy{ .action = BuildUpdatePolicyAction::Allow, .reason = "forward_version_change", - .next_step = "Version upgrade detected. Review diff, then reapply ready actions to move managed files forward.", + .next_step = + "Version upgrade detected. Review diff, then reapply ready actions to move managed files forward.", }; } case BuildUpdateKind::Downgrade: return BuildUpdatePolicy{ .action = BuildUpdatePolicyAction::Review, .reason = "backward_version_change", - .next_step = "Version downgrade detected. Automatic reapply blocked; inspect lockfile and template version before continuing.", + .next_step = "Version downgrade detected. Automatic reapply blocked; inspect lockfile and template version " + "before continuing.", }; case BuildUpdateKind::VersionChange: return BuildUpdatePolicy{ @@ -472,17 +481,16 @@ BuildUpdatePolicy build_update_policy(const BuildUpdateInfo& update) { return BuildUpdatePolicy{}; } -std::string update_recommendation(const BuildDiffReport& report) { +std::string update_recommendation(const BuildDiffReport &report) { return report.update_policy.next_step; } -bool origin_matches_requested_template(const GenerationLockRecord& lock, - const TemplateManifest& manifest) { +bool origin_matches_requested_template(const GenerationLockRecord &lock, const TemplateManifest &manifest) { return !lock.template_info.id.empty() && lock.template_info.id == manifest.info.id; } -std::optional find_managed_file_hash(const std::optional& lock, - const std::string& relative_path) { +std::optional find_managed_file_hash(const std::optional &lock, + const std::string &relative_path) { if (!lock.has_value()) { return std::nullopt; } @@ -493,10 +501,8 @@ std::optional find_managed_file_hash(const std::optionalsecond; } -BuildDiffReason classify_change_reason(const bool origin_detected, - const std::optional& baseline_hash, - const std::string& actual_hash, - const std::string& desired_hash) { +BuildDiffReason classify_change_reason(const bool origin_detected, const std::optional &baseline_hash, + const std::string &actual_hash, const std::string &desired_hash) { if (!baseline_hash.has_value()) { return origin_detected ? BuildDiffReason::Unknown : BuildDiffReason::None; } @@ -515,10 +521,9 @@ BuildDiffReason classify_change_reason(const bool origin_detected, return BuildDiffReason::Unknown; } -BuildDiffReason classify_create_reason(const bool origin_detected, - const bool previously_managed, - const std::optional& baseline_hash, - const std::string& desired_hash) { +BuildDiffReason classify_create_reason(const bool origin_detected, const bool previously_managed, + const std::optional &baseline_hash, + const std::string &desired_hash) { if (!previously_managed) { return origin_detected ? BuildDiffReason::TemplateUpdate : BuildDiffReason::None; } @@ -528,9 +533,8 @@ BuildDiffReason classify_create_reason(const bool origin_detected, return desired_hash == *baseline_hash ? BuildDiffReason::LocalEdit : BuildDiffReason::Conflict; } -BuildDiffReason classify_delete_reason(const bool origin_detected, - const std::optional& baseline_hash, - const std::filesystem::path& actual_path) { +BuildDiffReason classify_delete_reason(const bool origin_detected, const std::optional &baseline_hash, + const std::filesystem::path &actual_path) { if (!std::filesystem::is_regular_file(actual_path)) { return BuildDiffReason::TypeMismatch; } @@ -541,8 +545,7 @@ BuildDiffReason classify_delete_reason(const bool origin_detected, return actual_hash == *baseline_hash ? BuildDiffReason::TemplateUpdate : BuildDiffReason::LocalEdit; } -BuildReapplyAction classify_reapply_action(const BuildDiffStatus status, - const BuildDiffReason reason) { +BuildReapplyAction classify_reapply_action(const BuildDiffStatus status, const BuildDiffReason reason) { switch (status) { case BuildDiffStatus::Unchanged: return BuildReapplyAction::None; @@ -590,9 +593,9 @@ BuildReapplyAction classify_reapply_action(const BuildDiffStatus status, return BuildReapplyAction::Review; } -BuildReapplySummary build_reapply_summary(const BuildDiffReport& report) { +BuildReapplySummary build_reapply_summary(const BuildDiffReport &report) { BuildReapplySummary summary; - for (const auto& entry : report.entries) { + for (const auto &entry : report.entries) { switch (entry.reapply_action) { case BuildReapplyAction::None: break; @@ -636,7 +639,7 @@ BuildReapplySummary build_reapply_summary(const BuildDiffReport& report) { return summary; } -BuildUpdateInfo build_update_info(const BuildDiffReport& report) { +BuildUpdateInfo build_update_info(const BuildDiffReport &report) { BuildUpdateInfo update; update.from_version = report.origin.template_version; update.to_version = report.template_version; @@ -667,9 +670,9 @@ BuildUpdateInfo build_update_info(const BuildDiffReport& report) { return update; } -std::array status_counts(const BuildDiffReport& report) { +std::array status_counts(const BuildDiffReport &report) { std::array counts = {0, 0, 0, 0}; - for (const auto& entry : report.entries) { + for (const auto &entry : report.entries) { switch (entry.status) { case BuildDiffStatus::Unchanged: ++counts[0]; @@ -688,12 +691,10 @@ std::array status_counts(const BuildDiffReport& report) { return counts; } -} +} // namespace -BuildDiffReport build_diff_report(const BuildPlan& plan, - const TemplateManifest& manifest, - const std::map& values, - const PrebyteRenderer& renderer) { +BuildDiffReport build_diff_report(const BuildPlan &plan, const TemplateManifest &manifest, + const std::map &values, const PrebyteRenderer &renderer) { prebyte::Prebyte engine; renderer.configure(engine, values, manifest); @@ -708,8 +709,8 @@ BuildDiffReport build_diff_report(const BuildPlan& plan, if (previous_lock.has_value()) { report.origin.detected = true; report.origin.matches_requested_template = origin_matches_requested_template(*previous_lock, manifest); - report.origin.matches_requested_version = report.origin.matches_requested_template - && previous_lock->template_info.version == manifest.info.version; + report.origin.matches_requested_version = + report.origin.matches_requested_template && previous_lock->template_info.version == manifest.info.version; report.origin.lockfile_path = lock_path; report.origin.template_id = previous_lock->template_info.id; report.origin.template_version = previous_lock->template_info.version; @@ -717,12 +718,11 @@ BuildDiffReport build_diff_report(const BuildPlan& plan, } report.entries.reserve(plan.files.size()); - const std::set previous_managed_files = previous_lock.has_value() - ? previous_lock->managed_files - : std::set{}; + const std::set previous_managed_files = + previous_lock.has_value() ? previous_lock->managed_files : std::set{}; std::set current_managed_files; - for (const auto& file : plan.files) { + for (const auto &file : plan.files) { const std::filesystem::path relative_path = std::filesystem::relative(file.output_path, plan.build_root); const std::string relative_key = relative_path.generic_string(); current_managed_files.insert(relative_key); @@ -736,10 +736,7 @@ BuildDiffReport build_diff_report(const BuildPlan& plan, BuildDiffReason reason = BuildDiffReason::None; if (!std::filesystem::exists(actual_path)) { status = BuildDiffStatus::Create; - reason = classify_create_reason(report.origin.detected, - previously_managed, - baseline_hash, - expected_hash); + reason = classify_create_reason(report.origin.detected, previously_managed, baseline_hash, expected_hash); } else if (!std::filesystem::is_regular_file(actual_path)) { status = BuildDiffStatus::Change; reason = BuildDiffReason::TypeMismatch; @@ -747,9 +744,7 @@ BuildDiffReport build_diff_report(const BuildPlan& plan, const std::string actual = read_file(actual_path); if (actual != expected) { status = BuildDiffStatus::Change; - reason = classify_change_reason(report.origin.detected, - baseline_hash, - content_fingerprint_hex(actual), + reason = classify_change_reason(report.origin.detected, baseline_hash, content_fingerprint_hex(actual), expected_hash); } } @@ -762,7 +757,7 @@ BuildDiffReport build_diff_report(const BuildPlan& plan, }); } - for (const auto& relative_key : previous_managed_files) { + for (const auto &relative_key : previous_managed_files) { if (current_managed_files.contains(relative_key)) { continue; } @@ -774,18 +769,18 @@ BuildDiffReport build_diff_report(const BuildPlan& plan, .relative_path = relative_key, .status = BuildDiffStatus::Delete, .reason = classify_delete_reason(report.origin.detected, - find_managed_file_hash(previous_lock, relative_key), - actual_path), - .reapply_action = classify_reapply_action(BuildDiffStatus::Delete, - classify_delete_reason(report.origin.detected, - find_managed_file_hash(previous_lock, relative_key), - actual_path)), + find_managed_file_hash(previous_lock, relative_key), actual_path), + .reapply_action = classify_reapply_action( + BuildDiffStatus::Delete, + classify_delete_reason(report.origin.detected, find_managed_file_hash(previous_lock, relative_key), + actual_path)), }); } - std::sort(report.entries.begin(), report.entries.end(), [](const BuildDiffEntry& left, const BuildDiffEntry& right) { - return left.relative_path.generic_string() < right.relative_path.generic_string(); - }); + std::sort(report.entries.begin(), report.entries.end(), + [](const BuildDiffEntry &left, const BuildDiffEntry &right) { + return left.relative_path.generic_string() < right.relative_path.generic_string(); + }); report.update = build_update_info(report); report.update_policy = build_update_policy(report.update); @@ -794,16 +789,20 @@ BuildDiffReport build_diff_report(const BuildPlan& plan, return report; } -std::string format_build_diff_text(const BuildDiffReport& report) { +std::string format_build_diff_text(const BuildDiffReport &report) { std::ostringstream stream; stream << "Diff " << report.build_root.string() << '\n'; stream << "Template: " << template_label(report.template_id, report.template_version) << '\n'; if (report.origin.detected) { stream << "Origin lock: " << report.origin.lockfile_path.string() << '\n'; - stream << "Origin template: " << template_label(report.origin.template_id, report.origin.template_version) << '\n'; - stream << "Origin generated_at: " << (report.origin.generated_at.empty() ? std::string{""} : report.origin.generated_at) << '\n'; - stream << "Origin matches requested template: " << (report.origin.matches_requested_template ? "yes" : "no") << '\n'; - stream << "Origin matches requested version: " << (report.origin.matches_requested_version ? "yes" : "no") << '\n'; + stream << "Origin template: " << template_label(report.origin.template_id, report.origin.template_version) + << '\n'; + stream << "Origin generated_at: " + << (report.origin.generated_at.empty() ? std::string{""} : report.origin.generated_at) << '\n'; + stream << "Origin matches requested template: " << (report.origin.matches_requested_template ? "yes" : "no") + << '\n'; + stream << "Origin matches requested version: " << (report.origin.matches_requested_version ? "yes" : "no") + << '\n'; } else { stream << "Origin lock: \n"; } @@ -811,8 +810,8 @@ std::string format_build_diff_text(const BuildDiffReport& report) { stream << "Update policy: " << update_policy_action_name(report.update_policy.action) << '\n'; stream << "Update reason: " << report.update_policy.reason << '\n'; if (report.origin.detected && report.origin.matches_requested_template) { - stream << "Update version: " << version_label(report.update.from_version) - << " -> " << version_label(report.update.to_version) << '\n'; + stream << "Update version: " << version_label(report.update.from_version) << " -> " + << version_label(report.update.to_version) << '\n'; } stream << "Update recommendation: " << update_recommendation(report) << '\n'; @@ -828,7 +827,7 @@ std::string format_build_diff_text(const BuildDiffReport& report) { stream << "Reapply keep: " << report.reapply.keep_count << '\n'; stream << "Reapply conflict: " << report.reapply.conflict_count << '\n'; stream << "Reapply review: " << report.reapply.review_count << '\n'; - for (const auto& entry : report.entries) { + for (const auto &entry : report.entries) { stream << " " << status_name(entry.status) << " " << entry.relative_path.generic_string(); if (entry.reason != BuildDiffReason::None) { stream << " [" << reason_name(entry.reason) << ']'; @@ -841,7 +840,7 @@ std::string format_build_diff_text(const BuildDiffReport& report) { return stream.str(); } -std::string format_build_diff_json(const BuildDiffReport& report) { +std::string format_build_diff_json(const BuildDiffReport &report) { const auto counts = status_counts(report); std::ostringstream stream; stream << "{\n"; @@ -852,8 +851,10 @@ std::string format_build_diff_json(const BuildDiffReport& report) { stream << " },\n"; stream << " \"origin\": {\n"; stream << " \"detected\": " << (report.origin.detected ? "true" : "false") << ",\n"; - stream << " \"matches_requested_template\": " << (report.origin.matches_requested_template ? "true" : "false") << ",\n"; - stream << " \"matches_requested_version\": " << (report.origin.matches_requested_version ? "true" : "false") << ",\n"; + stream << " \"matches_requested_template\": " << (report.origin.matches_requested_template ? "true" : "false") + << ",\n"; + stream << " \"matches_requested_version\": " << (report.origin.matches_requested_version ? "true" : "false") + << ",\n"; stream << " \"lockfile\": \"" << json_escape(report.origin.lockfile_path.string()) << "\",\n"; stream << " \"template_id\": \"" << json_escape(report.origin.template_id) << "\",\n"; stream << " \"template_version\": \"" << json_escape(report.origin.template_version) << "\",\n"; @@ -888,9 +889,9 @@ std::string format_build_diff_json(const BuildDiffReport& report) { stream << " },\n"; stream << " \"entries\": [\n"; for (std::size_t index = 0; index < report.entries.size(); ++index) { - const auto& entry = report.entries[index]; - stream << " {\"path\": \"" << json_escape(entry.relative_path.generic_string()) - << "\", \"status\": \"" << status_name(entry.status) << "\", \"reason\": "; + const auto &entry = report.entries[index]; + stream << " {\"path\": \"" << json_escape(entry.relative_path.generic_string()) << "\", \"status\": \"" + << status_name(entry.status) << "\", \"reason\": "; if (entry.reason == BuildDiffReason::None) { stream << "null"; } else { @@ -913,4 +914,4 @@ std::string format_build_diff_json(const BuildDiffReport& report) { return stream.str(); } -} +} // namespace tempify diff --git a/src/main/cpp/build/BuildExecutor.cpp b/src/main/cpp/build/BuildExecutor.cpp index 13c8003..bbbfd90 100644 --- a/src/main/cpp/build/BuildExecutor.cpp +++ b/src/main/cpp/build/BuildExecutor.cpp @@ -12,7 +12,7 @@ namespace tempify { namespace { -void prepare_directory_path(const std::filesystem::path& path, const ExistingPathBehavior behavior) { +void prepare_directory_path(const std::filesystem::path &path, const ExistingPathBehavior behavior) { if (!std::filesystem::exists(path)) { return; } @@ -29,7 +29,7 @@ void prepare_directory_path(const std::filesystem::path& path, const ExistingPat throw TempifyError("Directory path already exists as file: " + path.string()); } -bool should_skip_output_file(const std::filesystem::path& path, const ExistingPathBehavior behavior) { +bool should_skip_output_file(const std::filesystem::path &path, const ExistingPathBehavior behavior) { if (!std::filesystem::exists(path)) { return false; } @@ -49,34 +49,28 @@ bool should_skip_output_file(const std::filesystem::path& path, const ExistingPa return false; } -void run_hook_phase(const LuaEngine& lua_engine, - const std::optional& hook_path, - const char* phase_name, - const TemplateManifest& manifest, - const BuildContext& context, - const PrebyteRenderer& renderer, - const std::optional timeout) { +void run_hook_phase(const LuaEngine &lua_engine, const std::optional &hook_path, + const char *phase_name, const TemplateManifest &manifest, const BuildContext &context, + const PrebyteRenderer &renderer, const std::optional timeout) { if (!hook_path.has_value()) { return; } try { lua_engine.run_hook(*hook_path, manifest, context, renderer, timeout); - } catch (const TempifyError& error) { - throw TempifyError(std::string("Hook phase '") + phase_name + "' failed (" + hook_path->string() + "): " + error.what()); + } catch (const TempifyError &error) { + throw TempifyError(std::string("Hook phase '") + phase_name + "' failed (" + hook_path->string() + + "): " + error.what()); } } -} +} // namespace -BuildExecutor::BuildExecutor(const PrebyteRenderer& renderer, const LuaEngine& lua_engine) - : renderer_(renderer), - lua_engine_(lua_engine) {} +BuildExecutor::BuildExecutor(const PrebyteRenderer &renderer, const LuaEngine &lua_engine) + : renderer_(renderer), lua_engine_(lua_engine) {} -void BuildExecutor::execute(const BuildPlan& plan, - const TemplateManifest& manifest, - const std::map& values, - const bool disable_hooks, +void BuildExecutor::execute(const BuildPlan &plan, const TemplateManifest &manifest, + const std::map &values, const bool disable_hooks, const std::optional hook_timeout) const { if (std::filesystem::exists(plan.build_root)) { if (std::filesystem::is_directory(plan.build_root)) { @@ -86,7 +80,8 @@ void BuildExecutor::execute(const BuildPlan& plan, } else if (plan.existing_path_behavior == ExistingPathBehavior::Overwrite) { std::filesystem::remove(plan.build_root); } else { - throw TempifyError("Target path exists as file and cannot be used as directory: " + plan.build_root.string()); + throw TempifyError("Target path exists as file and cannot be used as directory: " + + plan.build_root.string()); } } @@ -100,7 +95,7 @@ void BuildExecutor::execute(const BuildPlan& plan, run_hook_phase(lua_engine_, plan.pre_hook_path, "pre", manifest, context, renderer_, hook_timeout); } - for (const auto& directory : plan.directories) { + for (const auto &directory : plan.directories) { prepare_directory_path(directory, plan.existing_path_behavior); std::filesystem::create_directories(directory); } @@ -109,27 +104,31 @@ void BuildExecutor::execute(const BuildPlan& plan, renderer_.configure(engine, values, manifest); if (!disable_hooks) { - run_hook_phase(lua_engine_, plan.before_render_hook_path, "before_render", manifest, context, renderer_, hook_timeout); + run_hook_phase(lua_engine_, plan.before_render_hook_path, "before_render", manifest, context, renderer_, + hook_timeout); } - for (const auto& file : plan.files) { + for (const auto &file : plan.files) { if (should_skip_output_file(file.output_path, plan.existing_path_behavior)) { continue; } std::filesystem::create_directories(file.output_path.parent_path()); if (file.render_with_prebyte) { - if (plan.existing_path_behavior == ExistingPathBehavior::Overwrite && std::filesystem::exists(file.output_path)) { + if (plan.existing_path_behavior == ExistingPathBehavior::Overwrite && + std::filesystem::exists(file.output_path)) { std::filesystem::remove(file.output_path); } renderer_.render_file(engine, file.source_path, file.output_path); continue; } - std::filesystem::copy_file(file.source_path, file.output_path, std::filesystem::copy_options::overwrite_existing); + std::filesystem::copy_file(file.source_path, file.output_path, + std::filesystem::copy_options::overwrite_existing); } if (!disable_hooks) { - run_hook_phase(lua_engine_, plan.after_render_hook_path, "after_render", manifest, context, renderer_, hook_timeout); + run_hook_phase(lua_engine_, plan.after_render_hook_path, "after_render", manifest, context, renderer_, + hook_timeout); } if (!disable_hooks) { @@ -137,4 +136,4 @@ void BuildExecutor::execute(const BuildPlan& plan, } } -} +} // namespace tempify diff --git a/src/main/cpp/build/BuildPlanReport.cpp b/src/main/cpp/build/BuildPlanReport.cpp index 252d336..dfd3e12 100644 --- a/src/main/cpp/build/BuildPlanReport.cpp +++ b/src/main/cpp/build/BuildPlanReport.cpp @@ -1,7 +1,6 @@ #include "tempify/build/BuildPlanReport.h" #include "tempify/build/GenerationLock.h" - #include "tempify/support/Version.h" #include @@ -15,17 +14,29 @@ namespace tempify { namespace { -std::string json_escape(const std::string& value) { +std::string json_escape(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; @@ -69,31 +80,30 @@ std::string current_timestamp_utc() { return stream.str(); } -std::set sensitive_question_keys(const TemplateManifest& manifest) { +std::set sensitive_question_keys(const TemplateManifest &manifest) { std::set keys; - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (!question.sensitive) { continue; } keys.insert(question.key); - for (const auto& alias : question.aliases) { + for (const auto &alias : question.aliases) { keys.insert(alias); } } return keys; } -void append_hook(std::vector& hooks, - const std::optional& path, - const std::string& name) { +void append_hook(std::vector &hooks, const std::optional &path, + const std::string &name) { if (path.has_value()) { hooks.push_back(name); } } -} +} // namespace -BuildPlanReport build_plan_report(const BuildPlan& plan, const TemplateManifest& manifest) { +BuildPlanReport build_plan_report(const BuildPlan &plan, const TemplateManifest &manifest) { BuildPlanReport report; report.build_root = plan.build_root; report.existing_path_behavior = plan.existing_path_behavior; @@ -106,16 +116,16 @@ BuildPlanReport build_plan_report(const BuildPlan& plan, const TemplateManifest& return report; } -std::string format_build_plan_text(const BuildPlanReport& report) { +std::string format_build_plan_text(const BuildPlanReport &report) { std::ostringstream stream; stream << "Build root: " << report.build_root.string() << '\n'; stream << "Existing path behavior: " << existing_path_behavior_name(report.existing_path_behavior) << '\n'; stream << "Directories: " << report.directories.size() << '\n'; - for (const auto& directory : report.directories) { + for (const auto &directory : report.directories) { stream << " dir " << directory.string() << '\n'; } stream << "Files: " << report.files.size() << '\n'; - for (const auto& file : report.files) { + for (const auto &file : report.files) { stream << " file " << file.output_path.string(); if (file.render_with_prebyte) { stream << " [render]"; @@ -123,17 +133,18 @@ std::string format_build_plan_text(const BuildPlanReport& report) { stream << '\n'; } stream << "Hooks: " << report.hooks.size() << '\n'; - for (const auto& hook : report.hooks) { + for (const auto &hook : report.hooks) { stream << " hook " << hook << '\n'; } return stream.str(); } -std::string format_build_plan_json(const BuildPlanReport& report) { +std::string format_build_plan_json(const BuildPlanReport &report) { std::ostringstream stream; stream << "{\n"; stream << " \"build_root\": \"" << json_escape(report.build_root.string()) << "\",\n"; - stream << " \"existing_path_behavior\": \"" << existing_path_behavior_name(report.existing_path_behavior) << "\",\n"; + stream << " \"existing_path_behavior\": \"" << existing_path_behavior_name(report.existing_path_behavior) + << "\",\n"; stream << " \"directories\": [\n"; for (std::size_t index = 0; index < report.directories.size(); ++index) { stream << " \"" << json_escape(report.directories[index].string()) << "\""; @@ -145,9 +156,9 @@ std::string format_build_plan_json(const BuildPlanReport& report) { stream << " ],\n"; stream << " \"files\": [\n"; for (std::size_t index = 0; index < report.files.size(); ++index) { - const auto& file = report.files[index]; - stream << " {\"source\": \"" << json_escape(file.source_path.string()) - << "\", \"output\": \"" << json_escape(file.output_path.string()) + const auto &file = report.files[index]; + stream << " {\"source\": \"" << json_escape(file.source_path.string()) << "\", \"output\": \"" + << json_escape(file.output_path.string()) << "\", \"render\": " << (file.render_with_prebyte ? "true" : "false") << '}'; if (index + 1 < report.files.size()) { stream << ','; @@ -168,25 +179,17 @@ std::string format_build_plan_json(const BuildPlanReport& report) { return stream.str(); } -std::string format_generation_lock_json(const TemplateManifest& manifest, - const BuildPlan& plan, - const std::map& values, - const HookAcceptance hook_acceptance, - const bool hooks_disabled) { - return format_generation_lock_json(manifest, - plan, - values, - hook_acceptance, - hooks_disabled, +std::string format_generation_lock_json(const TemplateManifest &manifest, const BuildPlan &plan, + const std::map &values, + const HookAcceptance hook_acceptance, const bool hooks_disabled) { + return format_generation_lock_json(manifest, plan, values, hook_acceptance, hooks_disabled, build_generation_lock_managed_file_hashes(plan)); } -std::string format_generation_lock_json(const TemplateManifest& manifest, - const BuildPlan& plan, - const std::map& values, - const HookAcceptance hook_acceptance, - const bool hooks_disabled, - const std::map& managed_file_hashes) { +std::string format_generation_lock_json(const TemplateManifest &manifest, const BuildPlan &plan, + const std::map &values, + const HookAcceptance hook_acceptance, const bool hooks_disabled, + const std::map &managed_file_hashes) { const std::set redacted_keys = sensitive_question_keys(manifest); std::ostringstream stream; stream << "{\n"; @@ -204,7 +207,8 @@ std::string format_generation_lock_json(const TemplateManifest& manifest, stream << " \"hooks_disabled\": " << (hooks_disabled ? "true" : "false") << ",\n"; stream << " \"managed_files\": [\n"; for (std::size_t index = 0; index < plan.files.size(); ++index) { - const auto relative_path = std::filesystem::relative(plan.files[index].output_path, plan.build_root).generic_string(); + const auto relative_path = + std::filesystem::relative(plan.files[index].output_path, plan.build_root).generic_string(); stream << " \"" << json_escape(relative_path) << "\""; if (index + 1 < plan.files.size()) { stream << ','; @@ -214,7 +218,7 @@ std::string format_generation_lock_json(const TemplateManifest& manifest, stream << " ],\n"; stream << " \"managed_file_hashes\": {\n"; std::size_t hash_index = 0; - for (const auto& [relative_path, hash] : managed_file_hashes) { + for (const auto &[relative_path, hash] : managed_file_hashes) { stream << " \"" << json_escape(relative_path) << "\": \"" << json_escape(hash) << "\""; if (hash_index + 1 < managed_file_hashes.size()) { stream << ','; @@ -225,7 +229,7 @@ std::string format_generation_lock_json(const TemplateManifest& manifest, stream << " },\n"; stream << " \"values\": {\n"; std::size_t index = 0; - for (const auto& [key, value] : values) { + for (const auto &[key, value] : values) { const std::string lock_value = redacted_keys.contains(key) ? std::string{""} : value; stream << " \"" << json_escape(key) << "\": \"" << json_escape(lock_value) << "\""; if (index + 1 < values.size()) { @@ -239,4 +243,4 @@ std::string format_generation_lock_json(const TemplateManifest& manifest, return stream.str(); } -} +} // namespace tempify diff --git a/src/main/cpp/build/BuildPlanner.cpp b/src/main/cpp/build/BuildPlanner.cpp index b71d6ac..103cd72 100644 --- a/src/main/cpp/build/BuildPlanner.cpp +++ b/src/main/cpp/build/BuildPlanner.cpp @@ -12,13 +12,13 @@ namespace tempify { namespace { -std::filesystem::path normalize_relative_output_path(const std::filesystem::path& path) { +std::filesystem::path normalize_relative_output_path(const std::filesystem::path &path) { if (path.empty() || path.is_absolute()) { throw TempifyError("Rendered output path must be relative and non-empty"); } const std::filesystem::path normalized = path.lexically_normal(); - for (const auto& part : normalized) { + for (const auto &part : normalized) { if (part == "..") { throw TempifyError("Rendered output path escapes build root: " + normalized.string()); } @@ -27,9 +27,8 @@ std::filesystem::path normalize_relative_output_path(const std::filesystem::path return normalized; } -void collect_parent_directories(std::set& directories, - const std::filesystem::path& root, - const std::filesystem::path& output_path) { +void collect_parent_directories(std::set &directories, const std::filesystem::path &root, + const std::filesystem::path &output_path) { std::filesystem::path current = output_path.parent_path(); while (!current.empty() && current != root && current.native().size() >= root.native().size()) { directories.insert(current); @@ -37,16 +36,16 @@ void collect_parent_directories(std::set& directories, } } -int path_depth(const std::filesystem::path& path) { +int path_depth(const std::filesystem::path &path) { int depth = 0; - for (const auto& part : path) { + for (const auto &part : path) { static_cast(part); ++depth; } return depth; } -const LayoutRule* layout_rule_for(const TemplateManifest& manifest, const std::string& relative_path) { +const LayoutRule *layout_rule_for(const TemplateManifest &manifest, const std::string &relative_path) { for (auto it = manifest.layout_rules.rbegin(); it != manifest.layout_rules.rend(); ++it) { if (it->source == relative_path) { return &*it; @@ -55,14 +54,12 @@ const LayoutRule* layout_rule_for(const TemplateManifest& manifest, const std::s return nullptr; } -} +} // namespace -BuildPlanner::BuildPlanner(const PrebyteRenderer& renderer) - : renderer_(renderer) {} +BuildPlanner::BuildPlanner(const PrebyteRenderer &renderer) : renderer_(renderer) {} -BuildPlan BuildPlanner::plan(const TemplateManifest& manifest, - const std::map& values, - const std::optional& explicit_target) const { +BuildPlan BuildPlanner::plan(const TemplateManifest &manifest, const std::map &values, + const std::optional &explicit_target) const { prebyte::Prebyte engine; renderer_.configure(engine, values, manifest); @@ -74,12 +71,11 @@ BuildPlan BuildPlanner::plan(const TemplateManifest& manifest, plan.post_hook_path = manifest.post_hook_path; if (explicit_target.has_value()) { - plan.build_root = explicit_target->is_absolute() - ? *explicit_target - : std::filesystem::current_path() / *explicit_target; + plan.build_root = + explicit_target->is_absolute() ? *explicit_target : std::filesystem::current_path() / *explicit_target; } else { - const std::string rendered = renderer_.render_string(engine, - manifest.output_path_template.empty() ? manifest.info.id : manifest.output_path_template); + const std::string rendered = renderer_.render_string( + engine, manifest.output_path_template.empty() ? manifest.info.id : manifest.output_path_template); if (rendered.empty()) { throw TempifyError("Rendered build root is empty for template " + manifest.info.id); } @@ -93,13 +89,15 @@ BuildPlan BuildPlanner::plan(const TemplateManifest& manifest, std::set directories; directories.insert(plan.build_root); - for (const auto& raw_directory : manifest.directories) { - if (const LayoutRule* rule = layout_rule_for(manifest, raw_directory.relative_path); rule != nullptr && rule->exclude) { + for (const auto &raw_directory : manifest.directories) { + if (const LayoutRule *rule = layout_rule_for(manifest, raw_directory.relative_path); + rule != nullptr && rule->exclude) { continue; } const std::string layout_source = [&]() { - if (const LayoutRule* rule = layout_rule_for(manifest, raw_directory.relative_path); rule != nullptr && rule->target.has_value()) { + if (const LayoutRule *rule = layout_rule_for(manifest, raw_directory.relative_path); + rule != nullptr && rule->target.has_value()) { return *rule->target; } return raw_directory.relative_path; @@ -114,19 +112,21 @@ BuildPlan BuildPlanner::plan(const TemplateManifest& manifest, } std::map output_collision_guard; - for (const auto& source : manifest.files) { + for (const auto &source : manifest.files) { if (source.excluded) { continue; } - const LayoutRule* rule = layout_rule_for(manifest, source.relative_path); + const LayoutRule *rule = layout_rule_for(manifest, source.relative_path); if (rule != nullptr && rule->exclude) { continue; } - const std::string logical_source = (rule != nullptr && rule->target.has_value()) ? *rule->target : source.relative_path; - std::filesystem::path output_relative = normalize_relative_output_path( - std::filesystem::path(renderer_.render_string(engine, logical_source))); - const bool render_with_prebyte = (rule != nullptr && rule->render.has_value()) ? *rule->render : source.render_with_prebyte; + const std::string logical_source = + (rule != nullptr && rule->target.has_value()) ? *rule->target : source.relative_path; + std::filesystem::path output_relative = + normalize_relative_output_path(std::filesystem::path(renderer_.render_string(engine, logical_source))); + const bool render_with_prebyte = + (rule != nullptr && rule->render.has_value()) ? *rule->render : source.render_with_prebyte; if (render_with_prebyte) { output_relative.replace_extension(); } @@ -148,18 +148,18 @@ BuildPlan BuildPlanner::plan(const TemplateManifest& manifest, } plan.directories.assign(directories.begin(), directories.end()); - std::sort(plan.directories.begin(), plan.directories.end(), [](const auto& left, const auto& right) { + std::sort(plan.directories.begin(), plan.directories.end(), [](const auto &left, const auto &right) { if (path_depth(left) == path_depth(right)) { return left.generic_string() < right.generic_string(); } return path_depth(left) < path_depth(right); }); - std::sort(plan.files.begin(), plan.files.end(), [](const PlannedFile& left, const PlannedFile& right) { + std::sort(plan.files.begin(), plan.files.end(), [](const PlannedFile &left, const PlannedFile &right) { return left.output_path.generic_string() < right.output_path.generic_string(); }); return plan; } -} +} // namespace tempify diff --git a/src/main/cpp/build/GenerationLock.cpp b/src/main/cpp/build/GenerationLock.cpp index 4ddf42c..6947bd0 100644 --- a/src/main/cpp/build/GenerationLock.cpp +++ b/src/main/cpp/build/GenerationLock.cpp @@ -1,9 +1,8 @@ #include "tempify/build/GenerationLock.h" -#include "tempify/support/Errors.h" - #include "datatypes/Data.h" #include "parser/JsonParser.h" +#include "tempify/support/Errors.h" #include #include @@ -15,7 +14,7 @@ namespace tempify { namespace { -std::string read_file(const std::filesystem::path& path) { +std::string read_file(const std::filesystem::path &path) { std::ifstream input(path, std::ios::binary); if (!input) { throw TempifyError("Could not read file: " + path.string()); @@ -25,7 +24,7 @@ std::string read_file(const std::filesystem::path& path) { return stream.str(); } -const prebyte::Data* find_field(const prebyte::Data::Map& map, const std::string& key) { +const prebyte::Data *find_field(const prebyte::Data::Map &map, const std::string &key) { const auto it = map.find(key); if (it == map.end()) { return nullptr; @@ -33,93 +32,86 @@ const prebyte::Data* find_field(const prebyte::Data::Map& map, const std::string return &it->second; } -std::string qualified_field_name(const std::string& field_name, const std::string& prefix) { +std::string qualified_field_name(const std::string &field_name, const std::string &prefix) { if (prefix.empty()) { return field_name; } return prefix + "." + field_name; } -std::string optional_string_field(const prebyte::Data::Map& map, - const std::string& field_name, - const std::filesystem::path& path, - const std::string& prefix = {}) { - const prebyte::Data* value = find_field(map, field_name); +std::string optional_string_field(const prebyte::Data::Map &map, const std::string &field_name, + const std::filesystem::path &path, const std::string &prefix = {}) { + const prebyte::Data *value = find_field(map, field_name); if (value == nullptr) { return {}; } if (!value->is_string()) { - throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) - + "' must be string: " + path.string()); + throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) + + "' must be string: " + path.string()); } return value->as_string_ref(); } -bool optional_bool_field(const prebyte::Data::Map& map, - const std::string& field_name, - const std::filesystem::path& path, - const bool default_value, - const std::string& prefix = {}) { - const prebyte::Data* value = find_field(map, field_name); +bool optional_bool_field(const prebyte::Data::Map &map, const std::string &field_name, + const std::filesystem::path &path, const bool default_value, const std::string &prefix = {}) { + const prebyte::Data *value = find_field(map, field_name); if (value == nullptr) { return default_value; } if (!value->is_bool()) { - throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) - + "' must be boolean: " + path.string()); + throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) + + "' must be boolean: " + path.string()); } return value->as_bool(); } -std::set optional_string_array_field(const prebyte::Data::Map& map, - const std::string& field_name, - const std::filesystem::path& path, - const std::string& prefix = {}) { - const prebyte::Data* value = find_field(map, field_name); +std::set optional_string_array_field(const prebyte::Data::Map &map, const std::string &field_name, + const std::filesystem::path &path, const std::string &prefix = {}) { + const prebyte::Data *value = find_field(map, field_name); if (value == nullptr) { return {}; } if (!value->is_array()) { - throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) - + "' must be array: " + path.string()); + throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) + + "' must be array: " + path.string()); } std::set strings; - for (const auto& entry : value->as_array()) { + for (const auto &entry : value->as_array()) { if (!entry.is_string()) { - throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) - + "' must contain only strings: " + path.string()); + throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) + + "' must contain only strings: " + path.string()); } strings.insert(entry.as_string_ref()); } return strings; } -std::map optional_string_map_field(const prebyte::Data::Map& map, - const std::string& field_name, - const std::filesystem::path& path, - const std::string& prefix = {}) { - const prebyte::Data* value = find_field(map, field_name); +std::map optional_string_map_field(const prebyte::Data::Map &map, + const std::string &field_name, + const std::filesystem::path &path, + const std::string &prefix = {}) { + const prebyte::Data *value = find_field(map, field_name); if (value == nullptr) { return {}; } if (!value->is_map()) { - throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) - + "' must be object: " + path.string()); + throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) + + "' must be object: " + path.string()); } std::map strings; - for (const auto& [key, entry] : value->as_map()) { + for (const auto &[key, entry] : value->as_map()) { if (!entry.is_string()) { - throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) - + "' must contain only string values: " + path.string()); + throw TempifyError("Lock file field '" + qualified_field_name(field_name, prefix) + + "' must contain only string values: " + path.string()); } strings.emplace(key, entry.as_string_ref()); } return strings; } -} +} // namespace std::string content_fingerprint_hex(const std::string_view value) { std::uint64_t hash = 14695981039346656037ULL; @@ -134,9 +126,9 @@ std::string content_fingerprint_hex(const std::string_view value) { return stream.str(); } -std::map build_generation_lock_managed_file_hashes(const BuildPlan& plan) { +std::map build_generation_lock_managed_file_hashes(const BuildPlan &plan) { std::map hashes; - for (const auto& file : plan.files) { + for (const auto &file : plan.files) { if (!std::filesystem::is_regular_file(file.output_path)) { continue; } @@ -146,7 +138,7 @@ std::map build_generation_lock_managed_file_hashes(con return hashes; } -std::optional load_generation_lock(const std::filesystem::path& path) { +std::optional load_generation_lock(const std::filesystem::path &path) { if (!std::filesystem::is_regular_file(path)) { return std::nullopt; } @@ -155,7 +147,7 @@ std::optional load_generation_lock(const std::filesystem:: prebyte::Data data; try { data = parser.parse(path); - } catch (const std::exception& error) { + } catch (const std::exception &error) { throw TempifyError("Could not parse lock file '" + path.string() + "': " + error.what()); } @@ -163,7 +155,7 @@ std::optional load_generation_lock(const std::filesystem:: throw TempifyError("Lock file must be JSON object: " + path.string()); } - const auto& root = data.as_map(); + const auto &root = data.as_map(); GenerationLockRecord record; record.tempify_version = optional_string_field(root, "tempify_version", path); record.build_root = optional_string_field(root, "build_root", path); @@ -175,11 +167,11 @@ std::optional load_generation_lock(const std::filesystem:: record.managed_file_hashes = optional_string_map_field(root, "managed_file_hashes", path); record.values = optional_string_map_field(root, "values", path); - if (const prebyte::Data* template_value = find_field(root, "template"); template_value != nullptr) { + if (const prebyte::Data *template_value = find_field(root, "template"); template_value != nullptr) { if (!template_value->is_map()) { throw TempifyError("Lock file field 'template' must be object: " + path.string()); } - const auto& template_map = template_value->as_map(); + const auto &template_map = template_value->as_map(); record.template_info.id = optional_string_field(template_map, "id", path, "template"); record.template_info.name = optional_string_field(template_map, "name", path, "template"); record.template_info.version = optional_string_field(template_map, "version", path, "template"); @@ -189,4 +181,4 @@ std::optional load_generation_lock(const std::filesystem:: return record; } -} +} // namespace tempify diff --git a/src/main/cpp/build/ReapplySerialization.cpp b/src/main/cpp/build/ReapplySerialization.cpp index 13b5191..afd255c 100644 --- a/src/main/cpp/build/ReapplySerialization.cpp +++ b/src/main/cpp/build/ReapplySerialization.cpp @@ -17,17 +17,29 @@ struct ReapplyPathGroups { std::vector review_paths; }; -std::string json_escape(const std::string& value) { +std::string json_escape(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; @@ -77,7 +89,7 @@ std::string update_policy_action_name(const BuildUpdatePolicyAction action) { return "unavailable"; } -std::string template_label(const std::string& template_id, const std::string& template_version) { +std::string template_label(const std::string &template_id, const std::string &template_version) { if (template_id.empty() && template_version.empty()) { return ""; } @@ -90,15 +102,15 @@ std::string template_label(const std::string& template_id, const std::string& te return template_id + "@" + template_version; } -std::string version_label(const std::string& version) { +std::string version_label(const std::string &version) { return version.empty() ? std::string{""} : version; } -bool update_requires_review(const BuildDiffReport& report) { +bool update_requires_review(const BuildDiffReport &report) { return report.update_policy.action == BuildUpdatePolicyAction::Review; } -std::string version_transition_summary(const ReapplyVersionTransitionInfo& transition) { +std::string version_transition_summary(const ReapplyVersionTransitionInfo &transition) { if (transition.kind == "downgrade") { return "version downgrade"; } @@ -114,7 +126,7 @@ std::string version_transition_summary(const ReapplyVersionTransitionInfo& trans return "version transition requires review"; } -std::string metadata_review_path(const BuildDiffReport& report) { +std::string metadata_review_path(const BuildDiffReport &report) { if (!report.origin.detected || !update_requires_review(report)) { return {}; } @@ -123,16 +135,17 @@ std::string metadata_review_path(const BuildDiffReport& report) { } std::error_code error; - const std::filesystem::path relative = std::filesystem::relative(report.origin.lockfile_path, report.build_root, error); + const std::filesystem::path relative = + std::filesystem::relative(report.origin.lockfile_path, report.build_root, error); if (!error && !relative.empty()) { return relative.generic_string(); } return report.origin.lockfile_path.filename().generic_string(); } -ReapplyPathGroups collect_reapply_path_groups(const BuildDiffReport& report) { +ReapplyPathGroups collect_reapply_path_groups(const BuildDiffReport &report) { ReapplyPathGroups groups; - for (const auto& entry : report.entries) { + for (const auto &entry : report.entries) { const std::string path = entry.relative_path.generic_string(); switch (entry.reapply_action) { case BuildReapplyAction::None: @@ -159,17 +172,15 @@ ReapplyPathGroups collect_reapply_path_groups(const BuildDiffReport& report) { } const std::string review_path = metadata_review_path(report); - if (!review_path.empty() && std::find(groups.review_paths.begin(), groups.review_paths.end(), review_path) == groups.review_paths.end()) { + if (!review_path.empty() && + std::find(groups.review_paths.begin(), groups.review_paths.end(), review_path) == groups.review_paths.end()) { groups.review_paths.push_back(review_path); } return groups; } -void append_json_string_array(std::ostringstream& stream, - const std::string& key, - const std::vector& values, - const bool trailing_comma, - const int indent = 2) { +void append_json_string_array(std::ostringstream &stream, const std::string &key, + const std::vector &values, const bool trailing_comma, const int indent = 2) { const std::string outer(indent, ' '); const std::string inner(indent + 2, ' '); stream << outer << '"' << key << "\": [\n"; @@ -187,7 +198,7 @@ void append_json_string_array(std::ostringstream& stream, stream << '\n'; } -std::string format_path_list(const std::vector& paths) { +std::string format_path_list(const std::vector &paths) { if (paths.empty()) { return {}; } @@ -204,11 +215,10 @@ std::string format_path_list(const std::vector& paths) { return stream.str(); } -} +} // namespace -std::string format_reapply_result_text(const std::string& template_id, - const std::filesystem::path& build_root, - const BuildDiffReport& report) { +std::string format_reapply_result_text(const std::string &template_id, const std::filesystem::path &build_root, + const BuildDiffReport &report) { std::ostringstream stream; stream << "Reapplied " << template_id << " -> " << build_root.string() << '\n'; stream << "Origin template: " << template_label(report.origin.template_id, report.origin.template_version) << '\n'; @@ -216,8 +226,8 @@ std::string format_reapply_result_text(const std::string& template_id, stream << "Update policy: " << update_policy_action_name(report.update_policy.action) << '\n'; stream << "Next step: " << report.update_policy.next_step << '\n'; if (report.origin.detected && report.origin.matches_requested_template) { - stream << "Update version: " << version_label(report.update.from_version) - << " -> " << version_label(report.update.to_version) << '\n'; + stream << "Update version: " << version_label(report.update.from_version) << " -> " + << version_label(report.update.to_version) << '\n'; } stream << "Created: " << report.reapply.create_count << '\n'; stream << "Updated: " << report.reapply.update_count << '\n'; @@ -227,7 +237,7 @@ std::string format_reapply_result_text(const std::string& template_id, return stream.str(); } -std::string format_reapply_result_json(const BuildDiffReport& report) { +std::string format_reapply_result_json(const BuildDiffReport &report) { const ReapplyPathGroups groups = collect_reapply_path_groups(report); std::ostringstream stream; @@ -241,8 +251,10 @@ std::string format_reapply_result_json(const BuildDiffReport& report) { stream << " \"build_root\": \"" << json_escape(report.build_root.string()) << "\",\n"; stream << " \"origin\": {\n"; stream << " \"detected\": " << (report.origin.detected ? "true" : "false") << ",\n"; - stream << " \"matches_requested_template\": " << (report.origin.matches_requested_template ? "true" : "false") << ",\n"; - stream << " \"matches_requested_version\": " << (report.origin.matches_requested_version ? "true" : "false") << ",\n"; + stream << " \"matches_requested_template\": " << (report.origin.matches_requested_template ? "true" : "false") + << ",\n"; + stream << " \"matches_requested_version\": " << (report.origin.matches_requested_version ? "true" : "false") + << ",\n"; stream << " \"lockfile\": \"" << json_escape(report.origin.lockfile_path.string()) << "\",\n"; stream << " \"template_id\": \"" << json_escape(report.origin.template_id) << "\",\n"; stream << " \"template_version\": \"" << json_escape(report.origin.template_version) << "\",\n"; @@ -283,7 +295,7 @@ std::string format_reapply_result_json(const BuildDiffReport& report) { return stream.str(); } -ReapplyBlockedError build_reapply_blocked_error(const BuildDiffReport& report) { +ReapplyBlockedError build_reapply_blocked_error(const BuildDiffReport &report) { const ReapplyPathGroups groups = collect_reapply_path_groups(report); const std::string review_gate_path = metadata_review_path(report); std::vector review_paths = groups.review_paths; @@ -301,12 +313,9 @@ ReapplyBlockedError build_reapply_blocked_error(const BuildDiffReport& report) { .requested_template_version = report.template_version, }; } - if (report.update_policy.action == BuildUpdatePolicyAction::Review - && report.origin.detected - && report.origin.matches_requested_template - && report.update.kind != BuildUpdateKind::NoOrigin - && report.update.kind != BuildUpdateKind::TemplateMismatch - && !review_gate_path.empty()) { + if (report.update_policy.action == BuildUpdatePolicyAction::Review && report.origin.detected && + report.origin.matches_requested_template && report.update.kind != BuildUpdateKind::NoOrigin && + report.update.kind != BuildUpdateKind::TemplateMismatch && !review_gate_path.empty()) { version_transition = ReapplyVersionTransitionInfo{ .lockfile_path = review_gate_path, .kind = update_kind_name(report.update.kind), @@ -352,14 +361,11 @@ ReapplyBlockedError build_reapply_blocked_error(const BuildDiffReport& report) { stream << "status unavailable"; } stream << ". Run `tempify --diff` to inspect."; - return ReapplyBlockedError(stream.str(), - std::move(groups.conflict_paths), - std::move(groups.review_paths), - std::move(origin_mismatch), - std::move(version_transition)); + return ReapplyBlockedError(stream.str(), std::move(groups.conflict_paths), std::move(groups.review_paths), + std::move(origin_mismatch), std::move(version_transition)); } -std::string format_reapply_blocked_error_json(const ReapplyBlockedError& error) { +std::string format_reapply_blocked_error_json(const ReapplyBlockedError &error) { std::ostringstream stream; stream << "{\n"; stream << " \"status\": \"error\",\n"; @@ -372,7 +378,7 @@ std::string format_reapply_blocked_error_json(const ReapplyBlockedError& error) if (!error.origin_mismatch().has_value()) { stream << "null,\n"; } else { - const auto& mismatch = *error.origin_mismatch(); + const auto &mismatch = *error.origin_mismatch(); stream << "{\n"; stream << " \"lockfile\": \"" << json_escape(mismatch.lockfile_path) << "\",\n"; stream << " \"origin_template\": {\n"; @@ -389,7 +395,7 @@ std::string format_reapply_blocked_error_json(const ReapplyBlockedError& error) if (!error.version_transition().has_value()) { stream << "null\n"; } else { - const auto& transition = *error.version_transition(); + const auto &transition = *error.version_transition(); stream << "{\n"; stream << " \"lockfile\": \"" << json_escape(transition.lockfile_path) << "\",\n"; stream << " \"kind\": \"" << json_escape(transition.kind) << "\",\n"; @@ -403,4 +409,4 @@ std::string format_reapply_blocked_error_json(const ReapplyBlockedError& error) return stream.str(); } -} +} // namespace tempify diff --git a/src/main/cpp/cli/CliHelp.cpp b/src/main/cpp/cli/CliHelp.cpp index 2bbcfe2..dce2cc6 100644 --- a/src/main/cpp/cli/CliHelp.cpp +++ b/src/main/cpp/cli/CliHelp.cpp @@ -14,27 +14,23 @@ namespace { using HelpRow = std::pair; -void append_section(std::ostringstream& stream, - const std::string_view title, - const std::vector& lines) { +void append_section(std::ostringstream &stream, const std::string_view title, const std::vector &lines) { stream << title << ":\n"; - for (const auto& line : lines) { + for (const auto &line : lines) { stream << " " << line << '\n'; } stream << '\n'; } -void append_table(std::ostringstream& stream, - const std::string_view title, - const std::vector& rows) { +void append_table(std::ostringstream &stream, const std::string_view title, const std::vector &rows) { stream << title << ":\n"; std::size_t width = 0; - for (const auto& [name, description] : rows) { + for (const auto &[name, description] : rows) { static_cast(description); width = std::max(width, name.size()); } - for (const auto& [name, description] : rows) { + for (const auto &[name, description] : rows) { stream << " " << name; if (name.size() < width) { stream << std::string(width - name.size(), ' '); @@ -48,43 +44,47 @@ std::string format_top_level_help() { std::ostringstream stream; stream << "Tempify - Local Template Generator\n\n"; - append_section(stream, "Usage", { - "tempify [args...] [options]", - "tempify [target] [options]", - "tempify reapply [options]", - }); - - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--version", "Prints Tempify version"}, - }); - - append_table(stream, "Commands", { - {"list", "Lists available templates"}, - {"info", "Shows template details"}, - {"doctor", "Checks Tempify environment"}, - {"completion", "Generates shell completion script"}, - {"validate", "Validates template structure"}, - {"inspect", "Inspects merged template graph"}, - {"lint", "Lints template quality issues"}, - {"test", "Runs template fixtures"}, - {"refresh", "Rebuilds shared template index"}, - {"reapply", "Applies safe managed-file updates to existing target"}, - {"process", "Passes remaining args to embedded Prebyte"}, - }); - - append_section(stream, "Examples", { - "tempify basic_cpp my-app --set project_name=\"My App\"", - "tempify basic_cpp my-app --diff --json", - "tempify reapply basic_cpp my-app --report --json", - "tempify test basic_cpp", - "tempify basic_cpp my-app --tui", - "tempify reapply basic_cpp my-app --json", - "tempify completion bash", - "tempify refresh", - "tempify process -h", - "tempify basic_cpp -h", - }); + append_section(stream, "Usage", + { + "tempify [args...] [options]", + "tempify [target] [options]", + "tempify reapply [options]", + }); + + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--version", "Prints Tempify version"}, + }); + + append_table(stream, "Commands", + { + {"list", "Lists available templates"}, + {"info", "Shows template details"}, + {"doctor", "Checks Tempify environment"}, + {"completion", "Generates shell completion script"}, + {"validate", "Validates template structure"}, + {"inspect", "Inspects merged template graph"}, + {"lint", "Lints template quality issues"}, + {"test", "Runs template fixtures"}, + {"refresh", "Rebuilds shared template index"}, + {"reapply", "Applies safe managed-file updates to existing target"}, + {"process", "Passes remaining args to embedded Prebyte"}, + }); + + append_section(stream, "Examples", + { + "tempify basic_cpp my-app --set project_name=\"My App\"", + "tempify basic_cpp my-app --diff --json", + "tempify reapply basic_cpp my-app --report --json", + "tempify test basic_cpp", + "tempify basic_cpp my-app --tui", + "tempify reapply basic_cpp my-app --json", + "tempify completion bash", + "tempify refresh", + "tempify process -h", + "tempify basic_cpp -h", + }); stream << "Run 'tempify -h' for command-specific help.\n"; stream << "Run 'tempify -h' for render help.\n"; @@ -96,14 +96,16 @@ std::string format_list_help() { stream << "tempify list - List Available Templates\n\n"; append_section(stream, "Usage", {"tempify list"}); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--json", "Outputs machine-readable JSON catalog"}, - }); - append_section(stream, "Notes", { - "Merges workspace templates with shared local template store.", - "Also shows registry-cached templates when ReqPack has populated reqpack-available.json.", - }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--json", "Outputs machine-readable JSON catalog"}, + }); + append_section(stream, "Notes", + { + "Merges workspace templates with shared local template store.", + "Also shows registry-cached templates when ReqPack has populated reqpack-available.json.", + }); return stream.str(); } @@ -112,14 +114,16 @@ std::string format_refresh_help() { stream << "tempify refresh - Rebuild Shared Template Index\n\n"; append_section(stream, "Usage", {"tempify refresh"}); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--json", "Outputs machine-readable JSON summary"}, - }); - append_section(stream, "Notes", { - "Scans shared templates directory and rebuilds index/templates.json.", - "Package manager may also maintain same index file.", - }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--json", "Outputs machine-readable JSON summary"}, + }); + append_section(stream, "Notes", + { + "Scans shared templates directory and rebuilds index/templates.json.", + "Package manager may also maintain same index file.", + }); return stream.str(); } @@ -128,19 +132,22 @@ std::string format_completion_help() { stream << "tempify completion - Generate Shell Completion Script\n\n"; append_section(stream, "Usage", {"tempify completion "}); - append_table(stream, "Arguments", { - {"shell", "One of: bash, zsh, fish"}, - }); + append_table(stream, "Arguments", + { + {"shell", "One of: bash, zsh, fish"}, + }); append_table(stream, "Options", {{"-h,--help", "Displays this help"}}); - append_section(stream, "Notes", { - "Writes shell completion script to stdout.", - "Current scripts complete Tempify commands and local template ids via `tempify list`.", - }); - append_section(stream, "Examples", { - "tempify completion bash", - "tempify completion zsh", - "tempify completion fish", - }); + append_section(stream, "Notes", + { + "Writes shell completion script to stdout.", + "Current scripts complete Tempify commands and local template ids via `tempify list`.", + }); + append_section(stream, "Examples", + { + "tempify completion bash", + "tempify completion zsh", + "tempify completion fish", + }); return stream.str(); } @@ -149,17 +156,20 @@ std::string format_info_help() { stream << "tempify info - Show Template Details\n\n"; append_section(stream, "Usage", {"tempify info "}); - append_table(stream, "Arguments", { - {"template-id", "Template id or template path"}, - }); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--json", "Outputs machine-readable JSON details"}, - }); - append_section(stream, "Notes", { - "Shows template metadata, question summary, include ids, and hook presence.", - "Falls back to registry-cached metadata when template is not installed locally.", - }); + append_table(stream, "Arguments", + { + {"template-id", "Template id or template path"}, + }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--json", "Outputs machine-readable JSON details"}, + }); + append_section(stream, "Notes", + { + "Shows template metadata, question summary, include ids, and hook presence.", + "Falls back to registry-cached metadata when template is not installed locally.", + }); return stream.str(); } @@ -168,13 +178,15 @@ std::string format_doctor_help() { stream << "tempify doctor - Inspect Tempify Environment\n\n"; append_section(stream, "Usage", {"tempify doctor"}); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--json", "Outputs machine-readable JSON summary"}, - }); - append_section(stream, "Notes", { - "Checks data root, workspace template discovery, shared index readability, and catalog state.", - }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--json", "Outputs machine-readable JSON summary"}, + }); + append_section(stream, "Notes", + { + "Checks data root, workspace template discovery, shared index readability, and catalog state.", + }); return stream.str(); } @@ -183,16 +195,19 @@ std::string format_validate_help() { stream << "tempify validate - Validate Template Structure\n\n"; append_section(stream, "Usage", {"tempify validate "}); - append_table(stream, "Arguments", { - {"template-id", "Template id or template path"}, - }); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--json", "Outputs machine-readable JSON validation result"}, - }); - append_section(stream, "Notes", { - "Loads full template graph and checks duplicate keys, aliases, and layout references.", - }); + append_table(stream, "Arguments", + { + {"template-id", "Template id or template path"}, + }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--json", "Outputs machine-readable JSON validation result"}, + }); + append_section(stream, "Notes", + { + "Loads full template graph and checks duplicate keys, aliases, and layout references.", + }); return stream.str(); } @@ -201,16 +216,19 @@ std::string format_inspect_help() { stream << "tempify inspect - Inspect Merged Template Graph\n\n"; append_section(stream, "Usage", {"tempify inspect "}); - append_table(stream, "Arguments", { - {"template-id", "Template id or template path"}, - }); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--json", "Outputs machine-readable JSON inspection report"}, - }); - append_section(stream, "Notes", { - "Shows merged includes, files, questions, layout rules, and hook provenance.", - }); + append_table(stream, "Arguments", + { + {"template-id", "Template id or template path"}, + }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--json", "Outputs machine-readable JSON inspection report"}, + }); + append_section(stream, "Notes", + { + "Shows merged includes, files, questions, layout rules, and hook provenance.", + }); return stream.str(); } @@ -219,16 +237,19 @@ std::string format_lint_help() { stream << "tempify lint - Lint Template Quality\n\n"; append_section(stream, "Usage", {"tempify lint "}); - append_table(stream, "Arguments", { - {"template-id", "Template id or template path"}, - }); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--json", "Outputs machine-readable JSON lint report"}, - }); - append_section(stream, "Notes", { - "Reports non-fatal authoring issues like missing prompts, help text, or unused sources.", - }); + append_table(stream, "Arguments", + { + {"template-id", "Template id or template path"}, + }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--json", "Outputs machine-readable JSON lint report"}, + }); + append_section(stream, "Notes", + { + "Reports non-fatal authoring issues like missing prompts, help text, or unused sources.", + }); return stream.str(); } @@ -237,22 +258,26 @@ std::string format_test_help() { stream << "tempify test - Run Template Fixtures\n\n"; append_section(stream, "Usage", {"tempify test "}); - append_table(stream, "Arguments", { - {"template-id", "Template id or template path"}, - }); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--fixture ", "Runs only one named fixture"}, - {"--list-fixtures", "Lists fixture names without running them"}, - {"--json", "Outputs machine-readable test report"}, - {"--update-snapshots", "Rewrites snapshots from current render output"}, - }); - append_section(stream, "Notes", { - "Looks for fixtures under /tests//.", - "Each fixture may provide answers.json, optional lock.json, and must provide snapshot/ expected output.", - "Runs all selected fixtures and reports every failure before exiting non-zero.", - "Snapshot update mode rewrites snapshot/ and optional lock.json for selected fixtures.", - }); + append_table(stream, "Arguments", + { + {"template-id", "Template id or template path"}, + }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--fixture ", "Runs only one named fixture"}, + {"--list-fixtures", "Lists fixture names without running them"}, + {"--json", "Outputs machine-readable test report"}, + {"--update-snapshots", "Rewrites snapshots from current render output"}, + }); + append_section( + stream, "Notes", + { + "Looks for fixtures under /tests//.", + "Each fixture may provide answers.json, optional lock.json, and must provide snapshot/ expected output.", + "Runs all selected fixtures and reports every failure before exiting non-zero.", + "Snapshot update mode rewrites snapshot/ and optional lock.json for selected fixtures.", + }); return stream.str(); } @@ -261,14 +286,16 @@ std::string format_questions_help() { stream << "tempify -q|--questions - Show Template Questions\n\n"; append_section(stream, "Usage", {"tempify -q|--questions [options]"}); - append_table(stream, "Arguments", { - {"template-id", "Template id or template path"}, - }); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"--json", "Outputs question data as JSON"}, - {"--full", "Keeps empty question fields in output"}, - }); + append_table(stream, "Arguments", + { + {"template-id", "Template id or template path"}, + }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"--json", "Outputs question data as JSON"}, + {"--full", "Keeps empty question fields in output"}, + }); return stream.str(); } @@ -276,21 +303,24 @@ std::string format_process_help() { std::ostringstream stream; stream << "tempify process - Embedded Prebyte Passthrough\n\n"; - append_section(stream, "Usage", { - "tempify process [prebyte-args...]", - "tempify -p [prebyte-args...]", - }); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - }); - append_section(stream, "Notes", { - "Passes remaining arguments to embedded Prebyte command runner.", - "Use this when you want direct Prebyte behavior through Tempify.", - }); + append_section(stream, "Usage", + { + "tempify process [prebyte-args...]", + "tempify -p [prebyte-args...]", + }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + }); + append_section(stream, "Notes", + { + "Passes remaining arguments to embedded Prebyte command runner.", + "Use this when you want direct Prebyte behavior through Tempify.", + }); return stream.str(); } -std::string format_render_help(const CliRequest& request) { +std::string format_render_help(const CliRequest &request) { std::ostringstream stream; stream << "tempify render - Generate Template Output\n\n"; @@ -299,60 +329,68 @@ std::string format_render_help(const CliRequest& request) { } append_section(stream, "Usage", {"tempify [target] [options]"}); - append_table(stream, "Arguments", { - {"template-id", "Template id or template path"}, - {"target", "Output directory"}, - }); - append_table(stream, "Options", { - {"-h,--help", "Displays this help"}, - {"-q,--questions", "Shows question overview instead of rendering"}, - {"-f,--overwrite-if-exists", "Allows existing target and replaces conflicting files"}, - {"-s,--skip-if-file-exists", "Allows existing target and skips conflicting files"}, - {"--accept-hooks ", "Controls whether template hooks run"}, - {"--no-hooks", "Alias for --accept-hooks no"}, - {"--set ", "Sets template variable"}, - {"--var ", "Alias for --set"}, - {"--answers ", "Loads answer values from JSON file"}, - {"--write-answers ", "Writes resolved answers to JSON file"}, - {"--non-interactive", "Fails instead of prompting for missing answers"}, - {"--strict", "Rejects unknown or invalid imported answers"}, - {"--diff", "Compares managed output against target without writing files or running hooks"}, - {"--reapply", "Applies safe managed-file updates when matching origin lock permits"}, - {"--report", "With --reapply, prints reapply report only without writing files"}, - {"--json", "Outputs diff/reapply JSON with origin metadata, update kind, and blocked details"}, - {"--hook-timeout-ms ", "Aborts hook phases that run longer than this timeout (0 disables)"}, - {"--dry-run", "Shows build plan without writing files"}, - {"--plan-json", "Outputs build plan as JSON"}, - {"--tui", "Uses wizard frontend"}, - }); - append_section(stream, "Examples", { - "tempify basic_cpp my-app --set project_name=\"My App\"", - "tempify basic_cpp existing-dir --reapply --json", - "tempify basic_cpp existing-dir -s", - "tempify path/to/template out-dir --accept-hooks ask", - "tempify reapply basic_cpp existing-dir --json", - }); - append_section(stream, "Notes", { - "Global config: $XDG_CONFIG_HOME/tempify/config.json or ~/.config/tempify/config.json", - "Nearest .tempify/config.json overlays global config for current workspace.", - "Question precedence: defaults < .env < global config < workspace config < --answers < --set", - "Render config is soft default only; explicit CLI flags override it.", - "`--diff` is report-only. It does not write files and does not run hooks.", - "`--diff` text output includes update recommendation based on origin lock and version transition.", - "`--diff --json` outputs machine-readable status counts, origin metadata, and update kind.", - "`--reapply --report` is report-only alias for reapply planning and does not write files.", - "`--reapply --json` outputs machine-readable origin metadata, update kind, applied, kept, and blocked details.", - "`tempify reapply ` is alias for `tempify --reapply`.", - "`--reapply` blocks cross-template .tempify-lock.json reuse and requires matching origin template id.", - "`--reapply` allows patch/prerelease upgrades, but major upgrades, pre-1.0 minor upgrades, downgrades, or unknown version transitions require review.", - "`--reapply` requires existing .tempify-lock.json, applies only ready create/update/delete actions, and does not run hooks.", - }); + append_table(stream, "Arguments", + { + {"template-id", "Template id or template path"}, + {"target", "Output directory"}, + }); + append_table(stream, "Options", + { + {"-h,--help", "Displays this help"}, + {"-q,--questions", "Shows question overview instead of rendering"}, + {"-f,--overwrite-if-exists", "Allows existing target and replaces conflicting files"}, + {"-s,--skip-if-file-exists", "Allows existing target and skips conflicting files"}, + {"--accept-hooks ", "Controls whether template hooks run"}, + {"--no-hooks", "Alias for --accept-hooks no"}, + {"--set ", "Sets template variable"}, + {"--var ", "Alias for --set"}, + {"--answers ", "Loads answer values from JSON file"}, + {"--write-answers ", "Writes resolved answers to JSON file"}, + {"--non-interactive", "Fails instead of prompting for missing answers"}, + {"--strict", "Rejects unknown or invalid imported answers"}, + {"--diff", "Compares managed output against target without writing files or running hooks"}, + {"--reapply", "Applies safe managed-file updates when matching origin lock permits"}, + {"--report", "With --reapply, prints reapply report only without writing files"}, + {"--json", "Outputs diff/reapply JSON with origin metadata, update kind, and blocked details"}, + {"--hook-timeout-ms ", "Aborts hook phases that run longer than this timeout (0 disables)"}, + {"--dry-run", "Shows build plan without writing files"}, + {"--plan-json", "Outputs build plan as JSON"}, + {"--tui", "Uses wizard frontend"}, + }); + append_section(stream, "Examples", + { + "tempify basic_cpp my-app --set project_name=\"My App\"", + "tempify basic_cpp existing-dir --reapply --json", + "tempify basic_cpp existing-dir -s", + "tempify path/to/template out-dir --accept-hooks ask", + "tempify reapply basic_cpp existing-dir --json", + }); + append_section( + stream, "Notes", + { + "Global config: $XDG_CONFIG_HOME/tempify/config.json or ~/.config/tempify/config.json", + "Nearest .tempify/config.json overlays global config for current workspace.", + "Question precedence: defaults < .env < global config < workspace config < --answers < --set", + "Render config is soft default only; explicit CLI flags override it.", + "`--diff` is report-only. It does not write files and does not run hooks.", + "`--diff` text output includes update recommendation based on origin lock and version transition.", + "`--diff --json` outputs machine-readable status counts, origin metadata, and update kind.", + "`--reapply --report` is report-only alias for reapply planning and does not write files.", + "`--reapply --json` outputs machine-readable origin metadata, update kind, applied, kept, and blocked " + "details.", + "`tempify reapply ` is alias for `tempify --reapply`.", + "`--reapply` blocks cross-template .tempify-lock.json reuse and requires matching origin template id.", + "`--reapply` allows patch/prerelease upgrades, but major upgrades, pre-1.0 minor upgrades, downgrades, or " + "unknown version transitions require review.", + "`--reapply` requires existing .tempify-lock.json, applies only ready create/update/delete actions, and " + "does not run hooks.", + }); return stream.str(); } -} +} // namespace -std::string CliParser::help_text(const CliRequest& request) const { +std::string CliParser::help_text(const CliRequest &request) const { const HelpTopic topic = request.help_topic.value_or(HelpTopic::TopLevel); switch (topic) { case HelpTopic::TopLevel: @@ -385,4 +423,4 @@ std::string CliParser::help_text(const CliRequest& request) const { return format_top_level_help(); } -} +} // namespace tempify diff --git a/src/main/cpp/cli/CliParser.cpp b/src/main/cpp/cli/CliParser.cpp index c23d86a..c5e9d84 100644 --- a/src/main/cpp/cli/CliParser.cpp +++ b/src/main/cpp/cli/CliParser.cpp @@ -1,10 +1,9 @@ #include "tempify/cli/CliParser.h" -#include - #include "tempify/support/Errors.h" #include "tempify/support/Version.h" +#include #include #include @@ -12,18 +11,17 @@ namespace tempify { namespace { -bool is_help_token(const std::string& arg) { +bool is_help_token(const std::string &arg) { return arg == "-h" || arg == "--help"; } -bool is_questions_token(const std::string& arg) { +bool is_questions_token(const std::string &arg) { return arg == "-q" || arg == "--questions"; } std::string lowercase(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { - return static_cast(std::tolower(ch)); - }); + std::transform(value.begin(), value.end(), value.begin(), + [](const unsigned char ch) { return static_cast(std::tolower(ch)); }); return value; } @@ -41,13 +39,12 @@ HookAcceptance parse_hook_acceptance(std::string value) { throw TempifyError("Invalid value for `--accept-hooks`: " + value + ". Expected yes, ask, or no."); } -bool contains_help_token(const std::vector& args, const std::size_t start_index = 0) { - return std::ranges::any_of(args.begin() + static_cast(start_index), - args.end(), - [](const std::string& arg) { return is_help_token(arg); }); +bool contains_help_token(const std::vector &args, const std::size_t start_index = 0) { + return std::ranges::any_of(args.begin() + static_cast(start_index), args.end(), + [](const std::string &arg) { return is_help_token(arg); }); } -bool contains_value(const std::vector& args, const std::string& value) { +bool contains_value(const std::vector &args, const std::string &value) { return std::ranges::find(args, value) != args.end(); } @@ -58,7 +55,7 @@ CliRequest help_request(const HelpTopic topic) { return request; } -CliRequest reapply_help_request(const std::vector& args) { +CliRequest reapply_help_request(const std::vector &args) { CliRequest request = help_request(HelpTopic::Render); request.reapply = true; if (args.size() > 1 && !is_help_token(args[1]) && !args[1].starts_with('-')) { @@ -70,7 +67,7 @@ CliRequest reapply_help_request(const std::vector& args) { return request; } -std::pair split_assignment(const std::string& value) { +std::pair split_assignment(const std::string &value) { const std::size_t separator = value.find('='); if (separator == std::string::npos || separator == 0) { throw TempifyError("Expected assignment in form key=value, got: " + value); @@ -79,22 +76,22 @@ std::pair split_assignment(const std::string& value) { return {value.substr(0, separator), value.substr(separator + 1)}; } -bool parse_cli_args(CLI::App& app, const std::vector& args) { +bool parse_cli_args(CLI::App &app, const std::vector &args) { std::vector mutable_args = args; std::reverse(mutable_args.begin(), mutable_args.end()); try { app.parse(mutable_args); - } catch (const CLI::CallForHelp&) { + } catch (const CLI::CallForHelp &) { return true; - } catch (const CLI::ParseError& error) { + } catch (const CLI::ParseError &error) { throw TempifyError(error.what()); } return false; } -CliRequest parse_render_request(const std::vector& args); +CliRequest parse_render_request(const std::vector &args); -CliRequest parse_list_request(const std::vector& args) { +CliRequest parse_list_request(const std::vector &args) { CliRequest request; bool json_output = false; CLI::App app{"List available templates"}; @@ -109,15 +106,14 @@ CliRequest parse_list_request(const std::vector& args) { return request; } -CliRequest parse_info_request(const std::vector& args) { +CliRequest parse_info_request(const std::vector &args) { CliRequest request; std::string template_ref; bool json_output = false; CLI::App app{"Show template details"}; app.set_help_flag("-h,--help", "Show help"); - app.add_option("template-id", template_ref, "Template id or template path") - ->required(); + app.add_option("template-id", template_ref, "Template id or template path")->required(); app.add_flag("--json", json_output, "Output machine-readable JSON template details"); if (parse_cli_args(app, args)) { request.mode = CliMode::Help; @@ -131,7 +127,7 @@ CliRequest parse_info_request(const std::vector& args) { return request; } -CliRequest parse_doctor_request(const std::vector& args) { +CliRequest parse_doctor_request(const std::vector &args) { CliRequest request; bool json_output = false; CLI::App app{"Inspect Tempify environment"}; @@ -148,14 +144,13 @@ CliRequest parse_doctor_request(const std::vector& args) { return request; } -CliRequest parse_completion_request(const std::vector& args) { +CliRequest parse_completion_request(const std::vector &args) { CliRequest request; std::string shell; CLI::App app{"Generate shell completion script"}; app.set_help_flag("-h,--help", "Show help"); - app.add_option("shell", shell, "Shell name: bash, zsh, fish") - ->required(); + app.add_option("shell", shell, "Shell name: bash, zsh, fish")->required(); if (parse_cli_args(app, args)) { request.mode = CliMode::Help; request.help_topic = HelpTopic::Completion; @@ -171,15 +166,14 @@ CliRequest parse_completion_request(const std::vector& args) { return request; } -CliRequest parse_validate_request(const std::vector& args) { +CliRequest parse_validate_request(const std::vector &args) { CliRequest request; std::string template_ref; bool json_output = false; CLI::App app{"Validate template structure and references"}; app.set_help_flag("-h,--help", "Show help"); - app.add_option("template-id", template_ref, "Template id or template path") - ->required(); + app.add_option("template-id", template_ref, "Template id or template path")->required(); app.add_flag("--json", json_output, "Output machine-readable JSON validation result"); if (parse_cli_args(app, args)) { request.mode = CliMode::Help; @@ -193,15 +187,14 @@ CliRequest parse_validate_request(const std::vector& args) { return request; } -CliRequest parse_inspect_request(const std::vector& args) { +CliRequest parse_inspect_request(const std::vector &args) { CliRequest request; std::string template_ref; bool json_output = false; CLI::App app{"Inspect merged template graph and provenance"}; app.set_help_flag("-h,--help", "Show help"); - app.add_option("template-id", template_ref, "Template id or template path") - ->required(); + app.add_option("template-id", template_ref, "Template id or template path")->required(); app.add_flag("--json", json_output, "Output machine-readable JSON inspection report"); if (parse_cli_args(app, args)) { request.mode = CliMode::Help; @@ -215,15 +208,14 @@ CliRequest parse_inspect_request(const std::vector& args) { return request; } -CliRequest parse_lint_request(const std::vector& args) { +CliRequest parse_lint_request(const std::vector &args) { CliRequest request; std::string template_ref; bool json_output = false; CLI::App app{"Lint template quality and authoring issues"}; app.set_help_flag("-h,--help", "Show help"); - app.add_option("template-id", template_ref, "Template id or template path") - ->required(); + app.add_option("template-id", template_ref, "Template id or template path")->required(); app.add_flag("--json", json_output, "Output machine-readable JSON lint report"); if (parse_cli_args(app, args)) { request.mode = CliMode::Help; @@ -237,7 +229,7 @@ CliRequest parse_lint_request(const std::vector& args) { return request; } -CliRequest parse_test_request(const std::vector& args) { +CliRequest parse_test_request(const std::vector &args) { CliRequest request; std::string template_ref; std::string fixture_name; @@ -247,8 +239,7 @@ CliRequest parse_test_request(const std::vector& args) { CLI::App app{"Run template fixtures and compare snapshots"}; app.set_help_flag("-h,--help", "Show help"); - app.add_option("template-id", template_ref, "Template id or template path") - ->required(); + app.add_option("template-id", template_ref, "Template id or template path")->required(); app.add_option("--fixture", fixture_name, "Run single named fixture"); app.add_flag("--list-fixtures", list_fixtures, "List fixture names without running them"); app.add_flag("--json", json_output, "Output machine-readable JSON test report"); @@ -270,7 +261,7 @@ CliRequest parse_test_request(const std::vector& args) { return request; } -CliRequest parse_questions_request(const std::vector& args) { +CliRequest parse_questions_request(const std::vector &args) { CliRequest request; if (args.size() < 2 || !is_questions_token(args[1])) { throw TempifyError("Question overview syntax is `tempify -q|--questions [--json] [--full]`."); @@ -279,7 +270,7 @@ CliRequest parse_questions_request(const std::vector& args) { request.template_ref = args[0]; for (std::size_t index = 2; index < args.size(); ++index) { - const auto& arg = args[index]; + const auto &arg = args[index]; if (arg == "--json") { request.questions_output_format = QuestionsOutputFormat::Json; continue; @@ -296,7 +287,7 @@ CliRequest parse_questions_request(const std::vector& args) { return request; } -CliRequest parse_refresh_request(const std::vector& args) { +CliRequest parse_refresh_request(const std::vector &args) { CliRequest request; bool json_output = false; CLI::App app{"Refresh shared template index"}; @@ -311,13 +302,13 @@ CliRequest parse_refresh_request(const std::vector& args) { return request; } -CliRequest parse_reapply_request(const std::vector& args) { +CliRequest parse_reapply_request(const std::vector &args) { std::vector render_args(args.begin() + 1, args.end()); render_args.push_back("--reapply"); return parse_render_request(render_args); } -CliRequest parse_render_request(const std::vector& args) { +CliRequest parse_render_request(const std::vector &args) { CliRequest request; std::string render_template_ref; std::string render_target_dir; @@ -334,11 +325,9 @@ CliRequest parse_render_request(const std::vector& args) { CLI::App app{"Tempify template generator"}; app.set_help_flag("-h,--help", "Show help"); - app.add_option("template-id", render_template_ref, "Template id or template path") - ->required(); + app.add_option("template-id", render_template_ref, "Template id or template path")->required(); app.add_option("target", render_target_dir, "Target directory"); - app.add_option("--set,--var", assignments, "Set template variable in form key=value") - ->allow_extra_args(false); + app.add_option("--set,--var", assignments, "Set template variable in form key=value")->allow_extra_args(false); app.add_option("--answers", answers_file, "Load answer values from JSON file"); app.add_option("--write-answers", write_answers_file, "Write resolved answers to JSON file after generation"); app.add_flag("-f,--overwrite-if-exists", overwrite_if_exists, "Replace conflicting output files"); @@ -348,11 +337,14 @@ CliRequest parse_render_request(const std::vector& args) { app.add_flag("--tui", request.use_tui, "Use wizard frontend"); app.add_flag("--non-interactive", request.non_interactive, "Fail instead of prompting for missing answers"); app.add_flag("--strict", request.strict, "Reject unknown or invalid imported answers"); - app.add_flag("--diff", request.diff_only, "Compare managed output against target without writing files or running hooks"); - app.add_flag("--reapply", request.reapply, "Apply safe managed-file updates to existing target when origin lock permits"); + app.add_flag("--diff", request.diff_only, + "Compare managed output against target without writing files or running hooks"); + app.add_flag("--reapply", request.reapply, + "Apply safe managed-file updates to existing target when origin lock permits"); app.add_flag("--report", reapply_report, "With --reapply, print reapply report only without writing files"); app.add_flag("--json", json_output, "Output diff report as JSON when used with --diff"); - app.add_option("--hook-timeout-ms", hook_timeout_ms, "Abort hook phases that exceed this timeout in milliseconds (0 disables)"); + app.add_option("--hook-timeout-ms", hook_timeout_ms, + "Abort hook phases that exceed this timeout in milliseconds (0 disables)"); app.add_flag("--dry-run", request.dry_run, "Show build plan without writing files or running hooks"); app.add_flag("--plan-json", request.plan_json, "Output build plan as JSON"); @@ -433,16 +425,16 @@ CliRequest parse_render_request(const std::vector& args) { if (!render_target_dir.empty()) { request.target_dir = render_target_dir; } - for (const std::string& assignment : assignments) { + for (const std::string &assignment : assignments) { const auto [key, value] = split_assignment(assignment); request.variables[key] = value; } return request; } -} +} // namespace -CliRequest CliParser::parse(const std::vector& args) const { +CliRequest CliParser::parse(const std::vector &args) const { CliRequest request; if (args.empty()) { @@ -517,7 +509,8 @@ CliRequest CliParser::parse(const std::vector& args) const { } if (args.size() >= 3 && !args[0].starts_with('-') && !args[1].starts_with('-') && is_questions_token(args[2])) { - throw TempifyError("Question overview does not accept target path. Use `tempify -q|--questions [--json] [--full]`." ); + throw TempifyError("Question overview does not accept target path. Use `tempify -q|--questions " + "[--json] [--full]`."); } if (!args.empty() && !is_help_token(args[0]) && contains_help_token(args, 1)) { @@ -538,15 +531,15 @@ CliRequest CliParser::parse(const std::vector& args) const { } if (args[0] == "schema") { - throw TempifyError("Command `schema` removed. Use `tempify -q|--questions`." ); + throw TempifyError("Command `schema` removed. Use `tempify -q|--questions`."); } if (args[0] == "questions") { - throw TempifyError("Command `questions` removed. Use `tempify -q|--questions`." ); + throw TempifyError("Command `questions` removed. Use `tempify -q|--questions`."); } if (args[0] == "registry") { - throw TempifyError("Command `registry` removed. Use shared local template store and `tempify refresh`." ); + throw TempifyError("Command `registry` removed. Use shared local template store and `tempify refresh`."); } if (args[0] == "-p" || args[0] == "--prebyte") { @@ -608,4 +601,4 @@ CliRequest CliParser::parse(const std::vector& args) const { return parse_render_request(args); } -} +} // namespace tempify diff --git a/src/main/cpp/cli/ShellCompletion.cpp b/src/main/cpp/cli/ShellCompletion.cpp index 647c168..93b7176 100644 --- a/src/main/cpp/cli/ShellCompletion.cpp +++ b/src/main/cpp/cli/ShellCompletion.cpp @@ -160,9 +160,9 @@ complete -c tempify -n "__fish_seen_subcommand_from reapply" -s s -l skip-if-fil )FISH"; } -} +} // namespace -std::string render_shell_completion(const std::string& shell) { +std::string render_shell_completion(const std::string &shell) { if (shell == "bash") { return bash_completion(); } @@ -175,4 +175,4 @@ std::string render_shell_completion(const std::string& shell) { throw TempifyError("Invalid shell for `completion`: " + shell + ". Expected bash, zsh, or fish."); } -} +} // namespace tempify diff --git a/src/main/cpp/config/TempifyConfig.cpp b/src/main/cpp/config/TempifyConfig.cpp index 1908292..80a9b5e 100644 --- a/src/main/cpp/config/TempifyConfig.cpp +++ b/src/main/cpp/config/TempifyConfig.cpp @@ -1,9 +1,8 @@ #include "tempify/config/TempifyConfig.h" -#include "tempify/support/Errors.h" - #include "datatypes/Data.h" #include "parser/JsonParser.h" +#include "tempify/support/Errors.h" #include #include @@ -12,9 +11,7 @@ namespace tempify { namespace { -std::string scalar_to_string(const prebyte::Data& value, - const std::filesystem::path& path, - const std::string& key) { +std::string scalar_to_string(const prebyte::Data &value, const std::filesystem::path &path, const std::string &key) { if (value.is_string()) { return value.as_string_ref(); } @@ -31,8 +28,7 @@ std::string scalar_to_string(const prebyte::Data& value, throw TempifyError("Config value for key '" + key + "' must be scalar in " + path.string()); } -HookAcceptance parse_hook_acceptance(const std::string& value, - const std::filesystem::path& path) { +HookAcceptance parse_hook_acceptance(const std::string &value, const std::filesystem::path &path) { if (value == "yes") { return HookAcceptance::Yes; } @@ -46,8 +42,7 @@ HookAcceptance parse_hook_acceptance(const std::string& value, throw TempifyError("Invalid render.accept_hooks value in config '" + path.string() + "': " + value); } -ExistingPathBehavior parse_existing_path_behavior(const std::string& value, - const std::filesystem::path& path) { +ExistingPathBehavior parse_existing_path_behavior(const std::string &value, const std::filesystem::path &path) { if (value == "error") { return ExistingPathBehavior::Error; } @@ -61,26 +56,22 @@ ExistingPathBehavior parse_existing_path_behavior(const std::string& value, throw TempifyError("Invalid render.existing_path_behavior value in config '" + path.string() + "': " + value); } -void load_defaults(const prebyte::Data& data, - const std::filesystem::path& path, - TempifyConfig& config) { +void load_defaults(const prebyte::Data &data, const std::filesystem::path &path, TempifyConfig &config) { if (!data.is_map()) { throw TempifyError("Field 'defaults' in config must be JSON object: " + path.string()); } - for (const auto& [key, value] : data.as_map()) { + for (const auto &[key, value] : data.as_map()) { config.defaults[key] = scalar_to_string(value, path, key); } } -void load_render_config(const prebyte::Data& data, - const std::filesystem::path& path, - TempifyConfig& config) { +void load_render_config(const prebyte::Data &data, const std::filesystem::path &path, TempifyConfig &config) { if (!data.is_map()) { throw TempifyError("Field 'render' in config must be JSON object: " + path.string()); } - for (const auto& [key, value] : data.as_map()) { + for (const auto &[key, value] : data.as_map()) { if (key == "accept_hooks") { if (!value.is_string()) { throw TempifyError("Field 'render.accept_hooks' in config must be string: " + path.string()); @@ -95,7 +86,8 @@ void load_render_config(const prebyte::Data& data, } const int timeout = value.as_int(); if (timeout < 0) { - throw TempifyError("Field 'render.hook_timeout_ms' in config must be zero or greater: " + path.string()); + throw TempifyError("Field 'render.hook_timeout_ms' in config must be zero or greater: " + + path.string()); } config.hook_timeout_ms = timeout; continue; @@ -113,9 +105,9 @@ void load_render_config(const prebyte::Data& data, } } -} +} // namespace -TempifyConfig load_tempify_config_file(const std::filesystem::path& path) { +TempifyConfig load_tempify_config_file(const std::filesystem::path &path) { if (!std::filesystem::is_regular_file(path)) { throw TempifyError("Config file not found: " + path.string()); } @@ -124,7 +116,7 @@ TempifyConfig load_tempify_config_file(const std::filesystem::path& path) { prebyte::Data data; try { data = parser.parse(path); - } catch (const std::exception& error) { + } catch (const std::exception &error) { throw TempifyError("Could not parse config file '" + path.string() + "': " + error.what()); } @@ -133,7 +125,7 @@ TempifyConfig load_tempify_config_file(const std::filesystem::path& path) { } TempifyConfig config; - for (const auto& [key, value] : data.as_map()) { + for (const auto &[key, value] : data.as_map()) { if (key == "defaults") { load_defaults(value, path, config); continue; @@ -150,9 +142,9 @@ TempifyConfig load_tempify_config_file(const std::filesystem::path& path) { return config; } -TempifyConfig merge_tempify_config(const TempifyConfig& base, const TempifyConfig& overlay) { +TempifyConfig merge_tempify_config(const TempifyConfig &base, const TempifyConfig &overlay) { TempifyConfig merged = base; - for (const auto& [key, value] : overlay.defaults) { + for (const auto &[key, value] : overlay.defaults) { merged.defaults[key] = value; } if (overlay.hook_acceptance.has_value()) { @@ -167,4 +159,4 @@ TempifyConfig merge_tempify_config(const TempifyConfig& base, const TempifyConfi return merged; } -} +} // namespace tempify diff --git a/src/main/cpp/frontend/PlainCliFrontend.cpp b/src/main/cpp/frontend/PlainCliFrontend.cpp index 6591104..9bf0992 100644 --- a/src/main/cpp/frontend/PlainCliFrontend.cpp +++ b/src/main/cpp/frontend/PlainCliFrontend.cpp @@ -16,7 +16,7 @@ namespace { #if !defined(_WIN32) using TerminalState = termios; -void restore_terminal_echo(const std::optional& original) { +void restore_terminal_echo(const std::optional &original) { if (original.has_value()) { tcsetattr(STDIN_FILENO, TCSANOW, &*original); std::cout << '\n'; @@ -44,7 +44,7 @@ std::optional disable_terminal_echo(const bool sensitive) { #else struct TerminalState {}; -void restore_terminal_echo(const std::optional& original) { +void restore_terminal_echo(const std::optional &original) { static_cast(original); } @@ -54,9 +54,9 @@ std::optional disable_terminal_echo(const bool sensitive) { } #endif -} +} // namespace -std::optional PlainCliFrontend::prompt(const std::string& text, const bool sensitive) { +std::optional PlainCliFrontend::prompt(const std::string &text, const bool sensitive) { std::cout << text; std::cout.flush(); @@ -78,11 +78,11 @@ std::optional PlainCliFrontend::prompt(const std::string& text, co return PromptResult{.action = FrontendAction::Submit, .value = line}; } -void PlainCliFrontend::write_line(const std::string& text) { +void PlainCliFrontend::write_line(const std::string &text) { std::cout << text << '\n'; } -void PlainCliFrontend::begin_group(const std::string& name, const std::size_t, const std::size_t) { +void PlainCliFrontend::begin_group(const std::string &name, const std::size_t, const std::size_t) { if (!name.empty()) { std::cout << '[' << name << "]\n"; } @@ -90,4 +90,4 @@ void PlainCliFrontend::begin_group(const std::string& name, const std::size_t, c void PlainCliFrontend::end_group() {} -} +} // namespace tempify diff --git a/src/main/cpp/frontend/WizardFrontend.cpp b/src/main/cpp/frontend/WizardFrontend.cpp index 2d99399..b6b6b51 100644 --- a/src/main/cpp/frontend/WizardFrontend.cpp +++ b/src/main/cpp/frontend/WizardFrontend.cpp @@ -16,7 +16,7 @@ namespace { #if !defined(_WIN32) using TerminalState = termios; -void restore_terminal_echo(const std::optional& original) { +void restore_terminal_echo(const std::optional &original) { if (original.has_value()) { tcsetattr(STDIN_FILENO, TCSANOW, &*original); std::cout << '\n'; @@ -44,7 +44,7 @@ std::optional disable_terminal_echo(const bool sensitive) { #else struct TerminalState {}; -void restore_terminal_echo(const std::optional& original) { +void restore_terminal_echo(const std::optional &original) { static_cast(original); } @@ -54,9 +54,9 @@ std::optional disable_terminal_echo(const bool sensitive) { } #endif -} +} // namespace -std::optional WizardFrontend::prompt(const std::string& text, const bool sensitive) { +std::optional WizardFrontend::prompt(const std::string &text, const bool sensitive) { std::cout << text; std::cout.flush(); @@ -78,11 +78,11 @@ std::optional WizardFrontend::prompt(const std::string& text, cons return PromptResult{.action = FrontendAction::Submit, .value = line}; } -void WizardFrontend::write_line(const std::string& text) { +void WizardFrontend::write_line(const std::string &text) { std::cout << text << '\n'; } -void WizardFrontend::begin_group(const std::string& name, const std::size_t index, const std::size_t total) { +void WizardFrontend::begin_group(const std::string &name, const std::size_t index, const std::size_t total) { std::cout << "\n"; std::cout << "========================================\n"; std::cout << "Page " << index << '/' << total; @@ -99,4 +99,4 @@ void WizardFrontend::end_group() { std::cout << "----------------------------------------\n"; } -} +} // namespace tempify diff --git a/src/main/cpp/hook/HookTrustStore.cpp b/src/main/cpp/hook/HookTrustStore.cpp index 838ef1b..0cdd455 100644 --- a/src/main/cpp/hook/HookTrustStore.cpp +++ b/src/main/cpp/hook/HookTrustStore.cpp @@ -1,9 +1,8 @@ #include "tempify/hook/HookTrustStore.h" -#include "tempify/support/Errors.h" - #include "datatypes/Data.h" #include "parser/JsonParser.h" +#include "tempify/support/Errors.h" #include #include @@ -13,23 +12,35 @@ namespace tempify { namespace { -std::string json_escape(const std::string& value) { +std::string json_escape(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; } -std::set load_trusted_roots(const std::filesystem::path& path) { +std::set load_trusted_roots(const std::filesystem::path &path) { std::set roots; if (!std::filesystem::is_regular_file(path)) { return roots; @@ -39,7 +50,7 @@ std::set load_trusted_roots(const std::filesystem::path& path) { prebyte::Data data; try { data = parser.parse(path); - } catch (const std::exception& error) { + } catch (const std::exception &error) { throw TempifyError("Could not parse hook trust store '" + path.string() + "': " + error.what()); } @@ -47,13 +58,13 @@ std::set load_trusted_roots(const std::filesystem::path& path) { throw TempifyError("Hook trust store must be JSON object: " + path.string()); } - const auto& root = data.as_map(); + const auto &root = data.as_map(); const auto it = root.find("trusted_templates"); if (it == root.end() || !it->second.is_array()) { return roots; } - for (const auto& entry : it->second.as_array()) { + for (const auto &entry : it->second.as_array()) { if (!entry.is_string()) { continue; } @@ -62,13 +73,12 @@ std::set load_trusted_roots(const std::filesystem::path& path) { return roots; } -void save_trusted_roots(const std::filesystem::path& path, - const std::set& roots) { +void save_trusted_roots(const std::filesystem::path &path, const std::set &roots) { std::ostringstream stream; stream << "{\n"; stream << " \"trusted_templates\": [\n"; std::size_t index = 0; - for (const auto& root : roots) { + for (const auto &root : roots) { stream << " \"" << json_escape(root) << "\""; if (index + 1 < roots.size()) { stream << ','; @@ -89,32 +99,31 @@ void save_trusted_roots(const std::filesystem::path& path, output << stream.str(); } -std::string canonical_root_string(const std::filesystem::path& template_root) { +std::string canonical_root_string(const std::filesystem::path &template_root) { return std::filesystem::weakly_canonical(template_root).string(); } -} +} // namespace -HookTrustStore::HookTrustStore(std::filesystem::path path) - : path_(std::move(path)) {} +HookTrustStore::HookTrustStore(std::filesystem::path path) : path_(std::move(path)) {} -const std::filesystem::path& HookTrustStore::path() const noexcept { +const std::filesystem::path &HookTrustStore::path() const noexcept { return path_; } -bool HookTrustStore::is_trusted(const std::filesystem::path& template_root) const { +bool HookTrustStore::is_trusted(const std::filesystem::path &template_root) const { const auto roots = load_trusted_roots(path_); return roots.contains(canonical_root_string(template_root)); } -void HookTrustStore::trust(const std::filesystem::path& template_root) const { +void HookTrustStore::trust(const std::filesystem::path &template_root) const { auto roots = load_trusted_roots(path_); roots.insert(canonical_root_string(template_root)); save_trusted_roots(path_, roots); } -std::filesystem::path default_hook_trust_store_path(const std::filesystem::path& data_root) { +std::filesystem::path default_hook_trust_store_path(const std::filesystem::path &data_root) { return data_root / "trust" / "hooks.json"; } -} +} // namespace tempify diff --git a/src/main/cpp/lua/LuaEngine.cpp b/src/main/cpp/lua/LuaEngine.cpp index a5ba051..2564dd8 100644 --- a/src/main/cpp/lua/LuaEngine.cpp +++ b/src/main/cpp/lua/LuaEngine.cpp @@ -8,13 +8,13 @@ namespace tempify { namespace { -std::vector effective_group_order(const TemplateManifest& manifest) { +std::vector effective_group_order(const TemplateManifest &manifest) { if (!manifest.question_group_order.empty()) { return manifest.question_group_order; } std::vector groups; - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (std::ranges::find(groups, question.group) == groups.end()) { groups.push_back(question.group); } @@ -22,27 +22,36 @@ std::vector effective_group_order(const TemplateManifest& manifest) return groups; } -std::string escape_json(const std::string& value) { +std::string escape_json(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; } -void append_json_string_field(std::vector& fields, - const std::string& indent, - const std::string& name, - const std::string& value, - const bool full) { +void append_json_string_field(std::vector &fields, const std::string &indent, const std::string &name, + const std::string &value, const bool full) { if (!full && value.empty()) { return; } @@ -50,11 +59,8 @@ void append_json_string_field(std::vector& fields, fields.push_back(indent + "\"" + name + "\": \"" + escape_json(value) + "\""); } -void append_json_bool_field(std::vector& fields, - const std::string& indent, - const std::string& name, - const bool value, - const bool full) { +void append_json_bool_field(std::vector &fields, const std::string &indent, const std::string &name, + const bool value, const bool full) { if (!full && !value) { return; } @@ -62,11 +68,8 @@ void append_json_bool_field(std::vector& fields, fields.push_back(indent + "\"" + name + "\": " + (value ? "true" : "false")); } -void append_json_array_field(std::vector& fields, - const std::string& indent, - const std::string& name, - const std::vector& values, - const bool full) { +void append_json_array_field(std::vector &fields, const std::string &indent, const std::string &name, + const std::vector &values, const bool full) { if (!full && values.empty()) { return; } @@ -83,8 +86,7 @@ void append_json_array_field(std::vector& fields, fields.push_back(stream.str()); } -void write_json_fields(std::ostringstream& stream, - const std::vector& fields) { +void write_json_fields(std::ostringstream &stream, const std::vector &fields) { for (std::size_t index = 0; index < fields.size(); ++index) { stream << fields[index]; if (index + 1 < fields.size()) { @@ -94,10 +96,9 @@ void write_json_fields(std::ostringstream& stream, } } -} +} // namespace -std::string LuaEngine::export_questions_json(const TemplateManifest& manifest, - const bool full) const { +std::string LuaEngine::export_questions_json(const TemplateManifest &manifest, const bool full) const { std::ostringstream stream; std::vector template_fields; template_fields.push_back(" \"id\": \"" + escape_json(manifest.info.id) + "\""); @@ -119,11 +120,11 @@ std::string LuaEngine::export_questions_json(const TemplateManifest& manifest, stream << " \"questions\": {\n"; for (std::size_t group_index = 0; group_index < group_order.size(); ++group_index) { - const auto& group_name = group_order[group_index]; + const auto &group_name = group_order[group_index]; stream << " \"" << escape_json(group_name) << "\": [\n"; bool wrote_question = false; - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (question.group != group_name) { continue; } @@ -135,16 +136,9 @@ std::string LuaEngine::export_questions_json(const TemplateManifest& manifest, append_json_bool_field(fields, " ", "optional", question.optional, full); append_json_bool_field(fields, " ", "sensitive", question.sensitive, full); append_json_string_field(fields, " ", "help", question.help, full); - append_json_bool_field(fields, - " ", - "has_condition", - question.condition_is_function || question.condition_value.has_value(), - full); - append_json_bool_field(fields, - " ", - "has_validate", - question.validate_is_function, - full); + append_json_bool_field(fields, " ", "has_condition", + question.condition_is_function || question.condition_value.has_value(), full); + append_json_bool_field(fields, " ", "has_validate", question.validate_is_function, full); append_json_array_field(fields, " ", "aliases", question.aliases, full); append_json_array_field(fields, " ", "choices", question.choices, full); @@ -171,4 +165,4 @@ std::string LuaEngine::export_questions_json(const TemplateManifest& manifest, return stream.str(); } -} +} // namespace tempify diff --git a/src/main/cpp/lua/LuaHookRuntime.cpp b/src/main/cpp/lua/LuaHookRuntime.cpp index eec87ba..2f3e779 100644 --- a/src/main/cpp/lua/LuaHookRuntime.cpp +++ b/src/main/cpp/lua/LuaHookRuntime.cpp @@ -1,7 +1,5 @@ -#include "tempify/lua/LuaEngine.h" - #include "LuaEngineInternal.h" - +#include "tempify/lua/LuaEngine.h" #include "tempify/prebyte/PrebyteRenderer.h" #include @@ -25,34 +23,34 @@ namespace tempify { namespace { struct HookHost { - const TemplateManifest* manifest = nullptr; - const BuildContext* context = nullptr; - const PrebyteRenderer* renderer = nullptr; + const TemplateManifest *manifest = nullptr; + const BuildContext *context = nullptr; + const PrebyteRenderer *renderer = nullptr; std::optional deadline; std::optional timeout; }; constexpr char kHookHostRegistryKey = '\0'; -HookHost* hook_host(lua_State* state) { - return static_cast(lua_touserdata(state, lua_upvalueindex(1))); +HookHost *hook_host(lua_State *state) { + return static_cast(lua_touserdata(state, lua_upvalueindex(1))); } -HookHost* registered_hook_host(lua_State* state) { - lua_pushlightuserdata(state, const_cast(&kHookHostRegistryKey)); +HookHost *registered_hook_host(lua_State *state) { + lua_pushlightuserdata(state, const_cast(&kHookHostRegistryKey)); lua_rawget(state, LUA_REGISTRYINDEX); - HookHost* host = static_cast(lua_touserdata(state, -1)); + HookHost *host = static_cast(lua_touserdata(state, -1)); lua_pop(state, 1); return host; } -void register_hook_host(lua_State* state, HookHost* host) { - lua_pushlightuserdata(state, const_cast(&kHookHostRegistryKey)); +void register_hook_host(lua_State *state, HookHost *host) { + lua_pushlightuserdata(state, const_cast(&kHookHostRegistryKey)); lua_pushlightuserdata(state, host); lua_rawset(state, LUA_REGISTRYINDEX); } -std::optional remaining_timeout(const HookHost& host) { +std::optional remaining_timeout(const HookHost &host) { if (!host.deadline.has_value()) { return std::nullopt; } @@ -65,17 +63,17 @@ std::optional remaining_timeout(const HookHost& host) return std::chrono::duration_cast(*host.deadline - now); } -bool hook_timed_out(const HookHost& host) { +bool hook_timed_out(const HookHost &host) { return host.deadline.has_value() && std::chrono::steady_clock::now() >= *host.deadline; } -int timeout_message_ms(const HookHost& host) { +int timeout_message_ms(const HookHost &host) { return static_cast(host.timeout.value_or(std::chrono::milliseconds(0)).count()); } -void hook_timeout_guard(lua_State* state, lua_Debug* debug) { +void hook_timeout_guard(lua_State *state, lua_Debug *debug) { static_cast(debug); - HookHost* host = registered_hook_host(state); + HookHost *host = registered_hook_host(state); if (host == nullptr || !hook_timed_out(*host)) { return; } @@ -83,7 +81,7 @@ void hook_timeout_guard(lua_State* state, lua_Debug* debug) { luaL_error(state, "Hook timed out after %d ms", timeout_message_ms(*host)); } -std::filesystem::path resolve_input_path(const HookHost& host, const std::filesystem::path& path) { +std::filesystem::path resolve_input_path(const HookHost &host, const std::filesystem::path &path) { if (path.is_absolute()) { return path; } @@ -101,22 +99,22 @@ std::filesystem::path resolve_input_path(const HookHost& host, const std::filesy return build_candidate; } -std::filesystem::path resolve_output_path(const HookHost& host, const std::filesystem::path& path) { +std::filesystem::path resolve_output_path(const HookHost &host, const std::filesystem::path &path) { if (path.is_absolute()) { return path; } return host.context->build_root / path; } -int hook_exists(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_exists(lua_State *state) { + const HookHost &host = *hook_host(state); const std::filesystem::path path = resolve_input_path(host, luaL_checkstring(state, 1)); lua_pushboolean(state, std::filesystem::exists(path)); return 1; } -int hook_mkdir(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_mkdir(lua_State *state) { + const HookHost &host = *hook_host(state); const std::filesystem::path path = resolve_output_path(host, luaL_checkstring(state, 1)); std::filesystem::create_directories(path); const std::string path_text = path.string(); @@ -124,15 +122,15 @@ int hook_mkdir(lua_State* state) { return 1; } -int hook_remove(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_remove(lua_State *state) { + const HookHost &host = *hook_host(state); const std::filesystem::path path = resolve_output_path(host, luaL_checkstring(state, 1)); lua_pushinteger(state, static_cast(std::filesystem::remove_all(path))); return 1; } -int hook_read_file(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_read_file(lua_State *state) { + const HookHost &host = *hook_host(state); const std::filesystem::path path = resolve_input_path(host, luaL_checkstring(state, 1)); std::ifstream input(path, std::ios::binary); if (!input) { @@ -146,8 +144,8 @@ int hook_read_file(lua_State* state) { return 1; } -int hook_write_file(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_write_file(lua_State *state) { + const HookHost &host = *hook_host(state); const std::filesystem::path path = resolve_output_path(host, luaL_checkstring(state, 1)); const std::string content = luaL_checkstring(state, 2); std::filesystem::create_directories(path.parent_path()); @@ -162,15 +160,15 @@ int hook_write_file(lua_State* state) { return 1; } -int hook_list_files(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_list_files(lua_State *state) { + const HookHost &host = *hook_host(state); const std::filesystem::path path = resolve_input_path(host, luaL_checkstring(state, 1)); lua_newtable(state); if (!std::filesystem::is_directory(path)) { return 1; } int lua_index = 1; - for (const auto& entry : std::filesystem::directory_iterator(path)) { + for (const auto &entry : std::filesystem::directory_iterator(path)) { if (!entry.is_regular_file()) { continue; } @@ -181,15 +179,15 @@ int hook_list_files(lua_State* state) { return 1; } -int hook_list_dirs(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_list_dirs(lua_State *state) { + const HookHost &host = *hook_host(state); const std::filesystem::path path = resolve_input_path(host, luaL_checkstring(state, 1)); lua_newtable(state); if (!std::filesystem::is_directory(path)) { return 1; } int lua_index = 1; - for (const auto& entry : std::filesystem::directory_iterator(path)) { + for (const auto &entry : std::filesystem::directory_iterator(path)) { if (!entry.is_directory()) { continue; } @@ -200,14 +198,16 @@ int hook_list_dirs(lua_State* state) { return 1; } -int hook_copy(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_copy(lua_State *state) { + const HookHost &host = *hook_host(state); const std::filesystem::path source = resolve_input_path(host, luaL_checkstring(state, 1)); const std::filesystem::path target = resolve_output_path(host, luaL_checkstring(state, 2)); std::filesystem::create_directories(target.parent_path()); if (std::filesystem::is_directory(source)) { - std::filesystem::copy(source, target, std::filesystem::copy_options::recursive | std::filesystem::copy_options::overwrite_existing); + std::filesystem::copy(source, target, + std::filesystem::copy_options::recursive | + std::filesystem::copy_options::overwrite_existing); } else { std::filesystem::copy_file(source, target, std::filesystem::copy_options::overwrite_existing); } @@ -217,13 +217,13 @@ int hook_copy(lua_State* state) { return 1; } -int hook_process_string(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_process_string(lua_State *state) { + const HookHost &host = *hook_host(state); std::map values = host.context->values; if (lua_istable(state, 2) != 0) { const auto overrides = lua_internal::table_to_string_map(state, 2); values.insert(overrides.begin(), overrides.end()); - for (const auto& [key, value] : overrides) { + for (const auto &[key, value] : overrides) { values[key] = value; } } @@ -235,14 +235,14 @@ int hook_process_string(lua_State* state) { return 1; } -int hook_process_file(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_process_file(lua_State *state) { + const HookHost &host = *hook_host(state); const std::filesystem::path input = resolve_input_path(host, luaL_checkstring(state, 1)); const std::filesystem::path output = resolve_output_path(host, luaL_checkstring(state, 2)); std::map values = host.context->values; if (lua_istable(state, 3) != 0) { const auto overrides = lua_internal::table_to_string_map(state, 3); - for (const auto& [key, value] : overrides) { + for (const auto &[key, value] : overrides) { values[key] = value; } } @@ -256,9 +256,9 @@ int hook_process_file(lua_State* state) { return 1; } -int hook_exec(lua_State* state) { - const HookHost& host = *hook_host(state); - const char* command = luaL_checkstring(state, 1); +int hook_exec(lua_State *state) { + const HookHost &host = *hook_host(state); + const char *command = luaL_checkstring(state, 1); #if !defined(_WIN32) pid_t child = fork(); @@ -268,7 +268,7 @@ int hook_exec(lua_State* state) { if (child == 0) { setpgid(0, 0); - execl("/bin/sh", "sh", "-c", command, static_cast(nullptr)); + execl("/bin/sh", "sh", "-c", command, static_cast(nullptr)); _exit(127); } @@ -293,13 +293,14 @@ int hook_exec(lua_State* state) { if (hook_timed_out(host)) { kill(-child, SIGKILL); waitpid(child, &status, 0); - return luaL_error(state, "Hook timed out after %d ms while running command: %s", timeout_message_ms(host), command); + return luaL_error(state, "Hook timed out after %d ms while running command: %s", timeout_message_ms(host), + command); } std::this_thread::sleep_for(std::chrono::milliseconds(5)); } #else const std::string shell = [] { - if (const char* comspec = std::getenv("ComSpec"); comspec != nullptr && comspec[0] != '\0') { + if (const char *comspec = std::getenv("ComSpec"); comspec != nullptr && comspec[0] != '\0') { return std::string(comspec); } return std::string("cmd.exe"); @@ -309,16 +310,8 @@ int hook_exec(lua_State* state) { STARTUPINFOA startup_info{}; startup_info.cb = sizeof(startup_info); PROCESS_INFORMATION process_info{}; - if (!CreateProcessA(shell.c_str(), - command_line.data(), - nullptr, - nullptr, - FALSE, - CREATE_NEW_PROCESS_GROUP, - nullptr, - nullptr, - &startup_info, - &process_info)) { + if (!CreateProcessA(shell.c_str(), command_line.data(), nullptr, nullptr, FALSE, CREATE_NEW_PROCESS_GROUP, nullptr, + nullptr, &startup_info, &process_info)) { return luaL_error(state, "Could not start process: %s", command); } @@ -341,7 +334,8 @@ int hook_exec(lua_State* state) { TerminateProcess(process_info.hProcess, 1); WaitForSingleObject(process_info.hProcess, INFINITE); CloseHandle(process_info.hProcess); - return luaL_error(state, "Hook timed out after %d ms while running command: %s", timeout_message_ms(host), command); + return luaL_error(state, "Hook timed out after %d ms while running command: %s", timeout_message_ms(host), + command); } if (wait_result != WAIT_OBJECT_0) { @@ -361,27 +355,27 @@ int hook_exec(lua_State* state) { #endif } -int hook_get_template_root(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_get_template_root(lua_State *state) { + const HookHost &host = *hook_host(state); const std::string value = host.manifest->root.string(); lua_pushlstring(state, value.c_str(), value.size()); return 1; } -int hook_get_build_root(lua_State* state) { - const HookHost& host = *hook_host(state); +int hook_get_build_root(lua_State *state) { + const HookHost &host = *hook_host(state); const std::string value = host.context->build_root.string(); lua_pushlstring(state, value.c_str(), value.size()); return 1; } -void register_hook_function(lua_State* state, const char* name, lua_CFunction function, HookHost* host) { +void register_hook_function(lua_State *state, const char *name, lua_CFunction function, HookHost *host) { lua_pushlightuserdata(state, host); lua_pushcclosure(state, function, 1); lua_setglobal(state, name); } -void register_hook_helpers(lua_State* state, HookHost* host) { +void register_hook_helpers(lua_State *state, HookHost *host) { lua_internal::register_metadata_helpers(state); register_hook_function(state, "copy", hook_copy, host); @@ -397,32 +391,29 @@ void register_hook_helpers(lua_State* state, HookHost* host) { register_hook_function(state, "exec", hook_exec, host); register_hook_function(state, "get_template_root", hook_get_template_root, host); register_hook_function(state, "get_build_root", hook_get_build_root, host); - register_hook_function(state, "script", [](lua_State* inner_state) -> int { - const HookHost& inner_host = *hook_host(inner_state); - const std::string name = luaL_checkstring(inner_state, 1); - const auto it = std::ranges::find_if(inner_host.manifest->scripts, [&](const ScriptCatalogEntry& entry) { - return entry.name == name; - }); - if (it == inner_host.manifest->scripts.end()) { - return luaL_error(inner_state, "Unknown script: %s", name.c_str()); - } - LuaEngine engine; - engine.run_hook(it->path, - *inner_host.manifest, - *inner_host.context, - *inner_host.renderer, - remaining_timeout(inner_host)); - return 0; - }, host); + register_hook_function( + state, "script", + [](lua_State *inner_state) -> int { + const HookHost &inner_host = *hook_host(inner_state); + const std::string name = luaL_checkstring(inner_state, 1); + const auto it = std::ranges::find_if(inner_host.manifest->scripts, + [&](const ScriptCatalogEntry &entry) { return entry.name == name; }); + if (it == inner_host.manifest->scripts.end()) { + return luaL_error(inner_state, "Unknown script: %s", name.c_str()); + } + LuaEngine engine; + engine.run_hook(it->path, *inner_host.manifest, *inner_host.context, *inner_host.renderer, + remaining_timeout(inner_host)); + return 0; + }, + host); } -} +} // namespace -void LuaEngine::run_hook(const std::filesystem::path& script_path, - const TemplateManifest& manifest, - const BuildContext& context, - const PrebyteRenderer& renderer, - const std::optional timeout) const { +void LuaEngine::run_hook(const std::filesystem::path &script_path, const TemplateManifest &manifest, + const BuildContext &context, const PrebyteRenderer &renderer, + const std::optional timeout) const { auto state = lua_internal::make_state(); HookHost host{&manifest, &context, &renderer}; if (timeout.has_value()) { @@ -449,4 +440,4 @@ void LuaEngine::run_hook(const std::filesystem::path& script_path, } } -} +} // namespace tempify diff --git a/src/main/cpp/lua/LuaManifestLoader.cpp b/src/main/cpp/lua/LuaManifestLoader.cpp index 8782c50..992d00b 100644 --- a/src/main/cpp/lua/LuaManifestLoader.cpp +++ b/src/main/cpp/lua/LuaManifestLoader.cpp @@ -1,7 +1,5 @@ -#include "tempify/lua/LuaEngine.h" - #include "LuaEngineInternal.h" - +#include "tempify/lua/LuaEngine.h" #include "tempify/support/EnvLoader.h" #include "tempify/support/Errors.h" @@ -11,9 +9,8 @@ namespace tempify { namespace { -ConflictStrategy parse_conflict_strategy(const std::string& value, - const std::filesystem::path& path, - const std::string& field_name) { +ConflictStrategy parse_conflict_strategy(const std::string &value, const std::filesystem::path &path, + const std::string &field_name) { const std::string lowered = lua_internal::lower_copy(lua_internal::trim_copy(value)); if (lowered == "replace") { return ConflictStrategy::Replace; @@ -28,9 +25,8 @@ ConflictStrategy parse_conflict_strategy(const std::string& value, lua_internal::throw_lua_error(path, "Unsupported conflict strategy in '" + field_name + "': " + value); } -TemplateMergeConfig load_merge_config_from_stack(lua_State* state, - const int table_index, - const std::filesystem::path& path) { +TemplateMergeConfig load_merge_config_from_stack(lua_State *state, const int table_index, + const std::filesystem::path &path) { TemplateMergeConfig config; lua_getfield(state, table_index, "file_conflicts"); @@ -58,35 +54,29 @@ TemplateMergeConfig load_merge_config_from_stack(lua_State* state, lua_getfield(state, table_index, "question_conflicts"); if (lua_isnil(state, -1) == 0) { - config.question_conflicts = parse_conflict_strategy( - lua_internal::value_to_string(state, -1, path), - path, - "question_conflicts"); + config.question_conflicts = + parse_conflict_strategy(lua_internal::value_to_string(state, -1, path), path, "question_conflicts"); } lua_pop(state, 1); lua_getfield(state, table_index, "pre_hook_conflict"); if (lua_isnil(state, -1) == 0) { - config.pre_hook_conflict = parse_conflict_strategy( - lua_internal::value_to_string(state, -1, path), - path, - "pre_hook_conflict"); + config.pre_hook_conflict = + parse_conflict_strategy(lua_internal::value_to_string(state, -1, path), path, "pre_hook_conflict"); } lua_pop(state, 1); lua_getfield(state, table_index, "post_hook_conflict"); if (lua_isnil(state, -1) == 0) { - config.post_hook_conflict = parse_conflict_strategy( - lua_internal::value_to_string(state, -1, path), - path, - "post_hook_conflict"); + config.post_hook_conflict = + parse_conflict_strategy(lua_internal::value_to_string(state, -1, path), path, "post_hook_conflict"); } lua_pop(state, 1); return config; } -std::vector load_layout_rules_from_file(const std::filesystem::path& path) { +std::vector load_layout_rules_from_file(const std::filesystem::path &path) { auto state = lua_internal::make_state(); lua_internal::register_metadata_helpers(state.get()); lua_internal::load_file_result(state.get(), path); @@ -109,7 +99,8 @@ std::vector load_layout_rules_from_file(const std::filesystem::path& rule.source = lua_internal::required_string_field(state.get(), lua_gettop(state.get()), "source", path); rule.source_path = path; - if (const auto target = lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "target", path)) { + if (const auto target = + lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "target", path)) { rule.target = *target; } rule.exclude = lua_internal::optional_bool_field(state.get(), lua_gettop(state.get()), "exclude", false, path); @@ -127,14 +118,14 @@ std::vector load_layout_rules_from_file(const std::filesystem::path& return rules; } -std::vector load_script_catalog(const std::filesystem::path& template_root) { +std::vector load_script_catalog(const std::filesystem::path &template_root) { const std::filesystem::path scripts_root = template_root / "scripts"; std::vector scripts; if (!std::filesystem::is_directory(scripts_root)) { return scripts; } - for (const auto& entry : std::filesystem::directory_iterator(scripts_root)) { + for (const auto &entry : std::filesystem::directory_iterator(scripts_root)) { if (!entry.is_regular_file()) { continue; } @@ -151,11 +142,8 @@ std::vector load_script_catalog(const std::filesystem::path& return scripts; } -QuestionDefinition load_question_entry(lua_State* state, - const int table_index, - const std::filesystem::path& path, - const std::string& group_name, - const std::size_t source_index) { +QuestionDefinition load_question_entry(lua_State *state, const int table_index, const std::filesystem::path &path, + const std::string &group_name, const std::size_t source_index) { if (lua_istable(state, table_index) == 0) { lua_internal::throw_lua_error(path, "Question entry must be table"); } @@ -227,9 +215,8 @@ QuestionDefinition load_question_entry(lua_State* state, return question; } -void load_questions_from_file(const std::filesystem::path& path, - std::vector& group_order, - std::vector& questions) { +void load_questions_from_file(const std::filesystem::path &path, std::vector &group_order, + std::vector &questions) { auto state = lua_internal::make_state(); lua_internal::register_metadata_helpers(state.get()); lua_internal::load_file_result(state.get(), path); @@ -284,13 +271,13 @@ void load_questions_from_file(const std::filesystem::path& path, lua_pop(state.get(), 1); } - for (const auto& group_name : group_order) { + for (const auto &group_name : group_order) { if (std::ranges::find(loaded_groups, group_name) == loaded_groups.end()) { lua_internal::throw_lua_error(path, "Group declared in 'order' missing from 'groups': " + group_name); } } - for (const auto& group_name : loaded_groups) { + for (const auto &group_name : loaded_groups) { if (std::ranges::find(group_order, group_name) == group_order.end()) { lua_internal::throw_lua_error(path, "Group found in 'groups' missing from 'order': " + group_name); } @@ -299,7 +286,7 @@ void load_questions_from_file(const std::filesystem::path& path, lua_pop(state.get(), 1); } -std::vector load_include_ids_from_file(const std::filesystem::path& path) { +std::vector load_include_ids_from_file(const std::filesystem::path &path) { auto state = lua_internal::make_state(); lua_internal::register_metadata_helpers(state.get()); lua_internal::load_file_result(state.get(), path); @@ -309,8 +296,8 @@ std::vector load_include_ids_from_file(const std::filesystem::path& return lua_internal::string_list_from_stack(state.get(), lua_gettop(state.get()), path); } -PrebyteConfig load_prebyte_config_from_file(const std::filesystem::path& path, - const std::filesystem::path& template_root) { +PrebyteConfig load_prebyte_config_from_file(const std::filesystem::path &path, + const std::filesystem::path &template_root) { auto state = lua_internal::make_state(); lua_internal::register_metadata_helpers(state.get()); lua_internal::load_file_result(state.get(), path); @@ -323,7 +310,7 @@ PrebyteConfig load_prebyte_config_from_file(const std::filesystem::path& path, lua_getfield(state.get(), -1, "include_paths"); if (lua_istable(state.get(), -1) != 0) { const auto values = lua_internal::string_list_from_stack(state.get(), lua_gettop(state.get()), path); - for (const auto& value : values) { + for (const auto &value : values) { config.include_paths.push_back(template_root / value); } } @@ -338,14 +325,14 @@ PrebyteConfig load_prebyte_config_from_file(const std::filesystem::path& path, return config; } -} +} // namespace -TemplateInfo LuaEngine::load_template_info(const std::filesystem::path& template_root) const { +TemplateInfo LuaEngine::load_template_info(const std::filesystem::path &template_root) const { const TemplateManifest manifest = load_partial_manifest(template_root); return manifest.info; } -TemplateManifest LuaEngine::load_partial_manifest(const std::filesystem::path& template_root) const { +TemplateManifest LuaEngine::load_partial_manifest(const std::filesystem::path &template_root) const { const std::filesystem::path manifest_path = template_root / "template.lua"; if (!std::filesystem::is_regular_file(manifest_path)) { throw TempifyError("Missing template manifest: " + manifest_path.string()); @@ -363,18 +350,28 @@ TemplateManifest LuaEngine::load_partial_manifest(const std::filesystem::path& t manifest.root = template_root; manifest.info.root = template_root; manifest.info.id = lua_internal::required_string_field(state.get(), lua_gettop(state.get()), "id", manifest_path); - manifest.info.name = lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "name", manifest_path).value_or(manifest.info.id); - manifest.info.description = lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "description", manifest_path).value_or(""); - manifest.version = lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "version", manifest_path).value_or("0.1.0"); + manifest.info.name = + lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "name", manifest_path) + .value_or(manifest.info.id); + manifest.info.description = + lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "description", manifest_path) + .value_or(""); + manifest.version = + lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "version", manifest_path) + .value_or("0.1.0"); manifest.info.version = manifest.version; - manifest.source_dir = lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "source_dir", manifest_path).value_or("files"); + manifest.source_dir = + lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "source_dir", manifest_path) + .value_or("files"); manifest.output_path_template = manifest.info.id; lua_getfield(state.get(), -1, "output"); if (lua_istable(state.get(), -1) != 0) { - manifest.output_path_template = lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "path", manifest_path) - .value_or(manifest.output_path_template); - manifest.overwrite = lua_internal::optional_bool_field(state.get(), lua_gettop(state.get()), "overwrite", false, manifest_path); + manifest.output_path_template = + lua_internal::optional_string_field(state.get(), lua_gettop(state.get()), "path", manifest_path) + .value_or(manifest.output_path_template); + manifest.overwrite = + lua_internal::optional_bool_field(state.get(), lua_gettop(state.get()), "overwrite", false, manifest_path); } lua_pop(state.get(), 1); @@ -386,7 +383,8 @@ TemplateManifest LuaEngine::load_partial_manifest(const std::filesystem::path& t } lua_pop(state.get(), 1); - manifest.include_ids = lua_internal::optional_string_list_field(state.get(), lua_gettop(state.get()), "includes", manifest_path); + manifest.include_ids = + lua_internal::optional_string_list_field(state.get(), lua_gettop(state.get()), "includes", manifest_path); const std::filesystem::path source_root = template_root / manifest.source_dir; if (!std::filesystem::is_directory(source_root)) { @@ -397,7 +395,7 @@ TemplateManifest LuaEngine::load_partial_manifest(const std::filesystem::path& t .template_id = manifest.info.id, }); - for (const auto& entry : std::filesystem::recursive_directory_iterator(source_root)) { + for (const auto &entry : std::filesystem::recursive_directory_iterator(source_root)) { const std::filesystem::path relative = std::filesystem::relative(entry.path(), source_root); if (relative.empty()) { continue; @@ -431,7 +429,7 @@ TemplateManifest LuaEngine::load_partial_manifest(const std::filesystem::path& t load_questions_from_file(questions_path, manifest.question_group_order, manifest.questions); } - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (question.group.empty()) { lua_internal::throw_lua_error(manifest_path, "Question missing group after load: " + question.key); } @@ -451,7 +449,7 @@ TemplateManifest LuaEngine::load_partial_manifest(const std::filesystem::path& t const std::filesystem::path layout_path = template_root / "layout.lua"; if (std::filesystem::is_regular_file(layout_path)) { manifest.layout_rules = load_layout_rules_from_file(layout_path); - for (auto& rule : manifest.layout_rules) { + for (auto &rule : manifest.layout_rules) { rule.source_template_id = manifest.info.id; } } @@ -482,4 +480,4 @@ TemplateManifest LuaEngine::load_partial_manifest(const std::filesystem::path& t return manifest; } -} +} // namespace tempify diff --git a/src/main/cpp/lua/LuaQuestionRuntime.cpp b/src/main/cpp/lua/LuaQuestionRuntime.cpp index 6c26816..c73331a 100644 --- a/src/main/cpp/lua/LuaQuestionRuntime.cpp +++ b/src/main/cpp/lua/LuaQuestionRuntime.cpp @@ -1,18 +1,16 @@ -#include "tempify/lua/LuaEngine.h" - #include "LuaEngineInternal.h" +#include "tempify/lua/LuaEngine.h" namespace tempify { namespace { -void push_question_definition(lua_State* state, - const QuestionDefinition& question, - const char* missing_message) { +void push_question_definition(lua_State *state, const QuestionDefinition &question, const char *missing_message) { lua_internal::register_metadata_helpers(state); lua_internal::load_file_result(state, question.source_path); if (lua_istable(state, -1) == 0) { - lua_internal::throw_lua_error(question.source_path, "questions.lua must return a table with 'order' and 'groups'"); + lua_internal::throw_lua_error(question.source_path, + "questions.lua must return a table with 'order' and 'groups'"); } lua_getfield(state, -1, "groups"); @@ -31,10 +29,10 @@ void push_question_definition(lua_State* state, } } -} +} // namespace -std::optional LuaEngine::evaluate_default(const QuestionDefinition& question, - const std::map& values) const { +std::optional LuaEngine::evaluate_default(const QuestionDefinition &question, + const std::map &values) const { if (!question.default_is_function) { return question.default_value; } @@ -60,8 +58,8 @@ std::optional LuaEngine::evaluate_default(const QuestionDefinition& return lua_internal::value_to_string(state.get(), -1, question.source_path); } -bool LuaEngine::evaluate_condition(const QuestionDefinition& question, - const std::map& values) const { +bool LuaEngine::evaluate_condition(const QuestionDefinition &question, + const std::map &values) const { if (!question.condition_is_function) { if (!question.condition_value.has_value()) { return true; @@ -89,9 +87,8 @@ bool LuaEngine::evaluate_condition(const QuestionDefinition& question, return lua_internal::string_truthy(lua_internal::value_to_string(state.get(), -1, question.source_path)); } -std::optional LuaEngine::validate_answer(const QuestionDefinition& question, - const std::string& candidate, - const std::map& values) const { +std::optional LuaEngine::validate_answer(const QuestionDefinition &question, const std::string &candidate, + const std::map &values) const { if (!question.validate_is_function) { return std::nullopt; } @@ -127,4 +124,4 @@ std::optional LuaEngine::validate_answer(const QuestionDefinition& return message; } -} +} // namespace tempify diff --git a/src/main/cpp/lua/LuaSupport.cpp b/src/main/cpp/lua/LuaSupport.cpp index c284755..f7b41ec 100644 --- a/src/main/cpp/lua/LuaSupport.cpp +++ b/src/main/cpp/lua/LuaSupport.cpp @@ -1,5 +1,4 @@ #include "LuaEngineInternal.h" - #include "tempify/support/Errors.h" #include "tempify/support/Slug.h" @@ -14,12 +13,12 @@ namespace tempify::lua_internal { namespace { -std::string lua_type_name(lua_State* state, const int index) { +std::string lua_type_name(lua_State *state, const int index) { return lua_typename(state, lua_type(state, index)); } -int lua_slugify(lua_State* state) { - const char* input = luaL_checkstring(state, 1); +int lua_slugify(lua_State *state) { + const char *input = luaL_checkstring(state, 1); const std::string output = slugify(input); lua_pushlstring(state, output.c_str(), output.size()); return 1; @@ -38,28 +37,28 @@ std::string generate_token(const std::size_t length) { return token; } -int lua_generate_token(lua_State* state) { +int lua_generate_token(lua_State *state) { const auto length = static_cast(luaL_optinteger(state, 1, 16)); const std::string token = generate_token(length); lua_pushlstring(state, token.c_str(), token.size()); return 1; } -void push_string_map(lua_State* state, const std::map& values) { +void push_string_map(lua_State *state, const std::map &values) { lua_createtable(state, 0, static_cast(values.size())); - for (const auto& [key, value] : values) { + for (const auto &[key, value] : values) { lua_pushlstring(state, value.c_str(), value.size()); lua_setfield(state, -2, key.c_str()); } } -} +} // namespace -[[noreturn]] void throw_lua_error(const std::filesystem::path& path, const std::string& message) { +[[noreturn]] void throw_lua_error(const std::filesystem::path &path, const std::string &message) { throw TempifyError("Lua error in " + path.string() + ": " + message); } -std::string value_to_string(lua_State* state, const int index, const std::filesystem::path& path) { +std::string value_to_string(lua_State *state, const int index, const std::filesystem::path &path) { if (lua_isstring(state, index) != 0) { return lua_tostring(state, index); } @@ -93,9 +92,8 @@ std::string trim_copy(std::string value) { } std::string lower_copy(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { - return static_cast(std::tolower(ch)); - }); + std::transform(value.begin(), value.end(), value.begin(), + [](const unsigned char ch) { return static_cast(std::tolower(ch)); }); return value; } @@ -104,10 +102,8 @@ bool string_truthy(std::string value) { return !(value.empty() || value == "false" || value == "0" || value == "no" || value == "off"); } -std::optional optional_string_field(lua_State* state, - const int table_index, - const char* key, - const std::filesystem::path& path) { +std::optional optional_string_field(lua_State *state, const int table_index, const char *key, + const std::filesystem::path &path) { lua_getfield(state, table_index, key); if (lua_isnil(state, -1) != 0) { lua_pop(state, 1); @@ -119,10 +115,8 @@ std::optional optional_string_field(lua_State* state, return value; } -std::string required_string_field(lua_State* state, - const int table_index, - const char* key, - const std::filesystem::path& path) { +std::string required_string_field(lua_State *state, const int table_index, const char *key, + const std::filesystem::path &path) { const auto value = optional_string_field(state, table_index, key, path); if (!value.has_value() || value->empty()) { throw_lua_error(path, std::string("Missing required field '") + key + "'"); @@ -130,11 +124,8 @@ std::string required_string_field(lua_State* state, return *value; } -bool optional_bool_field(lua_State* state, - const int table_index, - const char* key, - const bool default_value, - const std::filesystem::path& path) { +bool optional_bool_field(lua_State *state, const int table_index, const char *key, const bool default_value, + const std::filesystem::path &path) { lua_getfield(state, table_index, key); if (lua_isnil(state, -1) != 0) { lua_pop(state, 1); @@ -148,9 +139,8 @@ bool optional_bool_field(lua_State* state, return value; } -std::vector string_list_from_stack(lua_State* state, - const int table_index, - const std::filesystem::path& path) { +std::vector string_list_from_stack(lua_State *state, const int table_index, + const std::filesystem::path &path) { std::vector values; const std::size_t count = lua_rawlen(state, table_index); values.reserve(count); @@ -164,10 +154,8 @@ std::vector string_list_from_stack(lua_State* state, return values; } -std::vector optional_string_list_field(lua_State* state, - const int table_index, - const char* key, - const std::filesystem::path& path) { +std::vector optional_string_list_field(lua_State *state, const int table_index, const char *key, + const std::filesystem::path &path) { lua_getfield(state, table_index, key); if (lua_isnil(state, -1) != 0) { lua_pop(state, 1); @@ -182,9 +170,8 @@ std::vector optional_string_list_field(lua_State* state, return values; } -std::map string_map_from_stack(lua_State* state, - const int table_index, - const std::filesystem::path& path) { +std::map string_map_from_stack(lua_State *state, const int table_index, + const std::filesystem::path &path) { std::map values; lua_pushnil(state); while (lua_next(state, table_index) != 0) { @@ -218,7 +205,7 @@ LuaStatePtr make_state() { return state; } -void register_metadata_helpers(lua_State* state) { +void register_metadata_helpers(lua_State *state) { lua_pushcfunction(state, lua_slugify); lua_setglobal(state, "slugify"); @@ -226,7 +213,7 @@ void register_metadata_helpers(lua_State* state) { lua_setglobal(state, "generate_token"); } -void load_file_result(lua_State* state, const std::filesystem::path& path) { +void load_file_result(lua_State *state, const std::filesystem::path &path) { const std::string path_text = path.string(); if (luaL_loadfile(state, path_text.c_str()) != LUA_OK) { const std::string message = lua_tostring(state, -1); @@ -239,7 +226,7 @@ void load_file_result(lua_State* state, const std::filesystem::path& path) { } } -std::map table_to_string_map(lua_State* state, const int index) { +std::map table_to_string_map(lua_State *state, const int index) { std::map values; if (lua_istable(state, index) == 0) { return values; @@ -257,11 +244,9 @@ std::map table_to_string_map(lua_State* state, const i return values; } -void push_context_table(lua_State* state, - const std::map& values, - const std::filesystem::path& template_root, - const std::filesystem::path& build_root, - const std::optional& candidate_value) { +void push_context_table(lua_State *state, const std::map &values, + const std::filesystem::path &template_root, const std::filesystem::path &build_root, + const std::optional &candidate_value) { lua_createtable(state, 0, 4); push_string_map(state, values); @@ -281,4 +266,4 @@ void push_context_table(lua_State* state, } } -} +} // namespace tempify::lua_internal diff --git a/src/main/cpp/main.cpp b/src/main/cpp/main.cpp index 4c47d45..3089080 100644 --- a/src/main/cpp/main.cpp +++ b/src/main/cpp/main.cpp @@ -1,10 +1,8 @@ +#include "support/Diagnostic.h" #include "tempify/app/TempifyApp.h" - #include "tempify/build/ReapplySerialization.h" #include "tempify/support/Errors.h" -#include "support/Diagnostic.h" - #include #include #include @@ -13,20 +11,14 @@ namespace { -bool wants_json_errors(const std::vector& args) { +bool wants_json_errors(const std::vector &args) { if (args.empty()) { return false; } - if (args[0] == "list" - || args[0] == "info" - || args[0] == "doctor" - || args[0] == "validate" - || args[0] == "inspect" - || args[0] == "lint" - || args[0] == "refresh" - || args[0] == "reapply" - || args[0] == "test") { + if (args[0] == "list" || args[0] == "info" || args[0] == "doctor" || args[0] == "validate" || + args[0] == "inspect" || args[0] == "lint" || args[0] == "refresh" || args[0] == "reapply" || + args[0] == "test") { for (std::size_t index = 1; index < args.size(); ++index) { if (args[index] == "--json") { return true; @@ -34,20 +26,10 @@ bool wants_json_errors(const std::vector& args) { } } - if (args[0] != "list" - && args[0] != "info" - && args[0] != "doctor" - && args[0] != "validate" - && args[0] != "inspect" - && args[0] != "lint" - && args[0] != "refresh" - && args[0] != "test" - && args[0] != "completion" - && args[0] != "process" - && args[0] != "reapply" - && args[0] != "-p" - && args[0] != "--prebyte" - && args[0] != "help") { + if (args[0] != "list" && args[0] != "info" && args[0] != "doctor" && args[0] != "validate" && + args[0] != "inspect" && args[0] != "lint" && args[0] != "refresh" && args[0] != "test" && + args[0] != "completion" && args[0] != "process" && args[0] != "reapply" && args[0] != "-p" && + args[0] != "--prebyte" && args[0] != "help") { for (std::size_t index = 1; index < args.size(); ++index) { if (args[index] == "--json") { return true; @@ -58,23 +40,35 @@ bool wants_json_errors(const std::vector& args) { return false; } -std::string json_escape(const std::string& value) { +std::string json_escape(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; } -void write_json_error(const std::string& code, const std::string& message) { +void write_json_error(const std::string &code, const std::string &message) { std::cerr << "{\n" << " \"status\": \"error\",\n" << " \"code\": \"" << code << "\",\n" @@ -82,9 +76,9 @@ void write_json_error(const std::string& code, const std::string& message) { << "}\n"; } -} +} // namespace -int main(int argc, char** argv) { +int main(int argc, char **argv) { std::vector args; args.reserve(static_cast(argc > 0 ? argc - 1 : 0)); for (int index = 1; index < argc; ++index) { @@ -94,21 +88,21 @@ int main(int argc, char** argv) { try { tempify::TempifyApp app; return app.run(args); - } catch (const tempify::ReapplyBlockedError& error) { + } catch (const tempify::ReapplyBlockedError &error) { if (wants_json_errors(args)) { std::cerr << tempify::format_reapply_blocked_error_json(error); } else { std::cerr << error.what() << '\n'; } return 1; - } catch (const prebyte::DiagnosticError& error) { + } catch (const prebyte::DiagnosticError &error) { if (wants_json_errors(args)) { write_json_error("CLI_ERROR", error.what()); } else { std::cerr << error.what() << '\n'; } return 1; - } catch (const std::exception& error) { + } catch (const std::exception &error) { if (wants_json_errors(args)) { write_json_error("CLI_ERROR", error.what()); } else { diff --git a/src/main/cpp/prebyte/PrebyteCommandRunner.cpp b/src/main/cpp/prebyte/PrebyteCommandRunner.cpp index 64a3a0d..f9208d0 100644 --- a/src/main/cpp/prebyte/PrebyteCommandRunner.cpp +++ b/src/main/cpp/prebyte/PrebyteCommandRunner.cpp @@ -5,7 +5,7 @@ namespace tempify { -int PrebyteCommandRunner::run(const std::vector& args) const { +int PrebyteCommandRunner::run(const std::vector &args) const { prebyte::CommandParser parser; prebyte::AppRunner runner; const prebyte::Command command = parser.parse(args); @@ -13,4 +13,4 @@ int PrebyteCommandRunner::run(const std::vector& args) const { return 0; } -} +} // namespace tempify diff --git a/src/main/cpp/prebyte/PrebyteRenderer.cpp b/src/main/cpp/prebyte/PrebyteRenderer.cpp index d70a95c..985ea07 100644 --- a/src/main/cpp/prebyte/PrebyteRenderer.cpp +++ b/src/main/cpp/prebyte/PrebyteRenderer.cpp @@ -4,38 +4,36 @@ namespace tempify { -void PrebyteRenderer::configure(prebyte::Prebyte& engine, - const std::map& values, - const TemplateManifest& manifest) const { - for (const auto& [key, value] : values) { +void PrebyteRenderer::configure(prebyte::Prebyte &engine, const std::map &values, + const TemplateManifest &manifest) const { + for (const auto &[key, value] : values) { engine.set_variable(key, value); } std::set include_paths; - for (const auto& root : manifest.source_roots) { + for (const auto &root : manifest.source_roots) { if (include_paths.insert(root.path.string()).second) { engine.add_include_path(root.path.string()); } } - for (const auto& path : manifest.prebyte_config.include_paths) { + for (const auto &path : manifest.prebyte_config.include_paths) { if (include_paths.insert(path.string()).second) { engine.add_include_path(path.string()); } } - for (const auto& [name, value] : manifest.prebyte_config.rules) { + for (const auto &[name, value] : manifest.prebyte_config.rules) { engine.set_rule(name, value); } } -std::string PrebyteRenderer::render_string(const prebyte::Prebyte& engine, std::string_view input) const { +std::string PrebyteRenderer::render_string(const prebyte::Prebyte &engine, std::string_view input) const { return engine.process(std::string(input)); } -void PrebyteRenderer::render_file(const prebyte::Prebyte& engine, - const std::filesystem::path& input_path, - const std::filesystem::path& output_path) const { +void PrebyteRenderer::render_file(const prebyte::Prebyte &engine, const std::filesystem::path &input_path, + const std::filesystem::path &output_path) const { engine.process_file(input_path.string(), output_path.string()); } -} +} // namespace tempify diff --git a/src/main/cpp/question/AnswerFile.cpp b/src/main/cpp/question/AnswerFile.cpp index 99bfa37..185634e 100644 --- a/src/main/cpp/question/AnswerFile.cpp +++ b/src/main/cpp/question/AnswerFile.cpp @@ -1,9 +1,8 @@ #include "tempify/question/AnswerFile.h" -#include "tempify/support/Errors.h" - #include "datatypes/Data.h" #include "parser/JsonParser.h" +#include "tempify/support/Errors.h" #include #include @@ -13,25 +12,35 @@ namespace tempify { namespace { -std::string json_escape(const std::string& value) { +std::string json_escape(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; } -std::string scalar_to_string(const prebyte::Data& value, - const std::filesystem::path& path, - const std::string& key, +std::string scalar_to_string(const prebyte::Data &value, const std::filesystem::path &path, const std::string &key, const bool strict) { static_cast(strict); if (value.is_string()) { @@ -50,10 +59,9 @@ std::string scalar_to_string(const prebyte::Data& value, throw TempifyError("Answer file value for key '" + key + "' must be scalar in " + path.string()); } -} +} // namespace -std::map load_answer_file(const std::filesystem::path& path, - const bool strict) { +std::map load_answer_file(const std::filesystem::path &path, const bool strict) { if (!std::filesystem::is_regular_file(path)) { throw TempifyError("Answer file not found: " + path.string()); } @@ -62,7 +70,7 @@ std::map load_answer_file(const std::filesystem::path& prebyte::Data data; try { data = parser.parse(path); - } catch (const std::exception& error) { + } catch (const std::exception &error) { throw TempifyError("Could not parse answer file '" + path.string() + "': " + error.what()); } @@ -71,18 +79,17 @@ std::map load_answer_file(const std::filesystem::path& } std::map values; - for (const auto& [key, value] : data.as_map()) { + for (const auto &[key, value] : data.as_map()) { values[key] = scalar_to_string(value, path, key, strict); } return values; } -void write_answer_file(const std::filesystem::path& path, - const std::map& values) { +void write_answer_file(const std::filesystem::path &path, const std::map &values) { std::ostringstream stream; stream << "{\n"; std::size_t index = 0; - for (const auto& [key, value] : values) { + for (const auto &[key, value] : values) { stream << " \"" << json_escape(key) << "\": \"" << json_escape(value) << "\""; if (index + 1 < values.size()) { stream << ','; @@ -102,4 +109,4 @@ void write_answer_file(const std::filesystem::path& path, output << stream.str(); } -} +} // namespace tempify diff --git a/src/main/cpp/question/QuestionGroupFlow.cpp b/src/main/cpp/question/QuestionGroupFlow.cpp index f0ca288..82d8556 100644 --- a/src/main/cpp/question/QuestionGroupFlow.cpp +++ b/src/main/cpp/question/QuestionGroupFlow.cpp @@ -1,18 +1,17 @@ #include "QuestionProcessorInternal.h" - #include "tempify/lua/LuaEngine.h" #include namespace tempify::question_internal { -std::vector build_group_order(const TemplateManifest& manifest) { +std::vector build_group_order(const TemplateManifest &manifest) { if (!manifest.question_group_order.empty()) { return manifest.question_group_order; } std::vector groups; - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { const std::string group = question.group.empty() ? "General" : question.group; if (std::ranges::find(groups, group) == groups.end()) { groups.push_back(group); @@ -21,12 +20,10 @@ std::vector build_group_order(const TemplateManifest& manifest) { return groups; } -void clear_group_values(std::map& values, - const TemplateManifest& manifest, - const std::map& explicit_cli, - const std::map& explicit_imported, - const std::string& group) { - for (const auto& question : manifest.questions) { +void clear_group_values(std::map &values, const TemplateManifest &manifest, + const std::map &explicit_cli, + const std::map &explicit_imported, const std::string &group) { + for (const auto &question : manifest.questions) { const std::string question_group = question.group.empty() ? "General" : question.group; if (question_group != group) { continue; @@ -38,19 +35,18 @@ void clear_group_values(std::map& values, continue; } values.erase(question.key); - for (const auto& alias : question.aliases) { + for (const auto &alias : question.aliases) { values.erase(alias); } } } -bool group_has_interactive_questions(const TemplateManifest& manifest, - const std::string& group, - const std::map& explicit_cli, - const std::map& explicit_imported, - const std::map& current_values, - const LuaEngine& lua_engine) { - for (const auto& question : manifest.questions) { +bool group_has_interactive_questions(const TemplateManifest &manifest, const std::string &group, + const std::map &explicit_cli, + const std::map &explicit_imported, + const std::map ¤t_values, + const LuaEngine &lua_engine) { + for (const auto &question : manifest.questions) { const std::string question_group = question.group.empty() ? "General" : question.group; if (question_group != group) { continue; @@ -69,4 +65,4 @@ bool group_has_interactive_questions(const TemplateManifest& manifest, return false; } -} +} // namespace tempify::question_internal diff --git a/src/main/cpp/question/QuestionProcessor.cpp b/src/main/cpp/question/QuestionProcessor.cpp index a0b057a..0e6fbfb 100644 --- a/src/main/cpp/question/QuestionProcessor.cpp +++ b/src/main/cpp/question/QuestionProcessor.cpp @@ -1,7 +1,6 @@ #include "tempify/question/QuestionProcessor.h" #include "QuestionProcessorInternal.h" - #include "tempify/frontend/IQuestionFrontend.h" #include "tempify/lua/LuaEngine.h" #include "tempify/support/Errors.h" @@ -17,7 +16,7 @@ namespace { constexpr std::string_view kRedactedValue = ""; -std::string review_value_for(const QuestionDefinition& question, const std::string& value) { +std::string review_value_for(const QuestionDefinition &question, const std::string &value) { if (question.sensitive) { return std::string(kRedactedValue); } @@ -25,21 +24,20 @@ std::string review_value_for(const QuestionDefinition& question, const std::stri } std::string normalize_confirmation_value(std::string value) { - std::ranges::transform(value, value.begin(), [](const unsigned char ch) { - return static_cast(std::tolower(ch)); - }); - value.erase(std::remove_if(value.begin(), value.end(), [](const unsigned char ch) { - return std::isspace(ch) != 0; - }), value.end()); + std::ranges::transform(value, value.begin(), + [](const unsigned char ch) { return static_cast(std::tolower(ch)); }); + value.erase( + std::remove_if(value.begin(), value.end(), [](const unsigned char ch) { return std::isspace(ch) != 0; }), + value.end()); return value; } -std::vector build_review_lines(const TemplateManifest& manifest, - const std::map& values, - const LuaEngine& lua_engine) { +std::vector build_review_lines(const TemplateManifest &manifest, + const std::map &values, + const LuaEngine &lua_engine) { std::vector lines; std::string last_group; - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (!lua_engine.evaluate_condition(question, values)) { continue; } @@ -63,13 +61,11 @@ std::vector build_review_lines(const TemplateManifest& manifest, return lines; } -bool review_answers(const TemplateManifest& manifest, - const std::map& values, - const LuaEngine& lua_engine, - IQuestionFrontend& frontend) { +bool review_answers(const TemplateManifest &manifest, const std::map &values, + const LuaEngine &lua_engine, IQuestionFrontend &frontend) { frontend.write_line(""); frontend.write_line("Review values before generate:"); - for (const auto& line : build_review_lines(manifest, values, lua_engine)) { + for (const auto &line : build_review_lines(manifest, values, lua_engine)) { frontend.write_line(line); } @@ -97,12 +93,9 @@ bool review_answers(const TemplateManifest& manifest, } } -void apply_resolved_value(const QuestionDefinition& question, - const std::string& candidate, - std::map& values, - const std::vector& questions, - const LuaEngine& lua_engine, - const std::string& source_label) { +void apply_resolved_value(const QuestionDefinition &question, const std::string &candidate, + std::map &values, const std::vector &questions, + const LuaEngine &lua_engine, const std::string &source_label) { const std::string coerced = question_internal::coerce_value(question, candidate); std::map preview = values; preview[question.key] = coerced; @@ -117,72 +110,64 @@ void apply_resolved_value(const QuestionDefinition& question, question_internal::propagate_aliases(values, questions); } -} +} // namespace -QuestionProcessor::QuestionProcessor(const LuaEngine& lua_engine, IQuestionFrontend& frontend) - : lua_engine_(lua_engine), - frontend_(frontend) {} +QuestionProcessor::QuestionProcessor(const LuaEngine &lua_engine, IQuestionFrontend &frontend) + : lua_engine_(lua_engine), frontend_(frontend) {} -std::map QuestionProcessor::collect(const TemplateManifest& manifest, - const std::map& cli_values, - const std::map& config_values, - const std::map& imported_values, - const bool non_interactive, - const bool strict, +std::map QuestionProcessor::collect(const TemplateManifest &manifest, + const std::map &cli_values, + const std::map &config_values, + const std::map &imported_values, + const bool non_interactive, const bool strict, const bool review_before_finish) const { const auto explicit_cli = question_internal::normalize_assignments(cli_values, manifest.questions, "CLI"); const auto explicit_config = question_internal::normalize_assignments(config_values, manifest.questions, "config"); - const auto explicit_imported = question_internal::normalize_assignments(imported_values, manifest.questions, "answer file"); + const auto explicit_imported = + question_internal::normalize_assignments(imported_values, manifest.questions, "answer file"); const auto env_values = question_internal::normalize_assignments(manifest.env_defaults, manifest.questions, ".env"); std::map values; - for (const auto& [key, value] : env_values) { + for (const auto &[key, value] : env_values) { if (!question_internal::is_question_key(manifest.questions, key)) { values[key] = value; } } - for (const auto& [key, value] : explicit_config) { + for (const auto &[key, value] : explicit_config) { if (!question_internal::is_question_key(manifest.questions, key)) { values[key] = value; } } - for (const auto& [key, value] : explicit_cli) { + for (const auto &[key, value] : explicit_cli) { if (!question_internal::is_question_key(manifest.questions, key)) { values[key] = value; } } if (strict) { - for (const auto& [key, value] : explicit_imported) { + for (const auto &[key, value] : explicit_imported) { static_cast(value); - if (!question_internal::is_question_key(manifest.questions, key) - && manifest.env_defaults.find(key) == manifest.env_defaults.end()) { + if (!question_internal::is_question_key(manifest.questions, key) && + manifest.env_defaults.find(key) == manifest.env_defaults.end()) { throw TempifyError("Unknown key '" + key + "' in answer file"); } } } - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (!lua_engine_.evaluate_condition(question, values)) { continue; } if (const auto explicit_value = explicit_cli.find(question.key); explicit_value != explicit_cli.end()) { - apply_resolved_value(question, - explicit_value->second, - values, - manifest.questions, - lua_engine_, + apply_resolved_value(question, explicit_value->second, values, manifest.questions, lua_engine_, "CLI value"); continue; } - if (const auto imported_value = explicit_imported.find(question.key); imported_value != explicit_imported.end()) { - apply_resolved_value(question, - imported_value->second, - values, - manifest.questions, - lua_engine_, + if (const auto imported_value = explicit_imported.find(question.key); + imported_value != explicit_imported.end()) { + apply_resolved_value(question, imported_value->second, values, manifest.questions, lua_engine_, "Answer file value"); } } @@ -192,9 +177,67 @@ std::map QuestionProcessor::collect(const TemplateMani std::optional last_interactive_group_index; while (true) { while (group_index < groups.size()) { - const std::string& current_group = groups[group_index]; - if (non_interactive) { - for (const auto& question : manifest.questions) { + const std::string ¤t_group = groups[group_index]; + if (non_interactive) { + for (const auto &question : manifest.questions) { + const std::string question_group = question.group.empty() ? "General" : question.group; + if (question_group != current_group) { + continue; + } + if (!lua_engine_.evaluate_condition(question, values)) { + continue; + } + if (const auto explicit_value = explicit_cli.find(question.key); + explicit_value != explicit_cli.end()) { + if (!values.contains(question.key)) { + apply_resolved_value(question, explicit_value->second, values, manifest.questions, + lua_engine_, "CLI value"); + } + continue; + } + if (const auto imported_value = explicit_imported.find(question.key); + imported_value != explicit_imported.end()) { + if (!values.contains(question.key)) { + apply_resolved_value(question, imported_value->second, values, manifest.questions, + lua_engine_, "Answer file value"); + } + continue; + } + + const auto default_value = question_internal::default_value_for( + question, env_values, explicit_config, values, lua_engine_); + if (default_value.has_value()) { + apply_resolved_value(question, *default_value, values, manifest.questions, lua_engine_, + "Default value"); + continue; + } + + if (question.optional) { + values.erase(question.key); + for (const auto &alias : question.aliases) { + values.erase(alias); + } + continue; + } + + throw TempifyError("Missing required answer for '" + question.key + + "' while `--non-interactive` is enabled."); + } + ++group_index; + continue; + } + + if (!question_internal::group_has_interactive_questions(manifest, current_group, explicit_cli, + explicit_imported, values, lua_engine_)) { + ++group_index; + continue; + } + + frontend_.begin_group(current_group, group_index + 1, groups.size()); + bool go_back = false; + bool restart_current = false; + + for (const auto &question : manifest.questions) { const std::string question_group = question.group.empty() ? "General" : question.group; if (question_group != current_group) { continue; @@ -202,158 +245,69 @@ std::map QuestionProcessor::collect(const TemplateMani if (!lua_engine_.evaluate_condition(question, values)) { continue; } - if (const auto explicit_value = explicit_cli.find(question.key); explicit_value != explicit_cli.end()) { + if (explicit_cli.contains(question.key)) { if (!values.contains(question.key)) { - apply_resolved_value(question, - explicit_value->second, - values, - manifest.questions, - lua_engine_, - "CLI value"); + apply_resolved_value(question, explicit_cli.at(question.key), values, manifest.questions, + lua_engine_, "CLI value"); } continue; } - if (const auto imported_value = explicit_imported.find(question.key); imported_value != explicit_imported.end()) { + if (explicit_imported.contains(question.key)) { if (!values.contains(question.key)) { - apply_resolved_value(question, - imported_value->second, - values, - manifest.questions, - lua_engine_, - "Answer file value"); + apply_resolved_value(question, explicit_imported.at(question.key), values, manifest.questions, + lua_engine_, "Answer file value"); } continue; } - const auto default_value = question_internal::default_value_for(question, - env_values, - explicit_config, - values, - lua_engine_); - if (default_value.has_value()) { - apply_resolved_value(question, - *default_value, - values, - manifest.questions, - lua_engine_, - "Default value"); - continue; - } + const auto default_value = + question_internal::default_value_for(question, env_values, explicit_config, values, lua_engine_); + const auto answer = question_internal::ask_question(question, default_value, values, manifest.questions, + lua_engine_, frontend_); - if (question.optional) { + if (!answer.has_value()) { values.erase(question.key); - for (const auto& alias : question.aliases) { + for (const auto &alias : question.aliases) { values.erase(alias); } continue; } - throw TempifyError("Missing required answer for '" + question.key + "' while `--non-interactive` is enabled."); - } - ++group_index; - continue; - } + if (*answer == ":__TEMPIFY_BACK__:") { + if (group_index == 0) { + frontend_.write_line("Already at first page."); + restart_current = true; + } else { + go_back = true; + } + break; + } - if (!question_internal::group_has_interactive_questions(manifest, - current_group, - explicit_cli, - explicit_imported, - values, - lua_engine_)) { - ++group_index; - continue; - } + values[question.key] = *answer; + question_internal::propagate_aliases(values, manifest.questions); + } - frontend_.begin_group(current_group, group_index + 1, groups.size()); - bool go_back = false; - bool restart_current = false; + frontend_.end_group(); - for (const auto& question : manifest.questions) { - const std::string question_group = question.group.empty() ? "General" : question.group; - if (question_group != current_group) { - continue; - } - if (!lua_engine_.evaluate_condition(question, values)) { - continue; - } - if (explicit_cli.contains(question.key)) { - if (!values.contains(question.key)) { - apply_resolved_value(question, - explicit_cli.at(question.key), - values, - manifest.questions, - lua_engine_, - "CLI value"); - } - continue; - } - if (explicit_imported.contains(question.key)) { - if (!values.contains(question.key)) { - apply_resolved_value(question, - explicit_imported.at(question.key), - values, - manifest.questions, - lua_engine_, - "Answer file value"); - } + if (restart_current) { + question_internal::clear_group_values(values, manifest, explicit_cli, explicit_imported, current_group); continue; } - const auto default_value = question_internal::default_value_for(question, - env_values, - explicit_config, - values, - lua_engine_); - const auto answer = question_internal::ask_question( - question, - default_value, - values, - manifest.questions, - lua_engine_, - frontend_); - - if (!answer.has_value()) { - values.erase(question.key); - for (const auto& alias : question.aliases) { - values.erase(alias); - } + if (go_back) { + question_internal::clear_group_values(values, manifest, explicit_cli, explicit_imported, current_group); + const std::size_t previous_group_index = group_index - 1; + const std::string &previous_group = groups[previous_group_index]; + question_internal::clear_group_values(values, manifest, explicit_cli, explicit_imported, + previous_group); + group_index = previous_group_index; continue; } - if (*answer == ":__TEMPIFY_BACK__:") { - if (group_index == 0) { - frontend_.write_line("Already at first page."); - restart_current = true; - } else { - go_back = true; - } - break; - } - - values[question.key] = *answer; - question_internal::propagate_aliases(values, manifest.questions); - } - - frontend_.end_group(); - - if (restart_current) { - question_internal::clear_group_values(values, manifest, explicit_cli, explicit_imported, current_group); - continue; - } - - if (go_back) { - question_internal::clear_group_values(values, manifest, explicit_cli, explicit_imported, current_group); - const std::size_t previous_group_index = group_index - 1; - const std::string& previous_group = groups[previous_group_index]; - question_internal::clear_group_values(values, manifest, explicit_cli, explicit_imported, previous_group); - group_index = previous_group_index; - continue; + last_interactive_group_index = group_index; + ++group_index; } - last_interactive_group_index = group_index; - ++group_index; - } - if (!review_before_finish || !last_interactive_group_index.has_value()) { break; } @@ -363,20 +317,16 @@ std::map QuestionProcessor::collect(const TemplateMani } const std::size_t review_group_index = *last_interactive_group_index; - question_internal::clear_group_values(values, - manifest, - explicit_cli, - explicit_imported, + question_internal::clear_group_values(values, manifest, explicit_cli, explicit_imported, groups[review_group_index]); group_index = review_group_index; last_interactive_group_index.reset(); } if (strict) { - for (const auto& [key, value] : explicit_imported) { + for (const auto &[key, value] : explicit_imported) { static_cast(value); - if (!question_internal::is_question_key(manifest.questions, key) - && values.find(key) == values.end()) { + if (!question_internal::is_question_key(manifest.questions, key) && values.find(key) == values.end()) { throw TempifyError("Unused key '" + key + "' in answer file"); } } @@ -385,4 +335,4 @@ std::map QuestionProcessor::collect(const TemplateMani return values; } -} +} // namespace tempify diff --git a/src/main/cpp/question/QuestionSupport.cpp b/src/main/cpp/question/QuestionSupport.cpp index 6c70282..ee12ba7 100644 --- a/src/main/cpp/question/QuestionSupport.cpp +++ b/src/main/cpp/question/QuestionSupport.cpp @@ -1,12 +1,11 @@ #include "QuestionProcessorInternal.h" - #include "tempify/frontend/IQuestionFrontend.h" #include "tempify/lua/LuaEngine.h" #include "tempify/support/Errors.h" #include -#include #include +#include #include #include #include @@ -17,14 +16,14 @@ namespace { constexpr std::string_view kRedactedValue = ""; -std::string validation_feedback_for(const QuestionDefinition& question) { +std::string validation_feedback_for(const QuestionDefinition &question) { if (question.sensitive) { return std::string("Invalid value for '") + question.key + "'"; } return {}; } -} +} // namespace std::string trim(std::string value) { const auto is_space = [](const unsigned char ch) { return std::isspace(ch) != 0; }; @@ -38,30 +37,27 @@ std::string trim(std::string value) { } std::string to_lower_copy(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { - return static_cast(std::tolower(ch)); - }); + std::transform(value.begin(), value.end(), value.begin(), + [](const unsigned char ch) { return static_cast(std::tolower(ch)); }); return value; } -bool is_question_key(const std::vector& questions, const std::string& key) { - return std::ranges::any_of(questions, [&](const QuestionDefinition& question) { - return question.key == key; - }); +bool is_question_key(const std::vector &questions, const std::string &key) { + return std::ranges::any_of(questions, [&](const QuestionDefinition &question) { return question.key == key; }); } -std::map normalize_assignments(const std::map& raw_values, - const std::vector& questions, - const std::string& source_name) { +std::map normalize_assignments(const std::map &raw_values, + const std::vector &questions, + const std::string &source_name) { std::map alias_to_key; - for (const auto& question : questions) { - for (const auto& alias : question.aliases) { + for (const auto &question : questions) { + for (const auto &alias : question.aliases) { alias_to_key[alias] = question.key; } } std::map normalized; - for (const auto& [raw_key, value] : raw_values) { + for (const auto &[raw_key, value] : raw_values) { const std::string key = alias_to_key.contains(raw_key) ? alias_to_key.at(raw_key) : raw_key; const auto existing = normalized.find(key); if (existing != normalized.end() && existing->second != value) { @@ -73,14 +69,13 @@ std::map normalize_assignments(const std::map& values, - const std::vector& questions) { - for (const auto& question : questions) { +void propagate_aliases(std::map &values, const std::vector &questions) { + for (const auto &question : questions) { const auto it = values.find(question.key); if (it == values.end()) { continue; } - for (const auto& alias : question.aliases) { + for (const auto &alias : question.aliases) { const auto existing = values.find(alias); if (existing != values.end() && existing->second != it->second) { throw TempifyError("Alias conflict for '" + alias + "'"); @@ -90,11 +85,11 @@ void propagate_aliases(std::map& values, } } -std::optional default_value_for(const QuestionDefinition& question, - const std::map& env_values, - const std::map& config_values, - const std::map& current_values, - const LuaEngine& lua_engine) { +std::optional default_value_for(const QuestionDefinition &question, + const std::map &env_values, + const std::map &config_values, + const std::map ¤t_values, + const LuaEngine &lua_engine) { const auto config_value = config_values.find(question.key); if (config_value != config_values.end()) { return config_value->second; @@ -112,7 +107,7 @@ std::optional default_value_for(const QuestionDefinition& question, return question.default_value; } -std::string display_default_value(const QuestionDefinition& question, const std::string& value) { +std::string display_default_value(const QuestionDefinition &question, const std::string &value) { if (question.sensitive) { return std::string(kRedactedValue); } @@ -123,7 +118,7 @@ std::string display_default_value(const QuestionDefinition& question, const std: return value; } -std::string parse_bool_value(const std::string& input) { +std::string parse_bool_value(const std::string &input) { const std::string lowered = to_lower_copy(trim(input)); if (lowered == "true" || lowered == "1" || lowered == "yes" || lowered == "y" || lowered == "on") { return "true"; @@ -135,7 +130,7 @@ std::string parse_bool_value(const std::string& input) { throw TempifyError("Expected boolean value (yes/no, true/false, 1/0)"); } -std::string parse_int_value(const std::string& input) { +std::string parse_int_value(const std::string &input) { const std::string trimmed = trim(input); if (trimmed.empty()) { throw TempifyError("Expected integer value"); @@ -150,7 +145,7 @@ std::string parse_int_value(const std::string& input) { return std::to_string(value); } -std::string parse_choice_value(const QuestionDefinition& question, const std::string& input) { +std::string parse_choice_value(const QuestionDefinition &question, const std::string &input) { const std::string trimmed = trim(input); if (trimmed.empty()) { throw TempifyError("Expected choice value"); @@ -163,9 +158,7 @@ std::string parse_choice_value(const QuestionDefinition& question, const std::st int index = 0; const auto [ptr, ec] = std::from_chars(trimmed.data(), trimmed.data() + trimmed.size(), index); - if (ec == std::errc{} && - ptr == trimmed.data() + trimmed.size() && - index >= 1 && + if (ec == std::errc{} && ptr == trimmed.data() + trimmed.size() && index >= 1 && static_cast(index) <= question.choices.size()) { return question.choices[static_cast(index - 1)]; } @@ -173,7 +166,7 @@ std::string parse_choice_value(const QuestionDefinition& question, const std::st throw TempifyError("Expected one of configured choices"); } -std::string coerce_value(const QuestionDefinition& question, const std::string& raw_value) { +std::string coerce_value(const QuestionDefinition &question, const std::string &raw_value) { const std::string type = to_lower_copy(question.type); if (type == "string") { return raw_value; @@ -194,7 +187,7 @@ std::string coerce_value(const QuestionDefinition& question, const std::string& throw TempifyError("Unsupported question type: " + question.type); } -std::string prompt_text_for(const QuestionDefinition& question, const std::optional& default_value) { +std::string prompt_text_for(const QuestionDefinition &question, const std::optional &default_value) { std::ostringstream stream; stream << (question.prompt.empty() ? question.key : question.prompt); @@ -221,12 +214,11 @@ std::string prompt_text_for(const QuestionDefinition& question, const std::optio return stream.str(); } -std::optional ask_question(const QuestionDefinition& question, - const std::optional& default_value, - std::map& values, - const std::vector& questions, - const LuaEngine& lua_engine, - IQuestionFrontend& frontend) { +std::optional ask_question(const QuestionDefinition &question, + const std::optional &default_value, + std::map &values, + const std::vector &questions, const LuaEngine &lua_engine, + IQuestionFrontend &frontend) { while (true) { const auto input = frontend.prompt(prompt_text_for(question, default_value), question.sensitive); if (!input.has_value()) { @@ -266,7 +258,7 @@ std::optional ask_question(const QuestionDefinition& question, try { candidate = coerce_value(question, candidate); - } catch (const TempifyError& error) { + } catch (const TempifyError &error) { frontend.write_line(error.what()); continue; } @@ -289,4 +281,4 @@ std::optional ask_question(const QuestionDefinition& question, } } -} +} // namespace tempify::question_internal diff --git a/src/main/cpp/store/AvailableTemplateCache.cpp b/src/main/cpp/store/AvailableTemplateCache.cpp index db1b6ef..dc4a11e 100644 --- a/src/main/cpp/store/AvailableTemplateCache.cpp +++ b/src/main/cpp/store/AvailableTemplateCache.cpp @@ -1,9 +1,8 @@ #include "tempify/store/AvailableTemplateCache.h" -#include "tempify/support/Errors.h" - #include "datatypes/Data.h" #include "parser/JsonParser.h" +#include "tempify/support/Errors.h" #include @@ -11,8 +10,7 @@ namespace tempify { namespace { -std::string string_field(const prebyte::Data::Map& object, - const std::string& key) { +std::string string_field(const prebyte::Data::Map &object, const std::string &key) { const auto it = object.find(key); if (it == object.end() || !it->second.is_string()) { return {}; @@ -20,15 +18,14 @@ std::string string_field(const prebyte::Data::Map& object, return it->second.as_string(); } -std::vector string_array_field(const prebyte::Data::Map& object, - const std::string& key) { +std::vector string_array_field(const prebyte::Data::Map &object, const std::string &key) { const auto it = object.find(key); if (it == object.end() || !it->second.is_array()) { return {}; } std::vector values; - for (const auto& item : it->second.as_array()) { + for (const auto &item : it->second.as_array()) { if (item.is_string()) { values.push_back(item.as_string()); } @@ -36,7 +33,7 @@ std::vector string_array_field(const prebyte::Data::Map& object, return values; } -std::vector load_cache(const std::filesystem::path& path) { +std::vector load_cache(const std::filesystem::path &path) { std::vector entries; if (!std::filesystem::is_regular_file(path)) { return entries; @@ -48,18 +45,18 @@ std::vector load_cache(const std::filesystem::path& pat throw TempifyError("Available template cache must be JSON object: " + path.string()); } - const auto& root = data.as_map(); + const auto &root = data.as_map(); const auto templates_it = root.find("templates"); if (templates_it == root.end() || !templates_it->second.is_array()) { return entries; } - for (const auto& item : templates_it->second.as_array()) { + for (const auto &item : templates_it->second.as_array()) { if (!item.is_map()) { continue; } - const auto& object = item.as_map(); + const auto &object = item.as_map(); const std::string id = string_field(object, "id"); const std::string version = string_field(object, "version"); if (id.empty() || version.empty()) { @@ -76,7 +73,7 @@ std::vector load_cache(const std::filesystem::path& pat const auto source_it = object.find("source"); if (source_it != object.end() && source_it->second.is_map()) { - const auto& source = source_it->second.as_map(); + const auto &source = source_it->second.as_map(); entry.source_url = string_field(source, "url"); entry.source_ref = string_field(source, "ref"); entry.source_subdir = string_field(source, "subdir"); @@ -94,13 +91,12 @@ std::vector load_cache(const std::filesystem::path& pat return entries; } -} +} // namespace AvailableTemplateCache::AvailableTemplateCache(std::filesystem::path shared_root) - : root_(std::filesystem::absolute(std::move(shared_root))), - file_(root_ / "index" / "reqpack-available.json") {} + : root_(std::filesystem::absolute(std::move(shared_root))), file_(root_ / "index" / "reqpack-available.json") {} -const std::filesystem::path& AvailableTemplateCache::file() const noexcept { +const std::filesystem::path &AvailableTemplateCache::file() const noexcept { return file_; } @@ -108,8 +104,8 @@ std::vector AvailableTemplateCache::list_templates() co return load_cache(file_); } -std::optional AvailableTemplateCache::find_template(const std::string& id) const { - for (const auto& entry : list_templates()) { +std::optional AvailableTemplateCache::find_template(const std::string &id) const { + for (const auto &entry : list_templates()) { if (entry.id == id) { return entry; } @@ -117,4 +113,4 @@ std::optional AvailableTemplateCache::find_template(con return std::nullopt; } -} +} // namespace tempify diff --git a/src/main/cpp/store/LocalTemplateStore.cpp b/src/main/cpp/store/LocalTemplateStore.cpp index 8cb48b8..87cb05c 100644 --- a/src/main/cpp/store/LocalTemplateStore.cpp +++ b/src/main/cpp/store/LocalTemplateStore.cpp @@ -1,10 +1,9 @@ #include "tempify/store/LocalTemplateStore.h" -#include "tempify/support/Errors.h" -#include "tempify/template/TemplateLoader.h" - #include "datatypes/Data.h" #include "parser/JsonParser.h" +#include "tempify/support/Errors.h" +#include "tempify/template/TemplateLoader.h" #include #include @@ -15,23 +14,35 @@ namespace tempify { namespace { -std::string json_escape(const std::string& value) { +std::string json_escape(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; } -void write_text_file(const std::filesystem::path& path, const std::string& content) { +void write_text_file(const std::filesystem::path &path, const std::string &content) { std::filesystem::create_directories(path.parent_path()); std::ofstream output(path, std::ios::binary); if (!output) { @@ -40,7 +51,7 @@ void write_text_file(const std::filesystem::path& path, const std::string& conte output << content; } -std::vector load_index(const std::filesystem::path& path) { +std::vector load_index(const std::filesystem::path &path) { std::vector entries; if (!std::filesystem::is_regular_file(path)) { return entries; @@ -52,17 +63,17 @@ std::vector load_index(const std::filesystem::path& path) throw TempifyError("Template store index must be JSON object: " + path.string()); } - const auto& root = data.as_map(); + const auto &root = data.as_map(); const auto it = root.find("templates"); if (it == root.end() || !it->second.is_array()) { return entries; } - for (const auto& item : it->second.as_array()) { + for (const auto &item : it->second.as_array()) { if (!item.is_map()) { continue; } - const auto& object = item.as_map(); + const auto &object = item.as_map(); entries.push_back({ .id = object.at("id").as_string(), .name = object.at("name").as_string(), @@ -76,17 +87,14 @@ std::vector load_index(const std::filesystem::path& path) return entries; } -void save_index(const std::filesystem::path& path, - const std::vector& entries) { +void save_index(const std::filesystem::path &path, const std::vector &entries) { std::ostringstream stream; stream << "{\n \"templates\": [\n"; for (std::size_t index = 0; index < entries.size(); ++index) { - const auto& entry = entries[index]; - stream << " {\"id\": \"" << json_escape(entry.id) - << "\", \"name\": \"" << json_escape(entry.name) - << "\", \"description\": \"" << json_escape(entry.description) - << "\", \"version\": \"" << json_escape(entry.version) - << "\", \"path\": \"" << json_escape(entry.path.string()) << "\"}"; + const auto &entry = entries[index]; + stream << " {\"id\": \"" << json_escape(entry.id) << "\", \"name\": \"" << json_escape(entry.name) + << "\", \"description\": \"" << json_escape(entry.description) << "\", \"version\": \"" + << json_escape(entry.version) << "\", \"path\": \"" << json_escape(entry.path.string()) << "\"}"; if (index + 1 < entries.size()) { stream << ','; } @@ -109,27 +117,25 @@ void save_index(const std::filesystem::path& path, } } -} +} // namespace LocalTemplateStore::LocalTemplateStore(std::filesystem::path shared_root) - : root_(std::filesystem::absolute(std::move(shared_root))), - templates_root_(root_ / "templates"), - index_root_(root_ / "index"), - index_file_(index_root_ / "templates.json") {} + : root_(std::filesystem::absolute(std::move(shared_root))), templates_root_(root_ / "templates"), + index_root_(root_ / "index"), index_file_(index_root_ / "templates.json") {} -const std::filesystem::path& LocalTemplateStore::root() const noexcept { +const std::filesystem::path &LocalTemplateStore::root() const noexcept { return root_; } -const std::filesystem::path& LocalTemplateStore::templates_root() const noexcept { +const std::filesystem::path &LocalTemplateStore::templates_root() const noexcept { return templates_root_; } -const std::filesystem::path& LocalTemplateStore::index_root() const noexcept { +const std::filesystem::path &LocalTemplateStore::index_root() const noexcept { return index_root_; } -const std::filesystem::path& LocalTemplateStore::index_file() const noexcept { +const std::filesystem::path &LocalTemplateStore::index_file() const noexcept { return index_file_; } @@ -137,8 +143,8 @@ std::vector LocalTemplateStore::list_templates() const { return load_index(index_file_); } -std::optional LocalTemplateStore::find_template(const std::string& id) const { - for (const auto& entry : list_templates()) { +std::optional LocalTemplateStore::find_template(const std::string &id) const { + for (const auto &entry : list_templates()) { if (entry.id == id) { return entry; } @@ -146,24 +152,24 @@ std::optional LocalTemplateStore::find_template(const std: return std::nullopt; } -std::size_t LocalTemplateStore::refresh(const TemplateLoader& loader) const { +std::size_t LocalTemplateStore::refresh(const TemplateLoader &loader) const { std::filesystem::create_directories(templates_root_); std::filesystem::create_directories(index_root_); std::map entries; std::vector template_roots; - for (const auto& entry : std::filesystem::directory_iterator(templates_root_)) { + for (const auto &entry : std::filesystem::directory_iterator(templates_root_)) { if (entry.is_directory()) { template_roots.push_back(entry.path()); } } std::ranges::sort(template_roots); - for (const auto& template_root : template_roots) { + for (const auto &template_root : template_roots) { TemplateInfo info; try { info = loader.summarize(template_root); - } catch (const TempifyError&) { + } catch (const TempifyError &) { continue; } @@ -183,7 +189,7 @@ std::size_t LocalTemplateStore::refresh(const TemplateLoader& loader) const { std::vector ordered; ordered.reserve(entries.size()); - for (const auto& [id, entry] : entries) { + for (const auto &[id, entry] : entries) { static_cast(id); ordered.push_back(entry); } @@ -192,4 +198,4 @@ std::size_t LocalTemplateStore::refresh(const TemplateLoader& loader) const { return ordered.size(); } -} +} // namespace tempify diff --git a/src/main/cpp/support/EnvLoader.cpp b/src/main/cpp/support/EnvLoader.cpp index d142e2c..3e8a4d2 100644 --- a/src/main/cpp/support/EnvLoader.cpp +++ b/src/main/cpp/support/EnvLoader.cpp @@ -19,15 +19,16 @@ std::string trim(std::string value) { } std::string strip_quotes(std::string value) { - if (value.size() >= 2 && ((value.front() == '"' && value.back() == '"') || (value.front() == '\'' && value.back() == '\''))) { + if (value.size() >= 2 && + ((value.front() == '"' && value.back() == '"') || (value.front() == '\'' && value.back() == '\''))) { return value.substr(1, value.size() - 2); } return value; } -} +} // namespace -std::map load_env_file(const std::filesystem::path& path) { +std::map load_env_file(const std::filesystem::path &path) { std::ifstream input(path); if (!input) { return {}; @@ -56,4 +57,4 @@ std::map load_env_file(const std::filesystem::path& pa return values; } -} +} // namespace tempify diff --git a/src/main/cpp/support/Paths.cpp b/src/main/cpp/support/Paths.cpp index 5f290ae..b7e253e 100644 --- a/src/main/cpp/support/Paths.cpp +++ b/src/main/cpp/support/Paths.cpp @@ -23,8 +23,8 @@ struct SearchCeiling { SearchCeilingMode mode = SearchCeilingMode::Inclusive; }; -std::optional environment_path(const char* name) { - if (const char* value = std::getenv(name)) { +std::optional environment_path(const char *name) { + if (const char *value = std::getenv(name)) { if (*value != '\0') { return std::filesystem::path(value); } @@ -32,8 +32,8 @@ std::optional environment_path(const char* name) { return std::nullopt; } -std::optional first_environment_path(std::initializer_list names) { - for (const char* name : names) { +std::optional first_environment_path(std::initializer_list names) { + for (const char *name : names) { if (const auto path = environment_path(name)) { return path; } @@ -41,7 +41,7 @@ std::optional first_environment_path(std::initializer_lis return std::nullopt; } -std::filesystem::path absolute_path(const std::filesystem::path& path) { +std::filesystem::path absolute_path(const std::filesystem::path &path) { std::error_code error; const std::filesystem::path absolute = std::filesystem::absolute(path, error); if (error) { @@ -50,32 +50,31 @@ std::filesystem::path absolute_path(const std::filesystem::path& path) { return absolute.lexically_normal(); } -bool is_directory_noexcept(const std::filesystem::path& path) { +bool is_directory_noexcept(const std::filesystem::path &path) { std::error_code error; return std::filesystem::is_directory(path, error); } -bool is_regular_file_noexcept(const std::filesystem::path& path) { +bool is_regular_file_noexcept(const std::filesystem::path &path) { std::error_code error; return std::filesystem::is_regular_file(path, error); } -bool exists_noexcept(const std::filesystem::path& path) { +bool exists_noexcept(const std::filesystem::path &path) { std::error_code error; return std::filesystem::exists(path, error); } -std::string normalized_component(const std::filesystem::path& component) { +std::string normalized_component(const std::filesystem::path &component) { std::string value = component.generic_string(); #if defined(_WIN32) - std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { - return static_cast(std::tolower(ch)); - }); + std::transform(value.begin(), value.end(), value.begin(), + [](const unsigned char ch) { return static_cast(std::tolower(ch)); }); #endif return value; } -bool path_starts_with(const std::filesystem::path& path, const std::filesystem::path& prefix) { +bool path_starts_with(const std::filesystem::path &path, const std::filesystem::path &prefix) { const std::filesystem::path normalized_path = path.lexically_normal(); const std::filesystem::path normalized_prefix = prefix.lexically_normal(); auto path_it = normalized_path.begin(); @@ -91,29 +90,21 @@ bool path_starts_with(const std::filesystem::path& path, const std::filesystem:: return true; } -bool reached_exclusive_ceiling(const std::filesystem::path& current, - const std::optional& ceiling) { - return ceiling.has_value() - && ceiling->mode == SearchCeilingMode::Exclusive - && current == ceiling->path; +bool reached_exclusive_ceiling(const std::filesystem::path ¤t, const std::optional &ceiling) { + return ceiling.has_value() && ceiling->mode == SearchCeilingMode::Exclusive && current == ceiling->path; } -bool reached_inclusive_ceiling(const std::filesystem::path& current, - const std::optional& ceiling) { - return ceiling.has_value() - && ceiling->mode == SearchCeilingMode::Inclusive - && current == ceiling->path; +bool reached_inclusive_ceiling(const std::filesystem::path ¤t, const std::optional &ceiling) { + return ceiling.has_value() && ceiling->mode == SearchCeilingMode::Inclusive && current == ceiling->path; } -std::optional search_ceiling(const std::filesystem::path& start) { +std::optional search_ceiling(const std::filesystem::path &start) { const std::filesystem::path absolute_start = absolute_path(start); std::error_code error; const std::filesystem::path temp_root = std::filesystem::temp_directory_path(error); const std::filesystem::path absolute_temp_root = absolute_path(temp_root); - if (!error - && absolute_start != absolute_temp_root - && path_starts_with(absolute_start, absolute_temp_root)) { + if (!error && absolute_start != absolute_temp_root && path_starts_with(absolute_start, absolute_temp_root)) { return SearchCeiling{absolute_temp_root, SearchCeilingMode::Exclusive}; } @@ -131,9 +122,9 @@ std::optional search_ceiling(const std::filesystem::path& start) return std::nullopt; } -} +} // namespace -std::optional find_workspace_templates_root(const std::filesystem::path& start) { +std::optional find_workspace_templates_root(const std::filesystem::path &start) { std::filesystem::path current = absolute_path(start); const std::optional ceiling = search_ceiling(current); @@ -146,9 +137,8 @@ std::optional find_workspace_templates_root(const std::fi return current / "templates"; } - if (reached_inclusive_ceiling(current, ceiling) - || current == current.root_path() - || current == current.parent_path()) { + if (reached_inclusive_ceiling(current, ceiling) || current == current.root_path() || + current == current.parent_path()) { return std::nullopt; } @@ -156,7 +146,7 @@ std::optional find_workspace_templates_root(const std::fi } } -std::optional find_workspace_config_file(const std::filesystem::path& start) { +std::optional find_workspace_config_file(const std::filesystem::path &start) { std::filesystem::path current = absolute_path(start); const std::optional ceiling = search_ceiling(current); @@ -170,9 +160,8 @@ std::optional find_workspace_config_file(const std::files return candidate; } - if (reached_inclusive_ceiling(current, ceiling) - || current == current.root_path() - || current == current.parent_path()) { + if (reached_inclusive_ceiling(current, ceiling) || current == current.root_path() || + current == current.parent_path()) { return std::nullopt; } @@ -228,4 +217,4 @@ std::filesystem::path default_global_config_file_path() { return resolve_tempify_config_root() / "config.json"; } -} +} // namespace tempify diff --git a/src/main/cpp/support/Slug.cpp b/src/main/cpp/support/Slug.cpp index 8c18a50..a0512c4 100644 --- a/src/main/cpp/support/Slug.cpp +++ b/src/main/cpp/support/Slug.cpp @@ -37,4 +37,4 @@ std::string slugify(std::string_view value) { return result; } -} +} // namespace tempify diff --git a/src/main/cpp/template/TemplateInspector.cpp b/src/main/cpp/template/TemplateInspector.cpp index 7b61f4d..9a67bf0 100644 --- a/src/main/cpp/template/TemplateInspector.cpp +++ b/src/main/cpp/template/TemplateInspector.cpp @@ -6,18 +6,17 @@ namespace tempify { namespace { -void append_hook(std::ostringstream& stream, - const std::string& name, - const std::optional& path) { +void append_hook(std::ostringstream &stream, const std::string &name, + const std::optional &path) { if (!path.has_value()) { return; } stream << "- " << name << ": " << path->string() << '\n'; } -} +} // namespace -std::string inspect_template_text(const TemplateManifest& manifest) { +std::string inspect_template_text(const TemplateManifest &manifest) { std::ostringstream stream; stream << manifest.info.id; if (!manifest.info.name.empty()) { @@ -30,7 +29,7 @@ std::string inspect_template_text(const TemplateManifest& manifest) { stream << "Output: " << manifest.output_path_template << '\n'; stream << "\nSource Roots:\n"; - for (const auto& source_root : manifest.source_roots) { + for (const auto &source_root : manifest.source_roots) { stream << "- " << source_root.template_id << ": " << source_root.path.string() << '\n'; } @@ -38,7 +37,7 @@ std::string inspect_template_text(const TemplateManifest& manifest) { if (manifest.include_ids.empty()) { stream << "- \n"; } else { - for (const auto& include_id : manifest.include_ids) { + for (const auto &include_id : manifest.include_ids) { stream << "- " << include_id << '\n'; } } @@ -47,7 +46,7 @@ std::string inspect_template_text(const TemplateManifest& manifest) { if (manifest.files.empty()) { stream << "- \n"; } else { - for (const auto& file : manifest.files) { + for (const auto &file : manifest.files) { stream << "- " << file.relative_path << " <- " << file.source_template_id; if (file.render_with_prebyte) { stream << " [render]"; @@ -63,7 +62,7 @@ std::string inspect_template_text(const TemplateManifest& manifest) { if (manifest.questions.empty()) { stream << "- \n"; } else { - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { stream << "- " << question.key << " [" << question.type << "]"; if (!question.group.empty()) { stream << " group=" << question.group; @@ -86,7 +85,7 @@ std::string inspect_template_text(const TemplateManifest& manifest) { if (manifest.layout_rules.empty()) { stream << "- \n"; } else { - for (const auto& rule : manifest.layout_rules) { + for (const auto &rule : manifest.layout_rules) { stream << "- source=" << rule.source; if (rule.target.has_value()) { stream << " target=" << *rule.target; @@ -109,16 +108,14 @@ std::string inspect_template_text(const TemplateManifest& manifest) { if (manifest.scripts.empty()) { stream << "- \n"; } else { - for (const auto& script : manifest.scripts) { + for (const auto &script : manifest.scripts) { stream << "- " << script.name << " <- " << script.path.string() << '\n'; } } stream << "\nHooks:\n"; - if (!manifest.pre_hook_path.has_value() - && !manifest.before_render_hook_path.has_value() - && !manifest.after_render_hook_path.has_value() - && !manifest.post_hook_path.has_value()) { + if (!manifest.pre_hook_path.has_value() && !manifest.before_render_hook_path.has_value() && + !manifest.after_render_hook_path.has_value() && !manifest.post_hook_path.has_value()) { stream << "- \n"; } else { append_hook(stream, "pre", manifest.pre_hook_path); @@ -130,4 +127,4 @@ std::string inspect_template_text(const TemplateManifest& manifest) { return stream.str(); } -} +} // namespace tempify diff --git a/src/main/cpp/template/TemplateLinter.cpp b/src/main/cpp/template/TemplateLinter.cpp index bc0eb7d..2c0d8dd 100644 --- a/src/main/cpp/template/TemplateLinter.cpp +++ b/src/main/cpp/template/TemplateLinter.cpp @@ -7,27 +7,27 @@ namespace tempify { namespace { -std::string describe_question(const QuestionDefinition& question) { - return question.source_path.empty() - ? question.key - : question.key + " (" + question.source_path.string() + "#" + std::to_string(question.source_index) + ")"; +std::string describe_question(const QuestionDefinition &question) { + return question.source_path.empty() ? question.key + : question.key + " (" + question.source_path.string() + "#" + + std::to_string(question.source_index) + ")"; } -bool has_matching_source_root(const TemplateManifest& manifest, - const std::filesystem::path& path) { - for (const auto& source_root : manifest.source_roots) { +bool has_matching_source_root(const TemplateManifest &manifest, const std::filesystem::path &path) { + for (const auto &source_root : manifest.source_roots) { const auto root = source_root.path.lexically_normal().generic_string(); const auto candidate = path.lexically_normal().generic_string(); - if (candidate == root || (candidate.size() > root.size() && candidate.starts_with(root) && candidate[root.size()] == '/')) { + if (candidate == root || + (candidate.size() > root.size() && candidate.starts_with(root) && candidate[root.size()] == '/')) { return true; } } return false; } -} +} // namespace -std::vector TemplateLinter::lint(const TemplateManifest& manifest) const { +std::vector TemplateLinter::lint(const TemplateManifest &manifest) const { std::vector warnings; if (manifest.info.description.empty()) { @@ -38,11 +38,11 @@ std::vector TemplateLinter::lint(const TemplateManifest& manifest) } std::set layout_sources; - for (const auto& rule : manifest.layout_rules) { + for (const auto &rule : manifest.layout_rules) { layout_sources.insert(rule.source); } - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (question.prompt.empty()) { warnings.push_back("question missing prompt: " + describe_question(question)); } @@ -57,16 +57,17 @@ std::vector TemplateLinter::lint(const TemplateManifest& manifest) } } - for (const auto& file : manifest.files) { + for (const auto &file : manifest.files) { if (!has_matching_source_root(manifest, file.source_path)) { warnings.push_back("file source outside declared source roots: " + file.source_path.string()); } - if (file.render_with_prebyte && !layout_sources.contains(file.relative_path) && !file.relative_path.ends_with(".pbt")) { + if (file.render_with_prebyte && !layout_sources.contains(file.relative_path) && + !file.relative_path.ends_with(".pbt")) { warnings.push_back("rendered file does not use .pbt extension: " + file.relative_path); } } - for (const auto& rule : manifest.layout_rules) { + for (const auto &rule : manifest.layout_rules) { if (!rule.exclude && !rule.target.has_value()) { warnings.push_back("layout rule relies on implicit target path for source: " + rule.source); } @@ -75,15 +76,14 @@ std::vector TemplateLinter::lint(const TemplateManifest& manifest) return warnings; } -std::string format_template_lint_text(const std::string& template_id, - const std::vector& warnings) { +std::string format_template_lint_text(const std::string &template_id, const std::vector &warnings) { std::ostringstream stream; stream << "Lint " << template_id << '\n'; stream << "Warnings: " << warnings.size() << '\n'; - for (const auto& warning : warnings) { + for (const auto &warning : warnings) { stream << "- " << warning << '\n'; } return stream.str(); } -} +} // namespace tempify diff --git a/src/main/cpp/template/TemplateLoader.cpp b/src/main/cpp/template/TemplateLoader.cpp index 4730886..e0f5c5b 100644 --- a/src/main/cpp/template/TemplateLoader.cpp +++ b/src/main/cpp/template/TemplateLoader.cpp @@ -11,7 +11,7 @@ namespace tempify { namespace { -std::string format_stack(const std::vector& stack) { +std::string format_stack(const std::vector &stack) { std::ostringstream stream; for (std::size_t index = 0; index < stack.size(); ++index) { if (index > 0) { @@ -22,24 +22,23 @@ std::string format_stack(const std::vector& stack) { return stream.str(); } -} +} // namespace -TemplateLoader::TemplateLoader(const LuaEngine& lua_engine) - : lua_engine_(lua_engine) {} +TemplateLoader::TemplateLoader(const LuaEngine &lua_engine) : lua_engine_(lua_engine) {} -TemplateInfo TemplateLoader::summarize(const std::filesystem::path& template_root) const { +TemplateInfo TemplateLoader::summarize(const std::filesystem::path &template_root) const { return lua_engine_.load_template_info(template_root); } -TemplateManifest TemplateLoader::load(const std::filesystem::path& template_root, - const std::map& template_index) const { +TemplateManifest TemplateLoader::load(const std::filesystem::path &template_root, + const std::map &template_index) const { std::vector stack; return load_recursive(template_root, template_index, stack); } -TemplateManifest TemplateLoader::load_recursive(const std::filesystem::path& template_root, - const std::map& template_index, - std::vector& stack) const { +TemplateManifest TemplateLoader::load_recursive(const std::filesystem::path &template_root, + const std::map &template_index, + std::vector &stack) const { if (std::find(stack.begin(), stack.end(), template_root) != stack.end()) { std::vector cycle = stack; cycle.push_back(template_root); @@ -50,7 +49,7 @@ TemplateManifest TemplateLoader::load_recursive(const std::filesystem::path& tem TemplateManifest current = lua_engine_.load_partial_manifest(template_root); TemplateManifest merged; - for (const auto& include_id : current.include_ids) { + for (const auto &include_id : current.include_ids) { const auto it = template_index.find(include_id); if (it == template_index.end()) { throw TempifyError("Unknown included template id '" + include_id + "' while loading " + current.info.id); @@ -63,4 +62,4 @@ TemplateManifest TemplateLoader::load_recursive(const std::filesystem::path& tem return merged; } -} +} // namespace tempify diff --git a/src/main/cpp/template/TemplateMerge.cpp b/src/main/cpp/template/TemplateMerge.cpp index fdd4c9f..91aabf1 100644 --- a/src/main/cpp/template/TemplateMerge.cpp +++ b/src/main/cpp/template/TemplateMerge.cpp @@ -1,6 +1,5 @@ -#include "tempify/template/TemplateLoader.h" - #include "tempify/support/Errors.h" +#include "tempify/template/TemplateLoader.h" #include @@ -8,13 +7,13 @@ namespace tempify { namespace { -ConflictStrategy conflict_for_path(const TemplateMergeConfig& policy, const std::string& path) { +ConflictStrategy conflict_for_path(const TemplateMergeConfig &policy, const std::string &path) { const auto exact = policy.file_conflicts.find(path); if (exact != policy.file_conflicts.end()) { return exact->second; } - for (const auto& [key, strategy] : policy.file_conflicts) { + for (const auto &[key, strategy] : policy.file_conflicts) { if (key.size() > 2 && key.starts_with("*.") && path.ends_with(key.substr(1))) { return strategy; } @@ -23,21 +22,19 @@ ConflictStrategy conflict_for_path(const TemplateMergeConfig& policy, const std: return ConflictStrategy::Replace; } -bool path_matches_drop_rule(const std::string& candidate, const std::string& drop_path) { +bool path_matches_drop_rule(const std::string &candidate, const std::string &drop_path) { if (candidate == drop_path) { return true; } - return candidate.size() > drop_path.size() - && candidate.starts_with(drop_path) - && candidate[drop_path.size()] == '/'; + return candidate.size() > drop_path.size() && candidate.starts_with(drop_path) && + candidate[drop_path.size()] == '/'; } -void merge_scripts(TemplateManifest& result, const TemplateManifest& overlay) { - for (const auto& script : overlay.scripts) { - const auto it = std::find_if(result.scripts.begin(), result.scripts.end(), [&](const ScriptCatalogEntry& existing) { - return existing.name == script.name; - }); +void merge_scripts(TemplateManifest &result, const TemplateManifest &overlay) { + for (const auto &script : overlay.scripts) { + const auto it = std::find_if(result.scripts.begin(), result.scripts.end(), + [&](const ScriptCatalogEntry &existing) { return existing.name == script.name; }); if (it == result.scripts.end()) { result.scripts.push_back(script); } else { @@ -46,20 +43,17 @@ void merge_scripts(TemplateManifest& result, const TemplateManifest& overlay) { } } -void merge_questions(TemplateManifest& result, - const TemplateManifest& overlay, - const TemplateMergeConfig& policy) { +void merge_questions(TemplateManifest &result, const TemplateManifest &overlay, const TemplateMergeConfig &policy) { const ConflictStrategy question_conflict = policy.question_conflicts.value_or(ConflictStrategy::Replace); - for (const auto& group_name : overlay.question_group_order) { + for (const auto &group_name : overlay.question_group_order) { if (std::ranges::find(result.question_group_order, group_name) == result.question_group_order.end()) { result.question_group_order.push_back(group_name); } } - for (const auto& question : overlay.questions) { - const auto it = std::find_if(result.questions.begin(), result.questions.end(), [&](const QuestionDefinition& existing) { - return existing.key == question.key; - }); + for (const auto &question : overlay.questions) { + const auto it = std::find_if(result.questions.begin(), result.questions.end(), + [&](const QuestionDefinition &existing) { return existing.key == question.key; }); if (it == result.questions.end()) { result.questions.push_back(question); continue; @@ -72,46 +66,45 @@ void merge_questions(TemplateManifest& result, case ConflictStrategy::Keep: break; case ConflictStrategy::Error: - throw TempifyError("Question conflict for key '" + question.key + "' between templates '" - + it->source_path.parent_path().parent_path().filename().string() + "' and '" + overlay.info.id + "'"); + throw TempifyError("Question conflict for key '" + question.key + "' between templates '" + + it->source_path.parent_path().parent_path().filename().string() + "' and '" + + overlay.info.id + "'"); } } } -void apply_drop_paths(TemplateManifest& result, const TemplateMergeConfig& policy) { +void apply_drop_paths(TemplateManifest &result, const TemplateMergeConfig &policy) { if (policy.drop_paths.empty()) { return; } - std::erase_if(result.directories, [&](const TemplateDirectoryEntry& existing) { - return std::ranges::any_of(policy.drop_paths, [&](const std::string& drop_path) { + std::erase_if(result.directories, [&](const TemplateDirectoryEntry &existing) { + return std::ranges::any_of(policy.drop_paths, [&](const std::string &drop_path) { return path_matches_drop_rule(existing.relative_path, drop_path); }); }); - std::erase_if(result.files, [&](const TemplateFileEntry& existing) { - return std::ranges::any_of(policy.drop_paths, [&](const std::string& drop_path) { + std::erase_if(result.files, [&](const TemplateFileEntry &existing) { + return std::ranges::any_of(policy.drop_paths, [&](const std::string &drop_path) { return path_matches_drop_rule(existing.relative_path, drop_path); }); }); } -void merge_directories(TemplateManifest& result, const TemplateManifest& overlay) { - for (const auto& directory : overlay.directories) { - const auto it = std::find_if(result.directories.begin(), result.directories.end(), [&](const TemplateDirectoryEntry& existing) { - return existing.relative_path == directory.relative_path; - }); +void merge_directories(TemplateManifest &result, const TemplateManifest &overlay) { + for (const auto &directory : overlay.directories) { + const auto it = std::find_if( + result.directories.begin(), result.directories.end(), + [&](const TemplateDirectoryEntry &existing) { return existing.relative_path == directory.relative_path; }); if (it == result.directories.end()) { result.directories.push_back(directory); } } } -void merge_files(TemplateManifest& result, - const TemplateManifest& overlay, - const TemplateMergeConfig& policy) { - for (const auto& file : overlay.files) { - const auto it = std::find_if(result.files.begin(), result.files.end(), [&](const TemplateFileEntry& existing) { +void merge_files(TemplateManifest &result, const TemplateManifest &overlay, const TemplateMergeConfig &policy) { + for (const auto &file : overlay.files) { + const auto it = std::find_if(result.files.begin(), result.files.end(), [&](const TemplateFileEntry &existing) { return existing.relative_path == file.relative_path; }); if (it == result.files.end()) { @@ -127,18 +120,15 @@ void merge_files(TemplateManifest& result, case ConflictStrategy::Keep: break; case ConflictStrategy::Error: - throw TempifyError("File conflict for '" + file.relative_path + "' between templates '" - + it->source_template_id + "' and '" + file.source_template_id + "'"); + throw TempifyError("File conflict for '" + file.relative_path + "' between templates '" + + it->source_template_id + "' and '" + file.source_template_id + "'"); } } } -void merge_hook(std::optional& destination, - const std::optional& overlay, - const std::optional& policy, - const bool overlay_is_local, - const std::string& error_prefix, - const std::string& overlay_id) { +void merge_hook(std::optional &destination, const std::optional &overlay, + const std::optional &policy, const bool overlay_is_local, + const std::string &error_prefix, const std::string &overlay_id) { if (!overlay.has_value()) { return; } @@ -161,12 +151,10 @@ void merge_hook(std::optional& destination, } } -} +} // namespace -TemplateManifest TemplateLoader::merge(const TemplateManifest& base, - const TemplateManifest& overlay, - const TemplateMergeConfig& policy, - const bool overlay_is_local) const { +TemplateManifest TemplateLoader::merge(const TemplateManifest &base, const TemplateManifest &overlay, + const TemplateMergeConfig &policy, const bool overlay_is_local) const { if (base.info.id.empty()) { return overlay; } @@ -176,29 +164,30 @@ TemplateManifest TemplateLoader::merge(const TemplateManifest& base, result.info = overlay.info; result.version = !overlay.version.empty() ? overlay.version : base.version; result.source_dir = !overlay.source_dir.empty() ? overlay.source_dir : base.source_dir; - result.output_path_template = !overlay.output_path_template.empty() ? overlay.output_path_template : base.output_path_template; + result.output_path_template = + !overlay.output_path_template.empty() ? overlay.output_path_template : base.output_path_template; result.overwrite = overlay.overwrite; result.source_roots.insert(result.source_roots.end(), overlay.source_roots.begin(), overlay.source_roots.end()); - for (const auto& include_id : overlay.include_ids) { + for (const auto &include_id : overlay.include_ids) { if (std::find(result.include_ids.begin(), result.include_ids.end(), include_id) == result.include_ids.end()) { result.include_ids.push_back(include_id); } } - for (const auto& [key, value] : overlay.env_defaults) { + for (const auto &[key, value] : overlay.env_defaults) { result.env_defaults[key] = value; } result.prebyte_config.include_paths.insert(result.prebyte_config.include_paths.end(), overlay.prebyte_config.include_paths.begin(), overlay.prebyte_config.include_paths.end()); - for (const auto& [key, value] : overlay.prebyte_config.rules) { + for (const auto &[key, value] : overlay.prebyte_config.rules) { result.prebyte_config.rules[key] = value; } - for (const auto& rule : overlay.layout_rules) { + for (const auto &rule : overlay.layout_rules) { result.layout_rules.push_back(rule); } @@ -208,18 +197,10 @@ TemplateManifest TemplateLoader::merge(const TemplateManifest& base, merge_directories(result, overlay); merge_files(result, overlay, policy); - merge_hook(result.pre_hook_path, - overlay.pre_hook_path, - policy.pre_hook_conflict, - overlay_is_local, - "Pre-hook conflict", - overlay.info.id); - merge_hook(result.post_hook_path, - overlay.post_hook_path, - policy.post_hook_conflict, - overlay_is_local, - "Post-hook conflict", - overlay.info.id); + merge_hook(result.pre_hook_path, overlay.pre_hook_path, policy.pre_hook_conflict, overlay_is_local, + "Pre-hook conflict", overlay.info.id); + merge_hook(result.post_hook_path, overlay.post_hook_path, policy.post_hook_conflict, overlay_is_local, + "Post-hook conflict", overlay.info.id); if (overlay.before_render_hook_path.has_value()) { result.before_render_hook_path = overlay.before_render_hook_path; @@ -231,4 +212,4 @@ TemplateManifest TemplateLoader::merge(const TemplateManifest& base, return result; } -} +} // namespace tempify diff --git a/src/main/cpp/template/TemplateTestRunner.cpp b/src/main/cpp/template/TemplateTestRunner.cpp index e0ece53..df68644 100644 --- a/src/main/cpp/template/TemplateTestRunner.cpp +++ b/src/main/cpp/template/TemplateTestRunner.cpp @@ -1,8 +1,8 @@ #include "tempify/template/TemplateTestRunner.h" #include "tempify/build/BuildExecutor.h" -#include "tempify/build/BuildPlanner.h" #include "tempify/build/BuildPlanReport.h" +#include "tempify/build/BuildPlanner.h" #include "tempify/frontend/IQuestionFrontend.h" #include "tempify/lua/LuaEngine.h" #include "tempify/prebyte/PrebyteRenderer.h" @@ -26,9 +26,8 @@ namespace tempify { std::string canonicalize_template_test_lockfile_json(std::string text) { - const auto replace_json_string_field = [](std::string input, - const std::string& key, - const std::string& replacement) { + const auto replace_json_string_field = [](std::string input, const std::string &key, + const std::string &replacement) { const std::string token = "\"" + key + "\": \""; std::size_t start = 0; while ((start = input.find(token, start)) != std::string::npos) { @@ -51,29 +50,41 @@ std::string canonicalize_template_test_lockfile_json(std::string text) { namespace { -std::string json_escape(const std::string& value) { +std::string json_escape(const std::string &value) { std::string escaped; escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; } -std::size_t snapshot_artifact_count(const TemplateFixtureResult& fixture) { +std::size_t snapshot_artifact_count(const TemplateFixtureResult &fixture) { return fixture.snapshot_file_count + (fixture.includes_lockfile_snapshot ? 1U : 0U); } -std::size_t passed_fixture_count(const TemplateTestReport& report) { +std::size_t passed_fixture_count(const TemplateTestReport &report) { std::size_t count = 0; - for (const auto& fixture : report.fixtures) { + for (const auto &fixture : report.fixtures) { if (!fixture.failure_message.has_value()) { ++count; } @@ -81,36 +92,35 @@ std::size_t passed_fixture_count(const TemplateTestReport& report) { return count; } -std::size_t failed_fixture_count(const TemplateTestReport& report) { +std::size_t failed_fixture_count(const TemplateTestReport &report) { return report.fixtures.size() - passed_fixture_count(report); } class ScopedDirectoryCleanup { -public: - explicit ScopedDirectoryCleanup(std::filesystem::path path) - : path_(std::move(path)) {} + public: + explicit ScopedDirectoryCleanup(std::filesystem::path path) : path_(std::move(path)) {} ~ScopedDirectoryCleanup() { std::filesystem::remove_all(path_); } -private: + private: std::filesystem::path path_; }; class SilentFrontend final : public IQuestionFrontend { -public: - std::optional prompt(const std::string& text, bool sensitive = false) override { + public: + std::optional prompt(const std::string &text, bool sensitive = false) override { static_cast(text); static_cast(sensitive); return std::nullopt; } - void write_line(const std::string& text) override { + void write_line(const std::string &text) override { static_cast(text); } - void begin_group(const std::string& name, std::size_t index, std::size_t total) override { + void begin_group(const std::string &name, std::size_t index, std::size_t total) override { static_cast(name); static_cast(index); static_cast(total); @@ -137,15 +147,14 @@ struct FixtureFailure { std::string message; }; -std::filesystem::path create_temp_directory(const std::string& prefix) { +std::filesystem::path create_temp_directory(const std::string &prefix) { std::error_code error; const std::filesystem::path temp_root = std::filesystem::temp_directory_path(error); if (error) { throw TempifyError("Could not resolve temp directory for fixture '" + prefix + "': " + error.message()); } - const std::uint64_t seed = static_cast( - std::chrono::steady_clock::now().time_since_epoch().count()); + const std::uint64_t seed = static_cast(std::chrono::steady_clock::now().time_since_epoch().count()); for (std::uint64_t attempt = 0; attempt < 128; ++attempt) { const std::filesystem::path candidate = temp_root / (prefix + "-" + std::to_string(seed + attempt)); error.clear(); @@ -160,15 +169,15 @@ std::filesystem::path create_temp_directory(const std::string& prefix) { throw TempifyError("Could not create temp directory for fixture '" + prefix + "'."); } -std::vector discover_fixtures(const TemplateManifest& manifest, - const std::optional& selected_fixture_name) { +std::vector discover_fixtures(const TemplateManifest &manifest, + const std::optional &selected_fixture_name) { const std::filesystem::path tests_root = manifest.root / "tests"; if (!std::filesystem::is_directory(tests_root)) { throw TempifyError("No test fixtures found under " + tests_root.string()); } std::vector fixture_roots; - for (const auto& entry : std::filesystem::directory_iterator(tests_root)) { + for (const auto &entry : std::filesystem::directory_iterator(tests_root)) { if (entry.is_directory()) { fixture_roots.push_back(entry.path()); } @@ -177,7 +186,7 @@ std::vector discover_fixtures(const TemplateManifest& manifes std::vector fixtures; fixtures.reserve(fixture_roots.size()); - for (const auto& root : fixture_roots) { + for (const auto &root : fixture_roots) { FixtureDefinition fixture{ .name = root.filename().string(), .answers_path = root / "answers.json", @@ -185,15 +194,17 @@ std::vector discover_fixtures(const TemplateManifest& manifes .lockfile_snapshot_path = root / "lock.json", }; if (!std::filesystem::is_directory(fixture.snapshot_root)) { - throw TempifyError("Fixture '" + fixture.name + "' missing snapshot directory: " + fixture.snapshot_root.string()); + throw TempifyError("Fixture '" + fixture.name + + "' missing snapshot directory: " + fixture.snapshot_root.string()); } if (std::filesystem::exists(fixture.answers_path) && !std::filesystem::is_regular_file(fixture.answers_path)) { - throw TempifyError("Fixture '" + fixture.name + "' answers path is not file: " + fixture.answers_path.string()); + throw TempifyError("Fixture '" + fixture.name + + "' answers path is not file: " + fixture.answers_path.string()); } - if (std::filesystem::exists(fixture.lockfile_snapshot_path) - && !std::filesystem::is_regular_file(fixture.lockfile_snapshot_path)) { - throw TempifyError("Fixture '" + fixture.name + "' lock snapshot path is not file: " - + fixture.lockfile_snapshot_path.string()); + if (std::filesystem::exists(fixture.lockfile_snapshot_path) && + !std::filesystem::is_regular_file(fixture.lockfile_snapshot_path)) { + throw TempifyError("Fixture '" + fixture.name + + "' lock snapshot path is not file: " + fixture.lockfile_snapshot_path.string()); } fixtures.push_back(std::move(fixture)); } @@ -204,7 +215,7 @@ std::vector discover_fixtures(const TemplateManifest& manifes if (selected_fixture_name.has_value()) { std::vector filtered; - for (const auto& fixture : fixtures) { + for (const auto &fixture : fixtures) { if (fixture.name == *selected_fixture_name) { filtered.push_back(fixture); } @@ -218,13 +229,13 @@ std::vector discover_fixtures(const TemplateManifest& manifes return fixtures; } -std::vector collect_relative_files(const std::filesystem::path& root) { +std::vector collect_relative_files(const std::filesystem::path &root) { std::vector files; if (!std::filesystem::is_directory(root)) { return files; } - for (const auto& entry : std::filesystem::recursive_directory_iterator(root)) { + for (const auto &entry : std::filesystem::recursive_directory_iterator(root)) { if (!entry.is_regular_file()) { continue; } @@ -234,7 +245,7 @@ std::vector collect_relative_files(const std::filesystem::path& roo return files; } -std::string read_file(const std::filesystem::path& path) { +std::string read_file(const std::filesystem::path &path) { std::ifstream input(path, std::ios::binary); if (!input) { throw TempifyError("Could not read file: " + path.string()); @@ -245,7 +256,7 @@ std::string read_file(const std::filesystem::path& path) { return stream.str(); } -void write_file(const std::filesystem::path& path, const std::string& content) { +void write_file(const std::filesystem::path &path, const std::string &content) { if (path.has_parent_path()) { std::filesystem::create_directories(path.parent_path()); } @@ -256,13 +267,13 @@ void write_file(const std::filesystem::path& path, const std::string& content) { output << content; } -void remove_file_if_exists(const std::filesystem::path& path) { +void remove_file_if_exists(const std::filesystem::path &path) { if (std::filesystem::exists(path)) { std::filesystem::remove(path); } } -std::vector split_lines_preserve(const std::string& text) { +std::vector split_lines_preserve(const std::string &text) { std::vector lines; std::size_t start = 0; while (start < text.size()) { @@ -290,7 +301,7 @@ std::vector split_lines_preserve(const std::string& text) { return lines; } -std::string preview_line(const std::string& line) { +std::string preview_line(const std::string &line) { std::string preview; preview.reserve(line.size()); for (const char ch : line) { @@ -318,9 +329,8 @@ std::string preview_line(const std::string& line) { return preview; } -std::string describe_content_mismatch(const std::string& relative_path, - const std::string& expected, - const std::string& actual) { +std::string describe_content_mismatch(const std::string &relative_path, const std::string &expected, + const std::string &actual) { const auto expected_lines = split_lines_preserve(expected); const auto actual_lines = split_lines_preserve(actual); const std::size_t line_count = std::max(expected_lines.size(), actual_lines.size()); @@ -329,10 +339,8 @@ std::string describe_content_mismatch(const std::string& relative_path, const std::string actual_line = index < actual_lines.size() ? actual_lines[index] : ""; if (expected_line != actual_line) { std::ostringstream stream; - stream << "content mismatch in " << relative_path - << " at line " << (index + 1) - << ": expected `" << preview_line(expected_line) - << "`, got `" << preview_line(actual_line) << '`'; + stream << "content mismatch in " << relative_path << " at line " << (index + 1) << ": expected `" + << preview_line(expected_line) << "`, got `" << preview_line(actual_line) << '`'; return stream.str(); } } @@ -340,7 +348,7 @@ std::string describe_content_mismatch(const std::string& relative_path, return "content mismatch in " + relative_path; } -FixtureFailure classify_failure(const std::string& message) { +FixtureFailure classify_failure(const std::string &message) { if (message.find("snapshot mismatch") != std::string::npos) { return {.code = "SNAPSHOT_MISMATCH", .kind = "snapshot_mismatch", .message = message}; } @@ -359,7 +367,7 @@ FixtureFailure classify_failure(const std::string& message) { return {.code = "TEST_ERROR", .kind = "test_error", .message = message}; } -std::string join_paths(const std::vector& paths) { +std::string join_paths(const std::vector &paths) { std::ostringstream stream; constexpr std::size_t max_paths = 5; for (std::size_t index = 0; index < paths.size() && index < max_paths; ++index) { @@ -374,20 +382,13 @@ std::string join_paths(const std::vector& paths) { return stream.str(); } -void compare_expected_file_set(const std::vector& expected_files, - const std::vector& actual_files, - const FixtureDefinition& fixture) { +void compare_expected_file_set(const std::vector &expected_files, + const std::vector &actual_files, const FixtureDefinition &fixture) { std::vector missing_files; std::vector unexpected_files; - std::set_difference(expected_files.begin(), - expected_files.end(), - actual_files.begin(), - actual_files.end(), + std::set_difference(expected_files.begin(), expected_files.end(), actual_files.begin(), actual_files.end(), std::back_inserter(missing_files)); - std::set_difference(actual_files.begin(), - actual_files.end(), - expected_files.begin(), - expected_files.end(), + std::set_difference(actual_files.begin(), actual_files.end(), expected_files.begin(), expected_files.end(), std::back_inserter(unexpected_files)); if (!missing_files.empty() || !unexpected_files.empty()) { @@ -403,62 +404,50 @@ void compare_expected_file_set(const std::vector& expected_files, } } -bool compare_lockfile_snapshot(const FixtureDefinition& fixture, - const TemplateManifest& manifest, - const BuildPlan& plan, - const std::map& values) { +bool compare_lockfile_snapshot(const FixtureDefinition &fixture, const TemplateManifest &manifest, + const BuildPlan &plan, const std::map &values) { if (!std::filesystem::is_regular_file(fixture.lockfile_snapshot_path)) { return false; } const std::string expected = canonicalize_template_test_lockfile_json(read_file(fixture.lockfile_snapshot_path)); - const std::string actual = canonicalize_template_test_lockfile_json(format_generation_lock_json(manifest, - plan, - values, - HookAcceptance::Yes, - false)); + const std::string actual = canonicalize_template_test_lockfile_json( + format_generation_lock_json(manifest, plan, values, HookAcceptance::Yes, false)); if (expected != actual) { - throw TempifyError("Fixture '" + fixture.name + "' lockfile mismatch: " - + describe_content_mismatch("lock.json", expected, actual)); + throw TempifyError("Fixture '" + fixture.name + + "' lockfile mismatch: " + describe_content_mismatch("lock.json", expected, actual)); } return true; } -void write_fixture_snapshot(const FixtureDefinition& fixture, - const TemplateManifest& manifest, - const BuildPlan& plan, - const std::map& values) { +void write_fixture_snapshot(const FixtureDefinition &fixture, const TemplateManifest &manifest, const BuildPlan &plan, + const std::map &values) { std::filesystem::remove_all(fixture.snapshot_root); std::filesystem::create_directories(fixture.snapshot_root); - for (const auto& relative_path : collect_relative_files(plan.build_root)) { + for (const auto &relative_path : collect_relative_files(plan.build_root)) { write_file(fixture.snapshot_root / relative_path, read_file(plan.build_root / relative_path)); } if (std::filesystem::is_regular_file(fixture.lockfile_snapshot_path)) { - write_file(fixture.lockfile_snapshot_path, - canonicalize_template_test_lockfile_json(format_generation_lock_json(manifest, - plan, - values, - HookAcceptance::Yes, - false))); + write_file(fixture.lockfile_snapshot_path, canonicalize_template_test_lockfile_json(format_generation_lock_json( + manifest, plan, values, HookAcceptance::Yes, false))); } } -SnapshotComparisonResult compare_snapshot_tree(const FixtureDefinition& fixture, - const TemplateManifest& manifest, - const BuildPlan& plan, - const std::map& values) { +SnapshotComparisonResult compare_snapshot_tree(const FixtureDefinition &fixture, const TemplateManifest &manifest, + const BuildPlan &plan, + const std::map &values) { const auto expected_files = collect_relative_files(fixture.snapshot_root); const auto actual_files = collect_relative_files(plan.build_root); compare_expected_file_set(expected_files, actual_files, fixture); - for (const auto& relative_path : expected_files) { + for (const auto &relative_path : expected_files) { const std::string expected = read_file(fixture.snapshot_root / relative_path); const std::string actual = read_file(plan.build_root / relative_path); if (expected != actual) { - throw TempifyError("Fixture '" + fixture.name + "' snapshot mismatch: " - + describe_content_mismatch(relative_path, expected, actual)); + throw TempifyError("Fixture '" + fixture.name + + "' snapshot mismatch: " + describe_content_mismatch(relative_path, expected, actual)); } } @@ -468,55 +457,55 @@ SnapshotComparisonResult compare_snapshot_tree(const FixtureDefinition& fixture, }; } -std::map load_fixture_answers(const FixtureDefinition& fixture) { +std::map load_fixture_answers(const FixtureDefinition &fixture) { if (!std::filesystem::exists(fixture.answers_path)) { return {}; } return load_answer_file(fixture.answers_path, true); } -} +} // namespace -TemplateTestRunner::TemplateTestRunner(const LuaEngine& lua_engine, const PrebyteRenderer& renderer) - : lua_engine_(lua_engine), - renderer_(renderer) {} +TemplateTestRunner::TemplateTestRunner(const LuaEngine &lua_engine, const PrebyteRenderer &renderer) + : lua_engine_(lua_engine), renderer_(renderer) {} -std::vector TemplateTestRunner::list_fixtures(const TemplateManifest& manifest, - const std::optional& fixture_name) const { +std::vector +TemplateTestRunner::list_fixtures(const TemplateManifest &manifest, + const std::optional &fixture_name) const { std::vector fixtures; - for (const auto& fixture : discover_fixtures(manifest, fixture_name)) { + for (const auto &fixture : discover_fixtures(manifest, fixture_name)) { fixtures.push_back({ .name = fixture.name, .has_answers_file = std::filesystem::is_regular_file(fixture.answers_path), .has_lockfile_snapshot = std::filesystem::is_regular_file(fixture.lockfile_snapshot_path), .snapshot_root = fixture.snapshot_root.string(), .answers_file = std::filesystem::is_regular_file(fixture.answers_path) - ? std::optional(fixture.answers_path.string()) - : std::nullopt, + ? std::optional(fixture.answers_path.string()) + : std::nullopt, .lockfile_snapshot = std::filesystem::is_regular_file(fixture.lockfile_snapshot_path) - ? std::optional(fixture.lockfile_snapshot_path.string()) - : std::nullopt, + ? std::optional(fixture.lockfile_snapshot_path.string()) + : std::nullopt, }); } return fixtures; } -std::vector TemplateTestRunner::list_fixture_names(const TemplateManifest& manifest, - const std::optional& fixture_name) const { +std::vector TemplateTestRunner::list_fixture_names(const TemplateManifest &manifest, + const std::optional &fixture_name) const { std::vector names; - for (const auto& fixture : discover_fixtures(manifest, fixture_name)) { + for (const auto &fixture : discover_fixtures(manifest, fixture_name)) { names.push_back(fixture.name); } return names; } -TemplateTestReport TemplateTestRunner::run(const TemplateManifest& manifest, - const std::optional& fixture_name) const { +TemplateTestReport TemplateTestRunner::run(const TemplateManifest &manifest, + const std::optional &fixture_name) const { TemplateTestReport report{.template_id = manifest.info.id}; const auto all_fixtures = discover_fixtures(manifest, std::nullopt); std::vector fixtures; if (fixture_name.has_value()) { - for (const auto& fixture : all_fixtures) { + for (const auto &fixture : all_fixtures) { if (fixture.name == *fixture_name) { fixtures.push_back(fixture); } @@ -540,17 +529,11 @@ TemplateTestReport TemplateTestRunner::run(const TemplateManifest& manifest, const BuildExecutor executor(renderer_, lua_engine_); const auto start_time = std::chrono::steady_clock::now(); - for (const auto& fixture : fixtures) { + for (const auto &fixture : fixtures) { TemplateFixtureResult result{.name = fixture.name}; const auto fixture_start = std::chrono::steady_clock::now(); try { - const auto values = question_processor.collect( - manifest, - {}, - {}, - load_fixture_answers(fixture), - true, - true); + const auto values = question_processor.collect(manifest, {}, {}, load_fixture_answers(fixture), true, true); const std::filesystem::path scratch_root = create_temp_directory("tempify-test-" + fixture.name); const ScopedDirectoryCleanup cleanup(scratch_root); @@ -560,15 +543,17 @@ TemplateTestReport TemplateTestRunner::run(const TemplateManifest& manifest, result.snapshot_file_count = comparison.file_count; result.includes_lockfile_snapshot = comparison.includes_lockfile_snapshot; - report.total_snapshot_artifact_count += comparison.file_count + (comparison.includes_lockfile_snapshot ? 1U : 0U); - } catch (const TempifyError& error) { + report.total_snapshot_artifact_count += + comparison.file_count + (comparison.includes_lockfile_snapshot ? 1U : 0U); + } catch (const TempifyError &error) { const FixtureFailure failure = classify_failure(error.what()); result.failure_code = failure.code; result.failure_kind = failure.kind; result.failure_message = failure.message; } result.elapsed_ms = static_cast( - std::chrono::duration_cast(std::chrono::steady_clock::now() - fixture_start).count()); + std::chrono::duration_cast(std::chrono::steady_clock::now() - fixture_start) + .count()); report.fixtures.push_back(std::move(result)); } @@ -578,8 +563,8 @@ TemplateTestReport TemplateTestRunner::run(const TemplateManifest& manifest, return report; } -TemplateTestReport TemplateTestRunner::update_snapshots(const TemplateManifest& manifest, - const std::optional& fixture_name) const { +TemplateTestReport TemplateTestRunner::update_snapshots(const TemplateManifest &manifest, + const std::optional &fixture_name) const { TemplateTestReport report{.template_id = manifest.info.id}; const auto fixtures = discover_fixtures(manifest, fixture_name); report.fixtures.reserve(fixtures.size()); @@ -590,17 +575,11 @@ TemplateTestReport TemplateTestRunner::update_snapshots(const TemplateManifest& const BuildExecutor executor(renderer_, lua_engine_); const auto start_time = std::chrono::steady_clock::now(); - for (const auto& fixture : fixtures) { + for (const auto &fixture : fixtures) { TemplateFixtureResult result{.name = fixture.name}; const auto fixture_start = std::chrono::steady_clock::now(); try { - const auto values = question_processor.collect( - manifest, - {}, - {}, - load_fixture_answers(fixture), - true, - true); + const auto values = question_processor.collect(manifest, {}, {}, load_fixture_answers(fixture), true, true); const std::filesystem::path scratch_root = create_temp_directory("tempify-test-update-" + fixture.name); const ScopedDirectoryCleanup cleanup(scratch_root); @@ -610,15 +589,17 @@ TemplateTestReport TemplateTestRunner::update_snapshots(const TemplateManifest& result.snapshot_file_count = collect_relative_files(fixture.snapshot_root).size(); result.includes_lockfile_snapshot = std::filesystem::is_regular_file(fixture.lockfile_snapshot_path); - report.total_snapshot_artifact_count += result.snapshot_file_count + (result.includes_lockfile_snapshot ? 1U : 0U); - } catch (const TempifyError& error) { + report.total_snapshot_artifact_count += + result.snapshot_file_count + (result.includes_lockfile_snapshot ? 1U : 0U); + } catch (const TempifyError &error) { const FixtureFailure failure = classify_failure(error.what()); result.failure_code = failure.code; result.failure_kind = failure.kind; result.failure_message = failure.message; } result.elapsed_ms = static_cast( - std::chrono::duration_cast(std::chrono::steady_clock::now() - fixture_start).count()); + std::chrono::duration_cast(std::chrono::steady_clock::now() - fixture_start) + .count()); report.fixtures.push_back(std::move(result)); } @@ -627,12 +608,12 @@ TemplateTestReport TemplateTestRunner::update_snapshots(const TemplateManifest& return report; } -std::string format_template_test_report(const TemplateTestReport& report) { +std::string format_template_test_report(const TemplateTestReport &report) { std::ostringstream stream; stream << "Test " << report.template_id << '\n'; const std::size_t passed = passed_fixture_count(report); const std::size_t failed = failed_fixture_count(report); - for (const auto& fixture : report.fixtures) { + for (const auto &fixture : report.fixtures) { if (fixture.failure_message.has_value()) { stream << "FAIL " << fixture.name << ": " << *fixture.failure_message << '\n'; continue; @@ -650,14 +631,14 @@ std::string format_template_test_report(const TemplateTestReport& report) { stream << passed << '/' << report.fixtures.size() << " fixtures passed, " << failed << " failed"; } if (!report.fixtures.empty()) { - stream << " (" << report.total_snapshot_artifact_count << " snapshot artifacts, " - << report.elapsed_ms << " ms)"; + stream << " (" << report.total_snapshot_artifact_count << " snapshot artifacts, " << report.elapsed_ms + << " ms)"; } stream << '\n'; return stream.str(); } -std::string format_template_test_report_json(const TemplateTestReport& report) { +std::string format_template_test_report_json(const TemplateTestReport &report) { std::ostringstream stream; const std::size_t passed = passed_fixture_count(report); const std::size_t failed = failed_fixture_count(report); @@ -671,7 +652,7 @@ std::string format_template_test_report_json(const TemplateTestReport& report) { stream << " \"elapsed_ms\": " << report.elapsed_ms << ",\n"; stream << " \"fixtures\": [\n"; for (std::size_t index = 0; index < report.fixtures.size(); ++index) { - const auto& fixture = report.fixtures[index]; + const auto &fixture = report.fixtures[index]; stream << " {\n"; stream << " \"name\": \"" << json_escape(fixture.name) << "\",\n"; stream << " \"status\": \"" << (fixture.failure_message.has_value() ? "failed" : "passed") << "\",\n"; @@ -698,15 +679,15 @@ std::string format_template_test_report_json(const TemplateTestReport& report) { return stream.str(); } -std::string format_template_fixture_listing_json(const std::string& template_id, - const std::vector& fixtures) { +std::string format_template_fixture_listing_json(const std::string &template_id, + const std::vector &fixtures) { std::ostringstream stream; stream << "{\n"; stream << " \"template_id\": \"" << json_escape(template_id) << "\",\n"; stream << " \"total\": " << fixtures.size() << ",\n"; stream << " \"fixtures\": [\n"; for (std::size_t index = 0; index < fixtures.size(); ++index) { - const auto& fixture = fixtures[index]; + const auto &fixture = fixtures[index]; stream << " {\n"; stream << " \"name\": \"" << json_escape(fixture.name) << "\",\n"; stream << " \"has_answers_file\": " << (fixture.has_answers_file ? "true" : "false") << ",\n"; @@ -741,4 +722,4 @@ std::string format_template_fixture_listing_json(const std::string& template_id, return stream.str(); } -} +} // namespace tempify diff --git a/src/main/cpp/template/TemplateValidator.cpp b/src/main/cpp/template/TemplateValidator.cpp index 2835e8d..1e382b4 100644 --- a/src/main/cpp/template/TemplateValidator.cpp +++ b/src/main/cpp/template/TemplateValidator.cpp @@ -8,27 +8,27 @@ namespace tempify { namespace { -std::string describe_question_source(const QuestionDefinition& question) { - return question.source_path.empty() - ? question.key - : question.source_path.string() + "#" + std::to_string(question.source_index); +std::string describe_question_source(const QuestionDefinition &question) { + return question.source_path.empty() ? question.key + : question.source_path.string() + "#" + std::to_string(question.source_index); } -} +} // namespace -void TemplateValidator::validate(const TemplateManifest& manifest) const { +void TemplateValidator::validate(const TemplateManifest &manifest) const { std::set question_keys; std::set aliases; - for (const auto& question : manifest.questions) { + for (const auto &question : manifest.questions) { if (aliases.contains(question.key)) { throw TempifyError("Question key '" + question.key + "' conflicts with existing alias"); } if (!question_keys.insert(question.key).second) { - throw TempifyError("Duplicate question key '" + question.key + "' in " + describe_question_source(question)); + throw TempifyError("Duplicate question key '" + question.key + "' in " + + describe_question_source(question)); } - for (const auto& alias : question.aliases) { + for (const auto &alias : question.aliases) { if (alias == question.key) { throw TempifyError("Question alias must differ from key for '" + question.key + "'"); } @@ -42,14 +42,14 @@ void TemplateValidator::validate(const TemplateManifest& manifest) const { } std::set known_layout_sources; - for (const auto& directory : manifest.directories) { + for (const auto &directory : manifest.directories) { known_layout_sources.insert(directory.relative_path); } - for (const auto& file : manifest.files) { + for (const auto &file : manifest.files) { known_layout_sources.insert(file.relative_path); } - for (const auto& rule : manifest.layout_rules) { + for (const auto &rule : manifest.layout_rules) { if (!known_layout_sources.contains(rule.source)) { throw TempifyError("Layout rule references missing source path: " + rule.source); } @@ -59,7 +59,7 @@ void TemplateValidator::validate(const TemplateManifest& manifest) const { } std::set script_names; - for (const auto& script : manifest.scripts) { + for (const auto &script : manifest.scripts) { if (!script_names.insert(script.name).second) { throw TempifyError("Duplicate script name found: " + script.name); } @@ -68,7 +68,7 @@ void TemplateValidator::validate(const TemplateManifest& manifest) const { } } - const auto validate_hook = [](const std::optional& path, const std::string& name) { + const auto validate_hook = [](const std::optional &path, const std::string &name) { if (path.has_value() && !std::filesystem::is_regular_file(*path)) { throw TempifyError("Missing hook file for " + name + ": " + path->string()); } @@ -79,4 +79,4 @@ void TemplateValidator::validate(const TemplateManifest& manifest) const { validate_hook(manifest.post_hook_path, "post"); } -} +} // namespace tempify diff --git a/src/main/include/tempify/app/TempifyApp.h b/src/main/include/tempify/app/TempifyApp.h index 307729a..bcd55d8 100644 --- a/src/main/include/tempify/app/TempifyApp.h +++ b/src/main/include/tempify/app/TempifyApp.h @@ -6,8 +6,8 @@ namespace tempify { class TempifyApp { -public: - int run(const std::vector& args) const; + public: + int run(const std::vector &args) const; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/build/BuildDiffReport.h b/src/main/include/tempify/build/BuildDiffReport.h index b0b59f9..b8789aa 100644 --- a/src/main/include/tempify/build/BuildDiffReport.h +++ b/src/main/include/tempify/build/BuildDiffReport.h @@ -111,12 +111,10 @@ struct BuildDiffReport { std::vector entries; }; -BuildDiffReport build_diff_report(const BuildPlan& plan, - const TemplateManifest& manifest, - const std::map& values, - const PrebyteRenderer& renderer); +BuildDiffReport build_diff_report(const BuildPlan &plan, const TemplateManifest &manifest, + const std::map &values, const PrebyteRenderer &renderer); -std::string format_build_diff_text(const BuildDiffReport& report); -std::string format_build_diff_json(const BuildDiffReport& report); +std::string format_build_diff_text(const BuildDiffReport &report); +std::string format_build_diff_json(const BuildDiffReport &report); -} +} // namespace tempify diff --git a/src/main/include/tempify/build/BuildExecutor.h b/src/main/include/tempify/build/BuildExecutor.h index 4ced9a4..2121212 100644 --- a/src/main/include/tempify/build/BuildExecutor.h +++ b/src/main/include/tempify/build/BuildExecutor.h @@ -14,18 +14,16 @@ class LuaEngine; class PrebyteRenderer; class BuildExecutor { -public: - BuildExecutor(const PrebyteRenderer& renderer, const LuaEngine& lua_engine); + public: + BuildExecutor(const PrebyteRenderer &renderer, const LuaEngine &lua_engine); - void execute(const BuildPlan& plan, - const TemplateManifest& manifest, - const std::map& values, - bool disable_hooks, + void execute(const BuildPlan &plan, const TemplateManifest &manifest, + const std::map &values, bool disable_hooks, std::optional hook_timeout = std::nullopt) const; -private: - const PrebyteRenderer& renderer_; - const LuaEngine& lua_engine_; + private: + const PrebyteRenderer &renderer_; + const LuaEngine &lua_engine_; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/build/BuildPlanReport.h b/src/main/include/tempify/build/BuildPlanReport.h index 52f8796..b635f55 100644 --- a/src/main/include/tempify/build/BuildPlanReport.h +++ b/src/main/include/tempify/build/BuildPlanReport.h @@ -8,22 +8,18 @@ namespace tempify { -BuildPlanReport build_plan_report(const BuildPlan& plan, const TemplateManifest& manifest); +BuildPlanReport build_plan_report(const BuildPlan &plan, const TemplateManifest &manifest); -std::string format_build_plan_text(const BuildPlanReport& report); -std::string format_build_plan_json(const BuildPlanReport& report); +std::string format_build_plan_text(const BuildPlanReport &report); +std::string format_build_plan_json(const BuildPlanReport &report); -std::string format_generation_lock_json(const TemplateManifest& manifest, - const BuildPlan& plan, - const std::map& values, - HookAcceptance hook_acceptance, - bool hooks_disabled); +std::string format_generation_lock_json(const TemplateManifest &manifest, const BuildPlan &plan, + const std::map &values, + HookAcceptance hook_acceptance, bool hooks_disabled); -std::string format_generation_lock_json(const TemplateManifest& manifest, - const BuildPlan& plan, - const std::map& values, - HookAcceptance hook_acceptance, - bool hooks_disabled, - const std::map& managed_file_hashes); +std::string format_generation_lock_json(const TemplateManifest &manifest, const BuildPlan &plan, + const std::map &values, + HookAcceptance hook_acceptance, bool hooks_disabled, + const std::map &managed_file_hashes); -} +} // namespace tempify diff --git a/src/main/include/tempify/build/BuildPlanner.h b/src/main/include/tempify/build/BuildPlanner.h index cc3f97a..deda625 100644 --- a/src/main/include/tempify/build/BuildPlanner.h +++ b/src/main/include/tempify/build/BuildPlanner.h @@ -13,15 +13,14 @@ namespace tempify { class PrebyteRenderer; class BuildPlanner { -public: - explicit BuildPlanner(const PrebyteRenderer& renderer); + public: + explicit BuildPlanner(const PrebyteRenderer &renderer); - BuildPlan plan(const TemplateManifest& manifest, - const std::map& values, - const std::optional& explicit_target = std::nullopt) const; + BuildPlan plan(const TemplateManifest &manifest, const std::map &values, + const std::optional &explicit_target = std::nullopt) const; -private: - const PrebyteRenderer& renderer_; + private: + const PrebyteRenderer &renderer_; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/build/GenerationLock.h b/src/main/include/tempify/build/GenerationLock.h index 0bf5c56..237cdb2 100644 --- a/src/main/include/tempify/build/GenerationLock.h +++ b/src/main/include/tempify/build/GenerationLock.h @@ -32,7 +32,7 @@ struct GenerationLockRecord { }; std::string content_fingerprint_hex(std::string_view value); -std::map build_generation_lock_managed_file_hashes(const BuildPlan& plan); -std::optional load_generation_lock(const std::filesystem::path& path); +std::map build_generation_lock_managed_file_hashes(const BuildPlan &plan); +std::optional load_generation_lock(const std::filesystem::path &path); -} +} // namespace tempify diff --git a/src/main/include/tempify/build/ReapplySerialization.h b/src/main/include/tempify/build/ReapplySerialization.h index 9f24ca5..870691d 100644 --- a/src/main/include/tempify/build/ReapplySerialization.h +++ b/src/main/include/tempify/build/ReapplySerialization.h @@ -8,13 +8,12 @@ namespace tempify { -std::string format_reapply_result_text(const std::string& template_id, - const std::filesystem::path& build_root, - const BuildDiffReport& report); +std::string format_reapply_result_text(const std::string &template_id, const std::filesystem::path &build_root, + const BuildDiffReport &report); -std::string format_reapply_result_json(const BuildDiffReport& report); +std::string format_reapply_result_json(const BuildDiffReport &report); -ReapplyBlockedError build_reapply_blocked_error(const BuildDiffReport& report); -std::string format_reapply_blocked_error_json(const ReapplyBlockedError& error); +ReapplyBlockedError build_reapply_blocked_error(const BuildDiffReport &report); +std::string format_reapply_blocked_error_json(const ReapplyBlockedError &error); -} +} // namespace tempify diff --git a/src/main/include/tempify/cli/CliParser.h b/src/main/include/tempify/cli/CliParser.h index 94017d7..21aa66e 100644 --- a/src/main/include/tempify/cli/CliParser.h +++ b/src/main/include/tempify/cli/CliParser.h @@ -8,9 +8,9 @@ namespace tempify { class CliParser { -public: - CliRequest parse(const std::vector& args) const; - std::string help_text(const CliRequest& request) const; + public: + CliRequest parse(const std::vector &args) const; + std::string help_text(const CliRequest &request) const; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/cli/ShellCompletion.h b/src/main/include/tempify/cli/ShellCompletion.h index cd00337..2fc433d 100644 --- a/src/main/include/tempify/cli/ShellCompletion.h +++ b/src/main/include/tempify/cli/ShellCompletion.h @@ -4,6 +4,6 @@ namespace tempify { -std::string render_shell_completion(const std::string& shell); +std::string render_shell_completion(const std::string &shell); } diff --git a/src/main/include/tempify/config/TempifyConfig.h b/src/main/include/tempify/config/TempifyConfig.h index 456014e..af8a895 100644 --- a/src/main/include/tempify/config/TempifyConfig.h +++ b/src/main/include/tempify/config/TempifyConfig.h @@ -16,7 +16,7 @@ struct TempifyConfig { std::optional existing_path_behavior; }; -TempifyConfig load_tempify_config_file(const std::filesystem::path& path); -TempifyConfig merge_tempify_config(const TempifyConfig& base, const TempifyConfig& overlay); +TempifyConfig load_tempify_config_file(const std::filesystem::path &path); +TempifyConfig merge_tempify_config(const TempifyConfig &base, const TempifyConfig &overlay); -} +} // namespace tempify diff --git a/src/main/include/tempify/domain/BuildPlan.h b/src/main/include/tempify/domain/BuildPlan.h index 3d610c8..69edd96 100644 --- a/src/main/include/tempify/domain/BuildPlan.h +++ b/src/main/include/tempify/domain/BuildPlan.h @@ -41,4 +41,4 @@ struct BuildPlanReport { std::vector hooks; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/domain/CliRequest.h b/src/main/include/tempify/domain/CliRequest.h index 1201917..2319fc4 100644 --- a/src/main/include/tempify/domain/CliRequest.h +++ b/src/main/include/tempify/domain/CliRequest.h @@ -85,4 +85,4 @@ struct CliRequest { std::optional help_topic; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/domain/Question.h b/src/main/include/tempify/domain/Question.h index d5bcc33..ea79408 100644 --- a/src/main/include/tempify/domain/Question.h +++ b/src/main/include/tempify/domain/Question.h @@ -26,4 +26,4 @@ struct QuestionDefinition { bool sensitive = false; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/domain/RenderOptions.h b/src/main/include/tempify/domain/RenderOptions.h index 179a9f0..54b83d6 100644 --- a/src/main/include/tempify/domain/RenderOptions.h +++ b/src/main/include/tempify/domain/RenderOptions.h @@ -16,4 +16,4 @@ enum class HookAcceptance { No, }; -} +} // namespace tempify diff --git a/src/main/include/tempify/domain/TemplateManifest.h b/src/main/include/tempify/domain/TemplateManifest.h index 6278321..ba12971 100644 --- a/src/main/include/tempify/domain/TemplateManifest.h +++ b/src/main/include/tempify/domain/TemplateManifest.h @@ -92,4 +92,4 @@ struct TemplateManifest { std::optional post_hook_path; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/frontend/IQuestionFrontend.h b/src/main/include/tempify/frontend/IQuestionFrontend.h index e0da5f9..05e348a 100644 --- a/src/main/include/tempify/frontend/IQuestionFrontend.h +++ b/src/main/include/tempify/frontend/IQuestionFrontend.h @@ -18,13 +18,13 @@ struct PromptResult { }; class IQuestionFrontend { -public: + public: virtual ~IQuestionFrontend() = default; - virtual std::optional prompt(const std::string& text, bool sensitive = false) = 0; - virtual void write_line(const std::string& text) = 0; - virtual void begin_group(const std::string& name, std::size_t index, std::size_t total) = 0; + virtual std::optional prompt(const std::string &text, bool sensitive = false) = 0; + virtual void write_line(const std::string &text) = 0; + virtual void begin_group(const std::string &name, std::size_t index, std::size_t total) = 0; virtual void end_group() = 0; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/frontend/PlainCliFrontend.h b/src/main/include/tempify/frontend/PlainCliFrontend.h index c837249..b6ff22c 100644 --- a/src/main/include/tempify/frontend/PlainCliFrontend.h +++ b/src/main/include/tempify/frontend/PlainCliFrontend.h @@ -5,11 +5,11 @@ namespace tempify { class PlainCliFrontend final : public IQuestionFrontend { -public: - std::optional prompt(const std::string& text, bool sensitive = false) override; - void write_line(const std::string& text) override; - void begin_group(const std::string& name, std::size_t index, std::size_t total) override; + public: + std::optional prompt(const std::string &text, bool sensitive = false) override; + void write_line(const std::string &text) override; + void begin_group(const std::string &name, std::size_t index, std::size_t total) override; void end_group() override; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/frontend/WizardFrontend.h b/src/main/include/tempify/frontend/WizardFrontend.h index dffe3c5..d77e535 100644 --- a/src/main/include/tempify/frontend/WizardFrontend.h +++ b/src/main/include/tempify/frontend/WizardFrontend.h @@ -5,11 +5,11 @@ namespace tempify { class WizardFrontend final : public IQuestionFrontend { -public: - std::optional prompt(const std::string& text, bool sensitive = false) override; - void write_line(const std::string& text) override; - void begin_group(const std::string& name, std::size_t index, std::size_t total) override; + public: + std::optional prompt(const std::string &text, bool sensitive = false) override; + void write_line(const std::string &text) override; + void begin_group(const std::string &name, std::size_t index, std::size_t total) override; void end_group() override; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/hook/HookTrustStore.h b/src/main/include/tempify/hook/HookTrustStore.h index 0357202..476dabe 100644 --- a/src/main/include/tempify/hook/HookTrustStore.h +++ b/src/main/include/tempify/hook/HookTrustStore.h @@ -7,17 +7,17 @@ namespace tempify { class HookTrustStore { -public: + public: explicit HookTrustStore(std::filesystem::path path); - const std::filesystem::path& path() const noexcept; - bool is_trusted(const std::filesystem::path& template_root) const; - void trust(const std::filesystem::path& template_root) const; + const std::filesystem::path &path() const noexcept; + bool is_trusted(const std::filesystem::path &template_root) const; + void trust(const std::filesystem::path &template_root) const; -private: + private: std::filesystem::path path_; }; -std::filesystem::path default_hook_trust_store_path(const std::filesystem::path& data_root); +std::filesystem::path default_hook_trust_store_path(const std::filesystem::path &data_root); -} +} // namespace tempify diff --git a/src/main/include/tempify/lua/LuaEngine.h b/src/main/include/tempify/lua/LuaEngine.h index 8de1284..ed0fab9 100644 --- a/src/main/include/tempify/lua/LuaEngine.h +++ b/src/main/include/tempify/lua/LuaEngine.h @@ -14,23 +14,18 @@ namespace tempify { class PrebyteRenderer; class LuaEngine { -public: - TemplateInfo load_template_info(const std::filesystem::path& template_root) const; - TemplateManifest load_partial_manifest(const std::filesystem::path& template_root) const; - std::optional evaluate_default(const QuestionDefinition& question, - const std::map& values) const; - bool evaluate_condition(const QuestionDefinition& question, - const std::map& values) const; - std::optional validate_answer(const QuestionDefinition& question, - const std::string& candidate, - const std::map& values) const; - std::string export_questions_json(const TemplateManifest& manifest, - bool full = false) const; - void run_hook(const std::filesystem::path& script_path, - const TemplateManifest& manifest, - const BuildContext& context, - const PrebyteRenderer& renderer, + public: + TemplateInfo load_template_info(const std::filesystem::path &template_root) const; + TemplateManifest load_partial_manifest(const std::filesystem::path &template_root) const; + std::optional evaluate_default(const QuestionDefinition &question, + const std::map &values) const; + bool evaluate_condition(const QuestionDefinition &question, const std::map &values) const; + std::optional validate_answer(const QuestionDefinition &question, const std::string &candidate, + const std::map &values) const; + std::string export_questions_json(const TemplateManifest &manifest, bool full = false) const; + void run_hook(const std::filesystem::path &script_path, const TemplateManifest &manifest, + const BuildContext &context, const PrebyteRenderer &renderer, std::optional timeout = std::nullopt) const; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/prebyte/PrebyteCommandRunner.h b/src/main/include/tempify/prebyte/PrebyteCommandRunner.h index 6a3c371..7f208fb 100644 --- a/src/main/include/tempify/prebyte/PrebyteCommandRunner.h +++ b/src/main/include/tempify/prebyte/PrebyteCommandRunner.h @@ -6,8 +6,8 @@ namespace tempify { class PrebyteCommandRunner { -public: - int run(const std::vector& args) const; + public: + int run(const std::vector &args) const; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/prebyte/PrebyteRenderer.h b/src/main/include/tempify/prebyte/PrebyteRenderer.h index 68842a9..c49372e 100644 --- a/src/main/include/tempify/prebyte/PrebyteRenderer.h +++ b/src/main/include/tempify/prebyte/PrebyteRenderer.h @@ -1,8 +1,7 @@ #pragma once -#include "tempify/domain/TemplateManifest.h" - #include "PrebyteEngine.h" +#include "tempify/domain/TemplateManifest.h" #include #include @@ -12,15 +11,13 @@ namespace tempify { class PrebyteRenderer { -public: - void configure(prebyte::Prebyte& engine, - const std::map& values, - const TemplateManifest& manifest) const; + public: + void configure(prebyte::Prebyte &engine, const std::map &values, + const TemplateManifest &manifest) const; - std::string render_string(const prebyte::Prebyte& engine, std::string_view input) const; - void render_file(const prebyte::Prebyte& engine, - const std::filesystem::path& input_path, - const std::filesystem::path& output_path) const; + std::string render_string(const prebyte::Prebyte &engine, std::string_view input) const; + void render_file(const prebyte::Prebyte &engine, const std::filesystem::path &input_path, + const std::filesystem::path &output_path) const; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/question/AnswerFile.h b/src/main/include/tempify/question/AnswerFile.h index 0b2a34e..4cf923c 100644 --- a/src/main/include/tempify/question/AnswerFile.h +++ b/src/main/include/tempify/question/AnswerFile.h @@ -6,9 +6,7 @@ namespace tempify { -std::map load_answer_file(const std::filesystem::path& path, - bool strict); -void write_answer_file(const std::filesystem::path& path, - const std::map& values); +std::map load_answer_file(const std::filesystem::path &path, bool strict); +void write_answer_file(const std::filesystem::path &path, const std::map &values); -} +} // namespace tempify diff --git a/src/main/include/tempify/question/QuestionProcessor.h b/src/main/include/tempify/question/QuestionProcessor.h index 485f2df..e3c40bc 100644 --- a/src/main/include/tempify/question/QuestionProcessor.h +++ b/src/main/include/tempify/question/QuestionProcessor.h @@ -11,20 +11,19 @@ class IQuestionFrontend; class LuaEngine; class QuestionProcessor { -public: - QuestionProcessor(const LuaEngine& lua_engine, IQuestionFrontend& frontend); - - std::map collect(const TemplateManifest& manifest, - const std::map& cli_values, - const std::map& config_values = {}, - const std::map& imported_values = {}, - bool non_interactive = false, - bool strict = false, + public: + QuestionProcessor(const LuaEngine &lua_engine, IQuestionFrontend &frontend); + + std::map collect(const TemplateManifest &manifest, + const std::map &cli_values, + const std::map &config_values = {}, + const std::map &imported_values = {}, + bool non_interactive = false, bool strict = false, bool review_before_finish = false) const; -private: - const LuaEngine& lua_engine_; - IQuestionFrontend& frontend_; + private: + const LuaEngine &lua_engine_; + IQuestionFrontend &frontend_; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/store/AvailableTemplateCache.h b/src/main/include/tempify/store/AvailableTemplateCache.h index b23b88d..cbfc514 100644 --- a/src/main/include/tempify/store/AvailableTemplateCache.h +++ b/src/main/include/tempify/store/AvailableTemplateCache.h @@ -20,17 +20,17 @@ struct AvailableTemplateRecord { }; class AvailableTemplateCache { -public: + public: explicit AvailableTemplateCache(std::filesystem::path shared_root); - const std::filesystem::path& file() const noexcept; + const std::filesystem::path &file() const noexcept; std::vector list_templates() const; - std::optional find_template(const std::string& id) const; + std::optional find_template(const std::string &id) const; -private: + private: std::filesystem::path root_; std::filesystem::path file_; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/store/LocalTemplateStore.h b/src/main/include/tempify/store/LocalTemplateStore.h index 8cf852f..fea365d 100644 --- a/src/main/include/tempify/store/LocalTemplateStore.h +++ b/src/main/include/tempify/store/LocalTemplateStore.h @@ -18,23 +18,23 @@ struct StoredTemplateRecord { }; class LocalTemplateStore { -public: + public: explicit LocalTemplateStore(std::filesystem::path shared_root); - const std::filesystem::path& root() const noexcept; - const std::filesystem::path& templates_root() const noexcept; - const std::filesystem::path& index_root() const noexcept; - const std::filesystem::path& index_file() const noexcept; + const std::filesystem::path &root() const noexcept; + const std::filesystem::path &templates_root() const noexcept; + const std::filesystem::path &index_root() const noexcept; + const std::filesystem::path &index_file() const noexcept; std::vector list_templates() const; - std::optional find_template(const std::string& id) const; - std::size_t refresh(const TemplateLoader& loader) const; + std::optional find_template(const std::string &id) const; + std::size_t refresh(const TemplateLoader &loader) const; -private: + private: std::filesystem::path root_; std::filesystem::path templates_root_; std::filesystem::path index_root_; std::filesystem::path index_file_; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/support/EnvLoader.h b/src/main/include/tempify/support/EnvLoader.h index 97af97a..0390d63 100644 --- a/src/main/include/tempify/support/EnvLoader.h +++ b/src/main/include/tempify/support/EnvLoader.h @@ -6,6 +6,6 @@ namespace tempify { -std::map load_env_file(const std::filesystem::path& path); +std::map load_env_file(const std::filesystem::path &path); } diff --git a/src/main/include/tempify/support/Errors.h b/src/main/include/tempify/support/Errors.h index dec3b2d..13cad2b 100644 --- a/src/main/include/tempify/support/Errors.h +++ b/src/main/include/tempify/support/Errors.h @@ -1,14 +1,14 @@ #pragma once #include -#include #include +#include #include namespace tempify { class TempifyError : public std::runtime_error { -public: + public: using std::runtime_error::runtime_error; }; @@ -29,39 +29,36 @@ struct ReapplyVersionTransitionInfo { }; class ReapplyBlockedError : public TempifyError { -public: - ReapplyBlockedError(std::string message, - std::vector conflict_paths, + public: + ReapplyBlockedError(std::string message, std::vector conflict_paths, std::vector review_paths, std::optional origin_mismatch = std::nullopt, std::optional version_transition = std::nullopt) - : TempifyError(std::move(message)), - conflict_paths_(std::move(conflict_paths)), - review_paths_(std::move(review_paths)), - origin_mismatch_(std::move(origin_mismatch)), + : TempifyError(std::move(message)), conflict_paths_(std::move(conflict_paths)), + review_paths_(std::move(review_paths)), origin_mismatch_(std::move(origin_mismatch)), version_transition_(std::move(version_transition)) {} - const std::vector& conflict_paths() const noexcept { + const std::vector &conflict_paths() const noexcept { return conflict_paths_; } - const std::vector& review_paths() const noexcept { + const std::vector &review_paths() const noexcept { return review_paths_; } - const std::optional& origin_mismatch() const noexcept { + const std::optional &origin_mismatch() const noexcept { return origin_mismatch_; } - const std::optional& version_transition() const noexcept { + const std::optional &version_transition() const noexcept { return version_transition_; } -private: + private: std::vector conflict_paths_; std::vector review_paths_; std::optional origin_mismatch_; std::optional version_transition_; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/support/Paths.h b/src/main/include/tempify/support/Paths.h index 5f1eece..013602e 100644 --- a/src/main/include/tempify/support/Paths.h +++ b/src/main/include/tempify/support/Paths.h @@ -5,10 +5,10 @@ namespace tempify { -std::optional find_workspace_templates_root(const std::filesystem::path& start); -std::optional find_workspace_config_file(const std::filesystem::path& start); +std::optional find_workspace_templates_root(const std::filesystem::path &start); +std::optional find_workspace_config_file(const std::filesystem::path &start); std::filesystem::path resolve_tempify_data_root(); std::filesystem::path resolve_tempify_config_root(); std::filesystem::path default_global_config_file_path(); -} +} // namespace tempify diff --git a/src/main/include/tempify/template/TemplateInspector.h b/src/main/include/tempify/template/TemplateInspector.h index 9c7a3ec..31950ed 100644 --- a/src/main/include/tempify/template/TemplateInspector.h +++ b/src/main/include/tempify/template/TemplateInspector.h @@ -6,6 +6,6 @@ namespace tempify { -std::string inspect_template_text(const TemplateManifest& manifest); +std::string inspect_template_text(const TemplateManifest &manifest); } diff --git a/src/main/include/tempify/template/TemplateLinter.h b/src/main/include/tempify/template/TemplateLinter.h index 776296c..8f57f0f 100644 --- a/src/main/include/tempify/template/TemplateLinter.h +++ b/src/main/include/tempify/template/TemplateLinter.h @@ -8,11 +8,10 @@ namespace tempify { class TemplateLinter { -public: - std::vector lint(const TemplateManifest& manifest) const; + public: + std::vector lint(const TemplateManifest &manifest) const; }; -std::string format_template_lint_text(const std::string& template_id, - const std::vector& warnings); +std::string format_template_lint_text(const std::string &template_id, const std::vector &warnings); -} +} // namespace tempify diff --git a/src/main/include/tempify/template/TemplateLoader.h b/src/main/include/tempify/template/TemplateLoader.h index 073a628..3650dff 100644 --- a/src/main/include/tempify/template/TemplateLoader.h +++ b/src/main/include/tempify/template/TemplateLoader.h @@ -10,23 +10,21 @@ namespace tempify { class LuaEngine; class TemplateLoader { -public: - explicit TemplateLoader(const LuaEngine& lua_engine); - - TemplateInfo summarize(const std::filesystem::path& template_root) const; - TemplateManifest load(const std::filesystem::path& template_root, - const std::map& template_index) const; - -private: - TemplateManifest load_recursive(const std::filesystem::path& template_root, - const std::map& template_index, - std::vector& stack) const; - TemplateManifest merge(const TemplateManifest& base, - const TemplateManifest& overlay, - const TemplateMergeConfig& policy, - bool overlay_is_local) const; - - const LuaEngine& lua_engine_; + public: + explicit TemplateLoader(const LuaEngine &lua_engine); + + TemplateInfo summarize(const std::filesystem::path &template_root) const; + TemplateManifest load(const std::filesystem::path &template_root, + const std::map &template_index) const; + + private: + TemplateManifest load_recursive(const std::filesystem::path &template_root, + const std::map &template_index, + std::vector &stack) const; + TemplateManifest merge(const TemplateManifest &base, const TemplateManifest &overlay, + const TemplateMergeConfig &policy, bool overlay_is_local) const; + + const LuaEngine &lua_engine_; }; -} +} // namespace tempify diff --git a/src/main/include/tempify/template/TemplateTestRunner.h b/src/main/include/tempify/template/TemplateTestRunner.h index eaf7028..2b44543 100644 --- a/src/main/include/tempify/template/TemplateTestRunner.h +++ b/src/main/include/tempify/template/TemplateTestRunner.h @@ -39,27 +39,28 @@ struct TemplateFixtureListing { }; class TemplateTestRunner { -public: - TemplateTestRunner(const LuaEngine& lua_engine, const PrebyteRenderer& renderer); + public: + TemplateTestRunner(const LuaEngine &lua_engine, const PrebyteRenderer &renderer); - TemplateTestReport run(const TemplateManifest& manifest, - const std::optional& fixture_name = std::nullopt) const; - TemplateTestReport update_snapshots(const TemplateManifest& manifest, - const std::optional& fixture_name = std::nullopt) const; - std::vector list_fixture_names(const TemplateManifest& manifest, - const std::optional& fixture_name = std::nullopt) const; - std::vector list_fixtures(const TemplateManifest& manifest, - const std::optional& fixture_name = std::nullopt) const; + TemplateTestReport run(const TemplateManifest &manifest, + const std::optional &fixture_name = std::nullopt) const; + TemplateTestReport update_snapshots(const TemplateManifest &manifest, + const std::optional &fixture_name = std::nullopt) const; + std::vector list_fixture_names(const TemplateManifest &manifest, + const std::optional &fixture_name = std::nullopt) const; + std::vector + list_fixtures(const TemplateManifest &manifest, + const std::optional &fixture_name = std::nullopt) const; -private: - const LuaEngine& lua_engine_; - const PrebyteRenderer& renderer_; + private: + const LuaEngine &lua_engine_; + const PrebyteRenderer &renderer_; }; std::string canonicalize_template_test_lockfile_json(std::string text); -std::string format_template_test_report(const TemplateTestReport& report); -std::string format_template_test_report_json(const TemplateTestReport& report); -std::string format_template_fixture_listing_json(const std::string& template_id, - const std::vector& fixtures); +std::string format_template_test_report(const TemplateTestReport &report); +std::string format_template_test_report_json(const TemplateTestReport &report); +std::string format_template_fixture_listing_json(const std::string &template_id, + const std::vector &fixtures); -} +} // namespace tempify diff --git a/src/main/include/tempify/template/TemplateValidator.h b/src/main/include/tempify/template/TemplateValidator.h index c8a05dc..bd47826 100644 --- a/src/main/include/tempify/template/TemplateValidator.h +++ b/src/main/include/tempify/template/TemplateValidator.h @@ -5,8 +5,8 @@ namespace tempify { class TemplateValidator { -public: - void validate(const TemplateManifest& manifest) const; + public: + void validate(const TemplateManifest &manifest) const; }; -} +} // namespace tempify diff --git a/tests/support/TempifyAppTestSupport.h b/tests/support/TempifyAppTestSupport.h index 6803b8f..bdbe30f 100644 --- a/tests/support/TempifyAppTestSupport.h +++ b/tests/support/TempifyAppTestSupport.h @@ -1,7 +1,6 @@ #pragma once #include "TempifyTestSupport.h" - #include "tempify/app/TempifyApp.h" #include "tempify/cli/CliParser.h" #include "tempify/domain/CliRequest.h" diff --git a/tests/support/TempifyTestSupport.h b/tests/support/TempifyTestSupport.h index 6478da1..477d4e5 100644 --- a/tests/support/TempifyTestSupport.h +++ b/tests/support/TempifyTestSupport.h @@ -14,22 +14,22 @@ namespace tempify::test_support { -inline const std::filesystem::path& project_root() { +inline const std::filesystem::path &project_root() { static const std::filesystem::path value = std::filesystem::path(TEMPIFY_SOURCE_DIR); return value; } -inline const std::filesystem::path& tests_root() { +inline const std::filesystem::path &tests_root() { static const std::filesystem::path value = project_root() / "tests"; return value; } -inline const std::filesystem::path& test_workspace_root() { +inline const std::filesystem::path &test_workspace_root() { static const std::filesystem::path value = tests_root() / "test_workspace"; return value; } -inline const std::filesystem::path& test_templates_root() { +inline const std::filesystem::path &test_templates_root() { static const std::filesystem::path value = tests_root() / "test_templates"; return value; } @@ -47,34 +47,32 @@ inline std::filesystem::path test_template_path(const std::string_view template_ } class ScopedDirectoryCleanup { -public: - explicit ScopedDirectoryCleanup(std::filesystem::path path) - : path_(std::move(path)) { + public: + explicit ScopedDirectoryCleanup(std::filesystem::path path) : path_(std::move(path)) { std::filesystem::remove_all(path_); } - ScopedDirectoryCleanup(const ScopedDirectoryCleanup&) = delete; - ScopedDirectoryCleanup& operator=(const ScopedDirectoryCleanup&) = delete; + ScopedDirectoryCleanup(const ScopedDirectoryCleanup &) = delete; + ScopedDirectoryCleanup &operator=(const ScopedDirectoryCleanup &) = delete; ~ScopedDirectoryCleanup() { std::filesystem::remove_all(path_); } - const std::filesystem::path& path() const noexcept { + const std::filesystem::path &path() const noexcept { return path_; } -private: + private: std::filesystem::path path_; }; class ScopedStdoutCapture { -public: - ScopedStdoutCapture() - : previous_(std::cout.rdbuf(buffer_.rdbuf())) {} + public: + ScopedStdoutCapture() : previous_(std::cout.rdbuf(buffer_.rdbuf())) {} - ScopedStdoutCapture(const ScopedStdoutCapture&) = delete; - ScopedStdoutCapture& operator=(const ScopedStdoutCapture&) = delete; + ScopedStdoutCapture(const ScopedStdoutCapture &) = delete; + ScopedStdoutCapture &operator=(const ScopedStdoutCapture &) = delete; ~ScopedStdoutCapture() { std::cout.rdbuf(previous_); @@ -84,61 +82,58 @@ class ScopedStdoutCapture { return buffer_.str(); } -private: + private: std::ostringstream buffer_; - std::streambuf* previous_ = nullptr; + std::streambuf *previous_ = nullptr; }; class ScopedStdinCapture { -public: + public: explicit ScopedStdinCapture(std::string text) - : buffer_(std::move(text)), - previous_(std::cin.rdbuf(buffer_.rdbuf())) {} + : buffer_(std::move(text)), previous_(std::cin.rdbuf(buffer_.rdbuf())) {} - ScopedStdinCapture(const ScopedStdinCapture&) = delete; - ScopedStdinCapture& operator=(const ScopedStdinCapture&) = delete; + ScopedStdinCapture(const ScopedStdinCapture &) = delete; + ScopedStdinCapture &operator=(const ScopedStdinCapture &) = delete; ~ScopedStdinCapture() { std::cin.rdbuf(previous_); } -private: + private: std::istringstream buffer_; - std::streambuf* previous_ = nullptr; + std::streambuf *previous_ = nullptr; }; class ScopedCurrentPath { -public: - explicit ScopedCurrentPath(const std::filesystem::path& path) - : previous_(std::filesystem::current_path()) { + public: + explicit ScopedCurrentPath(const std::filesystem::path &path) : previous_(std::filesystem::current_path()) { std::filesystem::current_path(path); } - ScopedCurrentPath(const ScopedCurrentPath&) = delete; - ScopedCurrentPath& operator=(const ScopedCurrentPath&) = delete; + ScopedCurrentPath(const ScopedCurrentPath &) = delete; + ScopedCurrentPath &operator=(const ScopedCurrentPath &) = delete; ~ScopedCurrentPath() { std::filesystem::current_path(previous_); } -private: + private: std::filesystem::path previous_; }; class ScopedTestWorkspace final : public ScopedCurrentPath { -public: - ScopedTestWorkspace() - : ScopedCurrentPath((ensure_test_workspace_layout(), test_workspace_root())) {} + public: + ScopedTestWorkspace() : ScopedCurrentPath((ensure_test_workspace_layout(), test_workspace_root())) {} }; -inline std::string read_text_file(const std::filesystem::path& path) { +inline std::string read_text_file(const std::filesystem::path &path) { std::ifstream input(path, std::ios::binary); std::ostringstream stream; stream << input.rdbuf(); return stream.str(); } -inline void write_text_file(const std::filesystem::path& path, const std::string& text) { +inline void write_text_file(const std::filesystem::path &path, const std::string &text) { if (path.has_parent_path()) { std::filesystem::create_directories(path.parent_path()); } @@ -151,23 +146,35 @@ inline std::string json_escape(std::string_view value) { escaped.reserve(value.size()); for (const char ch : value) { switch (ch) { - case '\\': escaped += "\\\\"; break; - case '"': escaped += "\\\""; break; - case '\n': escaped += "\\n"; break; - case '\r': escaped += "\\r"; break; - case '\t': escaped += "\\t"; break; - default: escaped.push_back(ch); break; + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; } } return escaped; } -inline std::string json_escaped_path(const std::filesystem::path& path) { +inline std::string json_escaped_path(const std::filesystem::path &path) { return json_escape(path.string()); } inline std::filesystem::path tempify_binary_path() { - if (const char* binary = std::getenv("TEMPIFY_TEST_BINARY"); binary != nullptr && binary[0] != '\0') { + if (const char *binary = std::getenv("TEMPIFY_TEST_BINARY"); binary != nullptr && binary[0] != '\0') { return binary; } @@ -179,15 +186,14 @@ inline std::filesystem::path tempify_binary_path() { } class ScopedTempifyDataHome { -public: + public: explicit ScopedTempifyDataHome(std::filesystem::path path) - : cleanup_(std::move(path)), - env_("XDG_DATA_HOME", cleanup_.path().string()) {} + : cleanup_(std::move(path)), env_("XDG_DATA_HOME", cleanup_.path().string()) {} - ScopedTempifyDataHome(const ScopedTempifyDataHome&) = delete; - ScopedTempifyDataHome& operator=(const ScopedTempifyDataHome&) = delete; + ScopedTempifyDataHome(const ScopedTempifyDataHome &) = delete; + ScopedTempifyDataHome &operator=(const ScopedTempifyDataHome &) = delete; - const std::filesystem::path& path() const noexcept { + const std::filesystem::path &path() const noexcept { return cleanup_.path(); } @@ -195,30 +201,29 @@ class ScopedTempifyDataHome { return cleanup_.path() / "tempify"; } -private: + private: ScopedDirectoryCleanup cleanup_; prebyte::test::ScopedEnvironmentVariable env_; }; class ScopedTempifyConfigHome { -public: + public: explicit ScopedTempifyConfigHome(std::filesystem::path path) - : cleanup_(std::move(path)), - env_("XDG_CONFIG_HOME", cleanup_.path().string()) {} + : cleanup_(std::move(path)), env_("XDG_CONFIG_HOME", cleanup_.path().string()) {} - ScopedTempifyConfigHome(const ScopedTempifyConfigHome&) = delete; - ScopedTempifyConfigHome& operator=(const ScopedTempifyConfigHome&) = delete; + ScopedTempifyConfigHome(const ScopedTempifyConfigHome &) = delete; + ScopedTempifyConfigHome &operator=(const ScopedTempifyConfigHome &) = delete; - const std::filesystem::path& path() const noexcept { + const std::filesystem::path &path() const noexcept { return cleanup_.path(); } -private: + private: ScopedDirectoryCleanup cleanup_; prebyte::test::ScopedEnvironmentVariable env_; }; -inline void link_test_templates_into_workspace(const std::filesystem::path& workspace_root) { +inline void link_test_templates_into_workspace(const std::filesystem::path &workspace_root) { std::filesystem::create_directories(workspace_root); std::error_code error; std::filesystem::remove(workspace_root / "templates", error); @@ -226,18 +231,20 @@ inline void link_test_templates_into_workspace(const std::filesystem::path& work std::filesystem::create_directory_symlink(test_templates_root(), workspace_root / "templates"); } -inline std::filesystem::path create_required_only_template_with_version(const std::filesystem::path& root, - const std::string& version) { +inline std::filesystem::path create_required_only_template_with_version(const std::filesystem::path &root, + const std::string &version) { const std::filesystem::path template_root = root / "required_only"; std::filesystem::create_directories(template_root / "files"); write_text_file(template_root / "template.lua", "return {\n" " id = \"required_only\",\n" " name = \"Required Only\",\n" - " version = \"" + version + "\",\n" - " source_dir = \"files\",\n" - " output = { path = \"{{ required_name }}\", overwrite = false },\n" - "}\n"); + " version = \"" + + version + + "\",\n" + " source_dir = \"files\",\n" + " output = { path = \"{{ required_name }}\", overwrite = false },\n" + "}\n"); write_text_file(template_root / "questions.lua", "return {\n" " order = { \"General\" },\n" @@ -251,21 +258,20 @@ inline std::filesystem::path create_required_only_template_with_version(const st return template_root; } -inline std::filesystem::path create_required_only_template(const std::filesystem::path& root) { +inline std::filesystem::path create_required_only_template(const std::filesystem::path &root) { return create_required_only_template_with_version(root, "1.0.0"); } -inline std::filesystem::path create_duplicate_alias_template(const std::filesystem::path& root) { +inline std::filesystem::path create_duplicate_alias_template(const std::filesystem::path &root) { const std::filesystem::path template_root = root / "duplicate_alias"; std::filesystem::create_directories(template_root / "files"); - write_text_file(template_root / "template.lua", - "return {\n" - " id = \"duplicate_alias\",\n" - " name = \"Duplicate Alias\",\n" - " version = \"1.0.0\",\n" - " source_dir = \"files\",\n" - " output = { path = \"out\", overwrite = false },\n" - "}\n"); + write_text_file(template_root / "template.lua", "return {\n" + " id = \"duplicate_alias\",\n" + " name = \"Duplicate Alias\",\n" + " version = \"1.0.0\",\n" + " source_dir = \"files\",\n" + " output = { path = \"out\", overwrite = false },\n" + "}\n"); write_text_file(template_root / "questions.lua", "return {\n" " order = { \"General\" },\n" @@ -280,36 +286,33 @@ inline std::filesystem::path create_duplicate_alias_template(const std::filesyst return template_root; } -inline std::filesystem::path create_bad_layout_template(const std::filesystem::path& root) { +inline std::filesystem::path create_bad_layout_template(const std::filesystem::path &root) { const std::filesystem::path template_root = root / "bad_layout"; std::filesystem::create_directories(template_root / "files"); - write_text_file(template_root / "template.lua", - "return {\n" - " id = \"bad_layout\",\n" - " name = \"Bad Layout\",\n" - " version = \"1.0.0\",\n" - " source_dir = \"files\",\n" - " output = { path = \"out\", overwrite = false },\n" - "}\n"); - write_text_file(template_root / "layout.lua", - "return {\n" - " { source = \"missing.txt\", target = \"renamed.txt\" },\n" - "}\n"); + write_text_file(template_root / "template.lua", "return {\n" + " id = \"bad_layout\",\n" + " name = \"Bad Layout\",\n" + " version = \"1.0.0\",\n" + " source_dir = \"files\",\n" + " output = { path = \"out\", overwrite = false },\n" + "}\n"); + write_text_file(template_root / "layout.lua", "return {\n" + " { source = \"missing.txt\", target = \"renamed.txt\" },\n" + "}\n"); write_text_file(template_root / "files" / "README.md.pbt", "# hi\n"); return template_root; } -inline std::filesystem::path create_sensitive_template(const std::filesystem::path& root) { +inline std::filesystem::path create_sensitive_template(const std::filesystem::path &root) { const std::filesystem::path template_root = root / "sensitive_demo"; std::filesystem::create_directories(template_root / "files"); - write_text_file(template_root / "template.lua", - "return {\n" - " id = \"sensitive_demo\",\n" - " name = \"Sensitive Demo\",\n" - " version = \"1.0.0\",\n" - " source_dir = \"files\",\n" - " output = { path = \"{{ project_name }}\", overwrite = false },\n" - "}\n"); + write_text_file(template_root / "template.lua", "return {\n" + " id = \"sensitive_demo\",\n" + " name = \"Sensitive Demo\",\n" + " version = \"1.0.0\",\n" + " source_dir = \"files\",\n" + " output = { path = \"{{ project_name }}\", overwrite = false },\n" + "}\n"); write_text_file(template_root / "questions.lua", "return {\n" " order = { \"Secrets\" },\n" @@ -324,17 +327,16 @@ inline std::filesystem::path create_sensitive_template(const std::filesystem::pa return template_root; } -inline std::filesystem::path create_slow_hook_template(const std::filesystem::path& root) { +inline std::filesystem::path create_slow_hook_template(const std::filesystem::path &root) { const std::filesystem::path template_root = root / "slow_hook_demo"; std::filesystem::create_directories(template_root / "files"); - write_text_file(template_root / "template.lua", - "return {\n" - " id = \"slow_hook_demo\",\n" - " name = \"Slow Hook Demo\",\n" - " version = \"1.0.0\",\n" - " source_dir = \"files\",\n" - " output = { path = \"{{ project_name }}\", overwrite = false },\n" - "}\n"); + write_text_file(template_root / "template.lua", "return {\n" + " id = \"slow_hook_demo\",\n" + " name = \"Slow Hook Demo\",\n" + " version = \"1.0.0\",\n" + " source_dir = \"files\",\n" + " output = { path = \"{{ project_name }}\", overwrite = false },\n" + "}\n"); write_text_file(template_root / "questions.lua", "return {\n" " order = { \"General\" },\n" @@ -349,53 +351,54 @@ inline std::filesystem::path create_slow_hook_template(const std::filesystem::pa return template_root; } -inline std::filesystem::path create_basic_template_at(const std::filesystem::path& template_root, - const std::string& template_id, - const std::string& template_name, - const std::string& version, - const std::string& description, - const std::string& readme_message = "Installed from shared store.") { +inline std::filesystem::path +create_basic_template_at(const std::filesystem::path &template_root, const std::string &template_id, + const std::string &template_name, const std::string &version, const std::string &description, + const std::string &readme_message = "Installed from shared store.") { std::filesystem::create_directories(template_root / "files"); write_text_file(template_root / "template.lua", "return {\n" - " id = \"" + template_id + "\",\n" - " name = \"" + template_name + "\",\n" - " version = \"" + version + "\",\n" - " description = \"" + description + "\",\n" - " source_dir = \"files\",\n" - " output = { path = \"{{ project_slug }}\", overwrite = false },\n" - "}\n"); - write_text_file(template_root / "questions.lua", - "return {\n" - " order = { \"Project\" },\n" - " groups = {\n" - " Project = {\n" - " { key = \"project_name\", type = \"string\", prompt = \"Project name\", default = \"Shared App\" },\n" - " { key = \"project_slug\", type = \"string\", default = function(ctx) return slugify(ctx.values.project_name or \"shared-app\") end },\n" - " },\n" - " },\n" - "}\n"); + " id = \"" + + template_id + + "\",\n" + " name = \"" + + template_name + + "\",\n" + " version = \"" + + version + + "\",\n" + " description = \"" + + description + + "\",\n" + " source_dir = \"files\",\n" + " output = { path = \"{{ project_slug }}\", overwrite = false },\n" + "}\n"); + write_text_file( + template_root / "questions.lua", + "return {\n" + " order = { \"Project\" },\n" + " groups = {\n" + " Project = {\n" + " { key = \"project_name\", type = \"string\", prompt = \"Project name\", default = \"Shared App\" },\n" + " { key = \"project_slug\", type = \"string\", default = function(ctx) return " + "slugify(ctx.values.project_name or \"shared-app\") end },\n" + " },\n" + " },\n" + "}\n"); write_text_file(template_root / "files" / "README.md.pbt", "# {{ project_name }}\n\n" + readme_message + "\n"); return template_root; } -inline void create_shared_template(const std::filesystem::path& shared_root, - const std::string& template_id, - const std::string& template_name, - const std::string& version, - const std::string& description, - const std::string& readme_message = "Installed from shared store.") { - static_cast(create_basic_template_at(shared_root / "templates" / template_id, - template_id, - template_name, - version, - description, - readme_message)); +inline void create_shared_template(const std::filesystem::path &shared_root, const std::string &template_id, + const std::string &template_name, const std::string &version, + const std::string &description, + const std::string &readme_message = "Installed from shared store.") { + static_cast(create_basic_template_at(shared_root / "templates" / template_id, template_id, template_name, + version, description, readme_message)); } -inline void write_available_template_cache(const std::filesystem::path& shared_root, - const std::string& payload) { +inline void write_available_template_cache(const std::filesystem::path &shared_root, const std::string &payload) { write_text_file(shared_root / "index" / "reqpack-available.json", payload); } -} +} // namespace tempify::test_support diff --git a/tests/support/TestHarness.cpp b/tests/support/TestHarness.cpp index 7ed97d1..9d13ab8 100644 --- a/tests/support/TestHarness.cpp +++ b/tests/support/TestHarness.cpp @@ -8,7 +8,7 @@ namespace prebyte::test { namespace { -bool set_environment_variable(const std::string& name, const std::string& value) { +bool set_environment_variable(const std::string &name, const std::string &value) { #ifdef _WIN32 return _putenv_s(name.c_str(), value.c_str()) == 0; #else @@ -16,7 +16,7 @@ bool set_environment_variable(const std::string& name, const std::string& value) #endif } -bool unset_environment_variable(const std::string& name) { +bool unset_environment_variable(const std::string &name) { #ifdef _WIN32 return _putenv_s(name.c_str(), "") == 0; #else @@ -24,15 +24,15 @@ bool unset_environment_variable(const std::string& name) { #endif } -} +} // namespace -std::vector& registry() { +std::vector ®istry() { static std::vector tests; return tests; } -const TestCase* find_test(std::string_view name) { - for (const TestCase& test_case : registry()) { +const TestCase *find_test(std::string_view name) { + for (const TestCase &test_case : registry()) { if (test_case.name == name) { return &test_case; } @@ -43,7 +43,7 @@ const TestCase* find_test(std::string_view name) { std::vector test_names() { std::vector names; names.reserve(registry().size()); - for (const TestCase& test_case : registry()) { + for (const TestCase &test_case : registry()) { names.push_back(test_case.name); } return names; @@ -53,12 +53,10 @@ TestRegistrar::TestRegistrar(std::string name, TestFunction function) { registry().push_back(TestCase{std::move(name), function}); } -AssertionFailure::AssertionFailure(const std::string& message) - : std::runtime_error(message) {} +AssertionFailure::AssertionFailure(const std::string &message) : std::runtime_error(message) {} -ScopedEnvironmentVariable::ScopedEnvironmentVariable(std::string name, std::string value) - : name_(std::move(name)) { - if (const char* current = std::getenv(name_.c_str())) { +ScopedEnvironmentVariable::ScopedEnvironmentVariable(std::string name, std::string value) : name_(std::move(name)) { + if (const char *current = std::getenv(name_.c_str())) { previous_value_ = current; } if (!set_environment_variable(name_, value)) { @@ -76,11 +74,11 @@ ScopedEnvironmentVariable::~ScopedEnvironmentVariable() { int run_all_tests() { int failed = 0; - for (const TestCase& test_case : registry()) { + for (const TestCase &test_case : registry()) { try { test_case.function(); std::cout << "[PASS] " << test_case.name << '\n'; - } catch (const std::exception& error) { + } catch (const std::exception &error) { ++failed; std::cerr << "[FAIL] " << test_case.name << " - " << error.what() << '\n'; } @@ -90,7 +88,7 @@ int run_all_tests() { } int run_test_by_name(std::string_view name) { - const TestCase* test_case = find_test(name); + const TestCase *test_case = find_test(name); if (test_case == nullptr) { std::cerr << "[FAIL] Unknown test: " << name << '\n'; return 1; @@ -100,10 +98,10 @@ int run_test_by_name(std::string_view name) { test_case->function(); std::cout << "[PASS] " << test_case->name << '\n'; return 0; - } catch (const std::exception& error) { + } catch (const std::exception &error) { std::cerr << "[FAIL] " << test_case->name << " - " << error.what() << '\n'; return 1; } } -} +} // namespace prebyte::test diff --git a/tests/support/TestHarness.h b/tests/support/TestHarness.h index 79c52de..c0575e8 100644 --- a/tests/support/TestHarness.h +++ b/tests/support/TestHarness.h @@ -9,15 +9,15 @@ namespace prebyte::test { -using TestFunction = void(*)(); +using TestFunction = void (*)(); struct TestCase { std::string name; TestFunction function; }; -std::vector& registry(); -const TestCase* find_test(std::string_view name); +std::vector ®istry(); +const TestCase *find_test(std::string_view name); std::vector test_names(); struct TestRegistrar { @@ -25,18 +25,18 @@ struct TestRegistrar { }; class AssertionFailure : public std::runtime_error { -public: - explicit AssertionFailure(const std::string& message); + public: + explicit AssertionFailure(const std::string &message); }; class ScopedEnvironmentVariable { -public: + public: ScopedEnvironmentVariable(std::string name, std::string value); - ScopedEnvironmentVariable(const ScopedEnvironmentVariable&) = delete; - ScopedEnvironmentVariable& operator=(const ScopedEnvironmentVariable&) = delete; + ScopedEnvironmentVariable(const ScopedEnvironmentVariable &) = delete; + ScopedEnvironmentVariable &operator=(const ScopedEnvironmentVariable &) = delete; ~ScopedEnvironmentVariable(); -private: + private: std::string name_; std::optional previous_value_; }; @@ -44,18 +44,18 @@ class ScopedEnvironmentVariable { int run_all_tests(); int run_test_by_name(std::string_view name); -template -void assert_equal(const Left& left, const Right& right, const char* left_expr, const char* right_expr, - const char* file, int line) { +template +void assert_equal(const Left &left, const Right &right, const char *left_expr, const char *right_expr, const char *file, + int line) { if (!(left == right)) { std::ostringstream stream; - stream << file << ':' << line << " expected equality: " << left_expr << " == " << right_expr - << " (left='" << left << "', right='" << right << "')"; + stream << file << ':' << line << " expected equality: " << left_expr << " == " << right_expr << " (left='" + << left << "', right='" << right << "')"; throw AssertionFailure(stream.str()); } } -inline void assert_true(bool value, const char* expr, const char* file, int line) { +inline void assert_true(bool value, const char *expr, const char *file, int line) { if (!value) { std::ostringstream stream; stream << file << ':' << line << " expected true: " << expr; @@ -63,33 +63,32 @@ inline void assert_true(bool value, const char* expr, const char* file, int line } } -template -void assert_throws(Function&& function, const char* expr, const char* file, int line) { +template +void assert_throws(Function &&function, const char *expr, const char *file, int line) { try { function(); - } catch (const Exception&) { + } catch (const Exception &) { return; - } catch (const std::exception& error) { + } catch (const std::exception &error) { std::ostringstream stream; - stream << file << ':' << line << " expected exception " << typeid(Exception).name() - << " from " << expr << ", got: " << error.what(); + stream << file << ':' << line << " expected exception " << typeid(Exception).name() << " from " << expr + << ", got: " << error.what(); throw AssertionFailure(stream.str()); } std::ostringstream stream; - stream << file << ':' << line << " expected exception " << typeid(Exception).name() - << " from " << expr; + stream << file << ':' << line << " expected exception " << typeid(Exception).name() << " from " << expr; throw AssertionFailure(stream.str()); } -} +} // namespace prebyte::test -#define TEST_CASE(name) \ - static void name(); \ - static ::prebyte::test::TestRegistrar name##_registrar(#name, &name); \ +#define TEST_CASE(name) \ + static void name(); \ + static ::prebyte::test::TestRegistrar name##_registrar(#name, &name); \ static void name() #define REQUIRE(expr) ::prebyte::test::assert_true((expr), #expr, __FILE__, __LINE__) #define REQUIRE_EQ(left, right) ::prebyte::test::assert_equal((left), (right), #left, #right, __FILE__, __LINE__) -#define REQUIRE_THROWS_AS(expr, exception_type) \ +#define REQUIRE_THROWS_AS(expr, exception_type) \ ::prebyte::test::assert_throws([&]() { (void)(expr); }, #expr, __FILE__, __LINE__) diff --git a/tests/support/TestMain.cpp b/tests/support/TestMain.cpp index 1958b74..ce66a25 100644 --- a/tests/support/TestMain.cpp +++ b/tests/support/TestMain.cpp @@ -1,24 +1,22 @@ -#include "TestHarness.h" #include "TempifyTestSupport.h" +#include "TestHarness.h" #include #include #include -int main(int argc, char** argv) { +int main(int argc, char **argv) { tempify::test_support::ensure_test_workspace_layout(); std::filesystem::current_path(tempify::test_support::test_workspace_root()); std::filesystem::create_directories(tempify::test_support::test_workspace_root() / ".xdg-data"); std::filesystem::create_directories(tempify::test_support::test_workspace_root() / ".xdg-config"); prebyte::test::ScopedEnvironmentVariable default_data_home( - "XDG_DATA_HOME", - (tempify::test_support::test_workspace_root() / ".xdg-data").string()); + "XDG_DATA_HOME", (tempify::test_support::test_workspace_root() / ".xdg-data").string()); prebyte::test::ScopedEnvironmentVariable default_config_home( - "XDG_CONFIG_HOME", - (tempify::test_support::test_workspace_root() / ".xdg-config").string()); + "XDG_CONFIG_HOME", (tempify::test_support::test_workspace_root() / ".xdg-config").string()); if (argc == 2 && std::string_view(argv[1]) == "--list-tests") { - for (const std::string& name : prebyte::test::test_names()) { + for (const std::string &name : prebyte::test::test_names()) { std::cout << name << '\n'; } return 0; diff --git a/tests/tempify/AdvancedHooksLayoutTests.cpp b/tests/tempify/AdvancedHooksLayoutTests.cpp index 6c39e7f..9568f36 100644 --- a/tests/tempify/AdvancedHooksLayoutTests.cpp +++ b/tests/tempify/AdvancedHooksLayoutTests.cpp @@ -1,6 +1,5 @@ -#include "TestHarness.h" #include "TempifyTestSupport.h" - +#include "TestHarness.h" #include "tempify/app/TempifyApp.h" #include "tempify/lua/LuaEngine.h" #include "tempify/template/TemplateLoader.h" @@ -13,9 +12,8 @@ namespace { class ScopedDirectoryCleanup { -public: - explicit ScopedDirectoryCleanup(std::filesystem::path path) - : path_(std::move(path)) { + public: + explicit ScopedDirectoryCleanup(std::filesystem::path path) : path_(std::move(path)) { std::filesystem::remove_all(path_); } @@ -23,32 +21,38 @@ class ScopedDirectoryCleanup { std::filesystem::remove_all(path_); } - const std::filesystem::path& path() const noexcept { return path_; } + const std::filesystem::path &path() const noexcept { + return path_; + } -private: + private: std::filesystem::path path_; }; -std::string read_text_file(const std::filesystem::path& path) { +std::string read_text_file(const std::filesystem::path &path) { std::ifstream input(path, std::ios::binary); std::ostringstream stream; stream << input.rdbuf(); return stream.str(); } -} +} // namespace TEST_CASE(TempifyApp_advanced_hooks_layout_scripts_and_hook_lifecycle_work) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-advanced-hooks-layout-test"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "advanced_hooks_layout", - target.path().string(), - "--set", "project_name=Advanced Demo", - "--set", "project_slug=advanced-demo", - "--set", "use_notes=false", - }), 0); + "advanced_hooks_layout", + target.path().string(), + "--set", + "project_name=Advanced Demo", + "--set", + "project_slug=advanced-demo", + "--set", + "use_notes=false", + }), + 0); REQUIRE(std::filesystem::exists(target.path() / "README.md")); REQUIRE(std::filesystem::exists(target.path() / "static" / "output.txt")); @@ -69,8 +73,8 @@ TEST_CASE(LuaEngine_export_questions_json_returns_minimal_question_payload) { const std::filesystem::path template_root = tempify::test_support::test_template_path("advanced_hooks_layout"); const tempify::TemplateManifest manifest = loader.load(template_root, { - {"advanced_hooks_layout", template_root}, - }); + {"advanced_hooks_layout", template_root}, + }); const std::string json = lua_engine.export_questions_json(manifest); REQUIRE(json.find("\"template\"") != std::string::npos); @@ -88,8 +92,8 @@ TEST_CASE(LuaEngine_export_questions_json_full_keeps_empty_fields) { const std::filesystem::path template_root = tempify::test_support::test_template_path("advanced_hooks_layout"); const tempify::TemplateManifest manifest = loader.load(template_root, { - {"advanced_hooks_layout", template_root}, - }); + {"advanced_hooks_layout", template_root}, + }); const std::string json = lua_engine.export_questions_json(manifest, true); REQUIRE(json.find("\"choices\": []") != std::string::npos); @@ -131,8 +135,8 @@ TEST_CASE(LuaEngine_export_questions_json_includes_sensitive_flag) { tempify::LuaEngine lua_engine; tempify::TemplateLoader loader(lua_engine); const tempify::TemplateManifest manifest = loader.load(template_root, { - {"sensitive_tpl", template_root}, - }); + {"sensitive_tpl", template_root}, + }); REQUIRE(manifest.questions.size() == static_cast(1)); REQUIRE(manifest.questions.front().sensitive); diff --git a/tests/tempify/BuildExecutorTests.cpp b/tests/tempify/BuildExecutorTests.cpp index 0bf382d..9a3143c 100644 --- a/tests/tempify/BuildExecutorTests.cpp +++ b/tests/tempify/BuildExecutorTests.cpp @@ -1,5 +1,4 @@ #include "TestHarness.h" - #include "tempify/build/BuildExecutor.h" #include "tempify/lua/LuaEngine.h" #include "tempify/prebyte/PrebyteRenderer.h" @@ -16,9 +15,8 @@ namespace { class ScopedDirectoryCleanup { -public: - explicit ScopedDirectoryCleanup(std::filesystem::path path) - : path_(std::move(path)) { + public: + explicit ScopedDirectoryCleanup(std::filesystem::path path) : path_(std::move(path)) { std::filesystem::remove_all(path_); } @@ -26,39 +24,38 @@ class ScopedDirectoryCleanup { std::filesystem::remove_all(path_); } - const std::filesystem::path& path() const noexcept { + const std::filesystem::path &path() const noexcept { return path_; } -private: + private: std::filesystem::path path_; }; -void write_text_file(const std::filesystem::path& path, const std::string& text) { +void write_text_file(const std::filesystem::path &path, const std::string &text) { std::filesystem::create_directories(path.parent_path()); std::ofstream output(path, std::ios::binary); output << text; } -std::string read_text_file(const std::filesystem::path& path) { +std::string read_text_file(const std::filesystem::path &path) { std::ifstream input(path, std::ios::binary); std::ostringstream stream; stream << input.rdbuf(); return stream.str(); } -std::filesystem::path write_hook_script(const std::filesystem::path& path, const std::string& phase) { - write_text_file( - path, - "local previous = \"\"\n" - "if exists(\"hook.log\") then\n" - " previous = read_file(\"hook.log\")\n" - "end\n" - "write_file(\"hook.log\", previous .. \"" + phase + "\\n\")\n"); +std::filesystem::path write_hook_script(const std::filesystem::path &path, const std::string &phase) { + write_text_file(path, "local previous = \"\"\n" + "if exists(\"hook.log\") then\n" + " previous = read_file(\"hook.log\")\n" + "end\n" + "write_file(\"hook.log\", previous .. \"" + + phase + "\\n\")\n"); return path; } -tempify::TemplateManifest make_manifest(const std::filesystem::path& template_root) { +tempify::TemplateManifest make_manifest(const std::filesystem::path &template_root) { tempify::TemplateManifest manifest; manifest.root = template_root; manifest.info.id = "build_exec"; @@ -69,10 +66,11 @@ tempify::TemplateManifest make_manifest(const std::filesystem::path& template_ro return manifest; } -} +} // namespace TEST_CASE(BuildExecutor_throws_when_target_exists_and_overwrite_disabled) { - ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-build-executor-existing-target-test"); + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-build-executor-existing-target-test"); write_text_file(workspace.path() / "existing" / "keep.txt", "keep\n"); tempify::BuildPlan plan; @@ -127,7 +125,8 @@ TEST_CASE(BuildExecutor_runs_hooks_in_expected_order_and_renders_files) { } TEST_CASE(BuildExecutor_disable_hooks_skips_hook_scripts_but_still_renders_files) { - ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-build-executor-disable-hooks-test"); + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-build-executor-disable-hooks-test"); const std::filesystem::path template_root = workspace.path() / "template"; const std::filesystem::path build_root = workspace.path() / "out"; const std::filesystem::path source_file = template_root / "files" / "hello.txt.pbt"; @@ -241,12 +240,10 @@ TEST_CASE(BuildExecutor_before_and_after_render_hooks_observe_expected_file_visi const std::filesystem::path build_root = workspace.path() / "out"; const std::filesystem::path source_file = template_root / "files" / "hello.txt.pbt"; write_text_file(source_file, "Hello {{ name }}\n"); - write_text_file( - template_root / "hooks" / "before_render.lua", - "write_file('before.txt', exists('hello.txt') and 'yes' or 'no')\n"); - write_text_file( - template_root / "hooks" / "after_render.lua", - "write_file('after.txt', exists('hello.txt') and 'yes' or 'no')\n"); + write_text_file(template_root / "hooks" / "before_render.lua", + "write_file('before.txt', exists('hello.txt') and 'yes' or 'no')\n"); + write_text_file(template_root / "hooks" / "after_render.lua", + "write_file('after.txt', exists('hello.txt') and 'yes' or 'no')\n"); tempify::TemplateManifest manifest = make_manifest(template_root); manifest.before_render_hook_path = template_root / "hooks" / "before_render.lua"; @@ -277,7 +274,8 @@ TEST_CASE(BuildExecutor_before_and_after_render_hooks_observe_expected_file_visi } TEST_CASE(BuildExecutor_hook_failures_include_phase_name) { - ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-build-executor-hook-failure-test"); + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-build-executor-hook-failure-test"); const std::filesystem::path template_root = workspace.path() / "template"; const std::filesystem::path build_root = workspace.path() / "out"; const std::filesystem::path source_file = template_root / "files" / "hello.txt.pbt"; @@ -307,14 +305,15 @@ TEST_CASE(BuildExecutor_hook_failures_include_phase_name) { try { executor.execute(plan, manifest, {{"name", "Stone"}}, false); REQUIRE(false); - } catch (const tempify::TempifyError& error) { + } catch (const tempify::TempifyError &error) { REQUIRE(std::string(error.what()).find("Hook phase 'after_render' failed") != std::string::npos); REQUIRE(std::string(error.what()).find("after_render.lua") != std::string::npos); } } TEST_CASE(BuildExecutor_hook_timeouts_include_phase_and_script_path) { - ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-build-executor-hook-timeout-test"); + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-build-executor-hook-timeout-test"); const std::filesystem::path template_root = workspace.path() / "template"; const std::filesystem::path build_root = workspace.path() / "out"; const std::filesystem::path source_file = template_root / "files" / "hello.txt.pbt"; @@ -344,7 +343,7 @@ TEST_CASE(BuildExecutor_hook_timeouts_include_phase_and_script_path) { try { executor.execute(plan, manifest, {{"name", "Stone"}}, false, std::chrono::milliseconds(25)); REQUIRE(false); - } catch (const tempify::TempifyError& error) { + } catch (const tempify::TempifyError &error) { REQUIRE(std::string(error.what()).find("Hook phase 'post' failed") != std::string::npos); REQUIRE(std::string(error.what()).find("post.lua") != std::string::npos); REQUIRE(std::string(error.what()).find("timed out after 25 ms") != std::string::npos); diff --git a/tests/tempify/BuildPlannerTests.cpp b/tests/tempify/BuildPlannerTests.cpp index 653a715..97d4dc4 100644 --- a/tests/tempify/BuildPlannerTests.cpp +++ b/tests/tempify/BuildPlannerTests.cpp @@ -1,5 +1,4 @@ #include "TestHarness.h" - #include "tempify/build/BuildPlanner.h" #include "tempify/prebyte/PrebyteRenderer.h" #include "tempify/support/Errors.h" @@ -14,9 +13,8 @@ namespace { class ScopedDirectoryCleanup { -public: - explicit ScopedDirectoryCleanup(std::filesystem::path path) - : path_(std::move(path)) { + public: + explicit ScopedDirectoryCleanup(std::filesystem::path path) : path_(std::move(path)) { std::filesystem::remove_all(path_); } @@ -24,15 +22,15 @@ class ScopedDirectoryCleanup { std::filesystem::remove_all(path_); } - const std::filesystem::path& path() const noexcept { + const std::filesystem::path &path() const noexcept { return path_; } -private: + private: std::filesystem::path path_; }; -void write_text_file(const std::filesystem::path& path, const std::string& text) { +void write_text_file(const std::filesystem::path &path, const std::string &text) { std::filesystem::create_directories(path.parent_path()); std::ofstream output(path, std::ios::binary); output << text; @@ -45,7 +43,7 @@ tempify::TemplateManifest make_manifest(std::string id) { return manifest; } -} +} // namespace TEST_CASE(BuildPlanner_rejects_rendered_output_path_that_escapes_build_root) { ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-build-planner-escape-test"); @@ -63,12 +61,8 @@ TEST_CASE(BuildPlanner_rejects_rendered_output_path_that_escapes_build_root) { const tempify::PrebyteRenderer renderer; const tempify::BuildPlanner planner(renderer); - REQUIRE_THROWS_AS( - planner.plan( - manifest, - {{"output_path", "../escape.txt"}}, - workspace.path() / "out"), - tempify::TempifyError); + REQUIRE_THROWS_AS(planner.plan(manifest, {{"output_path", "../escape.txt"}}, workspace.path() / "out"), + tempify::TempifyError); } TEST_CASE(BuildPlanner_rejects_empty_rendered_build_root) { @@ -78,9 +72,7 @@ TEST_CASE(BuildPlanner_rejects_empty_rendered_build_root) { const tempify::PrebyteRenderer renderer; const tempify::BuildPlanner planner(renderer); - REQUIRE_THROWS_AS( - planner.plan(manifest, {{"build_name", ""}}, std::nullopt), - tempify::TempifyError); + REQUIRE_THROWS_AS(planner.plan(manifest, {{"build_name", ""}}, std::nullopt), tempify::TempifyError); } TEST_CASE(BuildPlanner_detects_colliding_rendered_output_paths) { @@ -107,12 +99,8 @@ TEST_CASE(BuildPlanner_detects_colliding_rendered_output_paths) { const tempify::PrebyteRenderer renderer; const tempify::BuildPlanner planner(renderer); - REQUIRE_THROWS_AS( - planner.plan( - manifest, - {{"second_name", "same.txt"}}, - workspace.path() / "out"), - tempify::TempifyError); + REQUIRE_THROWS_AS(planner.plan(manifest, {{"second_name", "same.txt"}}, workspace.path() / "out"), + tempify::TempifyError); } TEST_CASE(BuildPlanner_applies_layout_target_and_render_override) { @@ -136,10 +124,7 @@ TEST_CASE(BuildPlanner_applies_layout_target_and_render_override) { const tempify::PrebyteRenderer renderer; const tempify::BuildPlanner planner(renderer); - const tempify::BuildPlan plan = planner.plan( - manifest, - {{"project_slug", "stone-app"}}, - workspace.path() / "out"); + const tempify::BuildPlan plan = planner.plan(manifest, {{"project_slug", "stone-app"}}, workspace.path() / "out"); REQUIRE_EQ(plan.files.size(), static_cast(1)); REQUIRE(plan.files[0].render_with_prebyte); @@ -195,7 +180,7 @@ TEST_CASE(BuildPlanner_excludes_layout_marked_entries_and_keeps_pbt_extension_wh REQUIRE(!plan.files[0].render_with_prebyte); bool has_skip_dir = false; - for (const auto& directory : plan.directories) { + for (const auto &directory : plan.directories) { if (directory.filename() == "skip_dir") { has_skip_dir = true; } diff --git a/tests/tempify/CliParserTests.cpp b/tests/tempify/CliParserTests.cpp index e1388d1..a921e7c 100644 --- a/tests/tempify/CliParserTests.cpp +++ b/tests/tempify/CliParserTests.cpp @@ -1,5 +1,4 @@ #include "TestHarness.h" - #include "tempify/cli/CliParser.h" #include "tempify/domain/CliRequest.h" #include "tempify/support/Errors.h" @@ -105,7 +104,8 @@ TEST_CASE(CliParser_parses_q_refresh_prebyte_info_doctor_validate_inspect_lint_t REQUIRE(list_json.test_json); REQUIRE(list_json.test_list_fixtures); - const tempify::CliRequest update = parser.parse({"test", "basic_cpp", "--update-snapshots", "--fixture", "ci_enabled"}); + const tempify::CliRequest update = + parser.parse({"test", "basic_cpp", "--update-snapshots", "--fixture", "ci_enabled"}); REQUIRE(update.mode == tempify::CliMode::TemplateTest); REQUIRE(update.test_update_snapshots); REQUIRE(update.test_fixture_name.has_value()); @@ -124,16 +124,21 @@ TEST_CASE(CliParser_parses_q_refresh_prebyte_info_doctor_validate_inspect_lint_t const tempify::CliRequest render = parser.parse({ "basic_cpp", "out-dir", - "--set", "project_name=App", - "--var", "namespace=core", + "--set", + "project_name=App", + "--var", + "namespace=core", "-f", - "--accept-hooks", "ask", - "--answers", "answers.json", + "--accept-hooks", + "ask", + "--answers", + "answers.json", "--non-interactive", "--strict", "--diff", "--json", - "--hook-timeout-ms", "250", + "--hook-timeout-ms", + "250", "--tui", }); REQUIRE(render.mode == tempify::CliMode::TemplateRender); @@ -162,7 +167,8 @@ TEST_CASE(CliParser_parses_q_refresh_prebyte_info_doctor_validate_inspect_lint_t "out-dir", "--reapply", "--json", - "--set", "project_name=App", + "--set", + "project_name=App", }); REQUIRE(reapply.mode == tempify::CliMode::TemplateRender); REQUIRE(reapply.reapply); @@ -175,7 +181,8 @@ TEST_CASE(CliParser_parses_q_refresh_prebyte_info_doctor_validate_inspect_lint_t "--reapply", "--report", "--json", - "--set", "project_name=App", + "--set", + "project_name=App", }); REQUIRE(reapply_report.mode == tempify::CliMode::TemplateRender); REQUIRE(reapply_report.reapply); @@ -187,7 +194,8 @@ TEST_CASE(CliParser_parses_q_refresh_prebyte_info_doctor_validate_inspect_lint_t "basic_cpp", "out-dir", "--json", - "--set", "project_name=App", + "--set", + "project_name=App", }); REQUIRE(reapply_subcommand.mode == tempify::CliMode::TemplateRender); REQUIRE_EQ(reapply_subcommand.template_ref, std::string("basic_cpp")); @@ -317,7 +325,8 @@ TEST_CASE(CliParser_rejects_invalid_assignments_unknown_options_and_extra_target REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "--questions"}), tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "-f", "-s"}), tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "--accept-hooks", "maybe"}), tempify::TempifyError); - REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "--no-hooks", "--accept-hooks", "yes"}), tempify::TempifyError); + REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "--no-hooks", "--accept-hooks", "yes"}), + tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "--hook-timeout-ms", "-1"}), tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "--json"}), tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "--diff", "--plan-json"}), tempify::TempifyError); @@ -326,9 +335,12 @@ TEST_CASE(CliParser_rejects_invalid_assignments_unknown_options_and_extra_target REQUIRE_THROWS_AS(parser.parse({"reapply"}), tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"reapply", "basic_cpp"}), tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "--report"}), tempify::TempifyError); - REQUIRE_THROWS_AS(parser.parse({"basic_cpp", "out-dir", "--reapply", "--report", "--write-answers", "answers.json"}), tempify::TempifyError); + REQUIRE_THROWS_AS( + parser.parse({"basic_cpp", "out-dir", "--reapply", "--report", "--write-answers", "answers.json"}), + tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"reapply", "basic_cpp", "out-dir", "--diff"}), tempify::TempifyError); - REQUIRE_THROWS_AS(parser.parse({"reapply", "basic_cpp", "out-dir", "--report", "--write-answers", "answers.json"}), tempify::TempifyError); + REQUIRE_THROWS_AS(parser.parse({"reapply", "basic_cpp", "out-dir", "--report", "--write-answers", "answers.json"}), + tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"doctor", "--wat"}), tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"completion"}), tempify::TempifyError); REQUIRE_THROWS_AS(parser.parse({"completion", "pwsh"}), tempify::TempifyError); diff --git a/tests/tempify/LocalTemplateStoreTests.cpp b/tests/tempify/LocalTemplateStoreTests.cpp index f9700c2..81f7486 100644 --- a/tests/tempify/LocalTemplateStoreTests.cpp +++ b/tests/tempify/LocalTemplateStoreTests.cpp @@ -1,11 +1,10 @@ #include "TempifyTestSupport.h" - #include "tempify/config/TempifyConfig.h" +#include "tempify/hook/HookTrustStore.h" +#include "tempify/lua/LuaEngine.h" #include "tempify/store/AvailableTemplateCache.h" #include "tempify/store/LocalTemplateStore.h" #include "tempify/support/Errors.h" -#include "tempify/lua/LuaEngine.h" -#include "tempify/hook/HookTrustStore.h" #include "tempify/support/Paths.h" #include "tempify/template/TemplateLoader.h" @@ -13,14 +12,14 @@ #include namespace { -using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::create_basic_template_at; using tempify::test_support::create_shared_template; using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::write_available_template_cache; using tempify::test_support::write_text_file; -} +} // namespace TEST_CASE(Paths_resolve_tempify_data_root_prefers_xdg_and_workspace_templates_root_is_optional) { ScopedDirectoryCleanup data_home(std::filesystem::temp_directory_path() / "tempify-paths-data-home-test"); @@ -90,16 +89,10 @@ TEST_CASE(LocalTemplateStore_refresh_writes_index_and_lists_shared_templates) { TEST_CASE(LocalTemplateStore_refresh_rejects_duplicate_shared_template_ids) { ScopedDirectoryCleanup shared_root(std::filesystem::temp_directory_path() / "tempify-store-duplicate-refresh-test"); - static_cast(create_basic_template_at(shared_root.path() / "templates" / "alpha", - "duplicate_tpl", - "Alpha", - "1.0.0", - "Alpha entry")); - static_cast(create_basic_template_at(shared_root.path() / "templates" / "beta", - "duplicate_tpl", - "Beta", - "1.0.0", - "Beta entry")); + static_cast(create_basic_template_at(shared_root.path() / "templates" / "alpha", "duplicate_tpl", "Alpha", + "1.0.0", "Alpha entry")); + static_cast(create_basic_template_at(shared_root.path() / "templates" / "beta", "duplicate_tpl", "Beta", + "1.0.0", "Beta entry")); tempify::LuaEngine lua_engine; tempify::TemplateLoader loader(lua_engine); @@ -118,27 +111,26 @@ TEST_CASE(AvailableTemplateCache_missing_file_returns_empty) { TEST_CASE(AvailableTemplateCache_loads_records_from_reqpack_cache) { ScopedDirectoryCleanup shared_root(std::filesystem::temp_directory_path() / "tempify-available-cache-load-test"); - write_available_template_cache(shared_root.path(), - "{\n" - " \"schemaVersion\": 1,\n" - " \"templates\": [\n" - " {\n" - " \"id\": \"java-xyz\",\n" - " \"name\": \"Java XYZ\",\n" - " \"description\": \"Small Java starter template\",\n" - " \"version\": \"0.1.0\",\n" - " \"tags\": [\"java\", \"starter\"],\n" - " \"source\": {\n" - " \"url\": \"../tempify-templates\",\n" - " \"ref\": \"main\",\n" - " \"subdir\": \"java-xyz\"\n" - " },\n" - " \"repository\": {\n" - " \"id\": \"local-registry\"\n" - " }\n" - " }\n" - " ]\n" - "}\n"); + write_available_template_cache(shared_root.path(), "{\n" + " \"schemaVersion\": 1,\n" + " \"templates\": [\n" + " {\n" + " \"id\": \"java-xyz\",\n" + " \"name\": \"Java XYZ\",\n" + " \"description\": \"Small Java starter template\",\n" + " \"version\": \"0.1.0\",\n" + " \"tags\": [\"java\", \"starter\"],\n" + " \"source\": {\n" + " \"url\": \"../tempify-templates\",\n" + " \"ref\": \"main\",\n" + " \"subdir\": \"java-xyz\"\n" + " },\n" + " \"repository\": {\n" + " \"id\": \"local-registry\"\n" + " }\n" + " }\n" + " ]\n" + "}\n"); tempify::AvailableTemplateCache cache(shared_root.path()); const auto entries = cache.list_templates(); @@ -153,28 +145,26 @@ TEST_CASE(TempifyConfig_load_and_merge_support_defaults_and_render_overlays) { const std::filesystem::path global_path = root.path() / "global.json"; const std::filesystem::path workspace_path = root.path() / "workspace.json"; - write_text_file(global_path, - "{\n" - " \"defaults\": {\n" - " \"project_name\": \"Global App\",\n" - " \"include_ci\": false\n" - " },\n" - " \"render\": {\n" - " \"accept_hooks\": \"no\",\n" - " \"hook_timeout_ms\": 1234\n" - " }\n" - "}\n"); - write_text_file(workspace_path, - "{\n" - " \"defaults\": {\n" - " \"project_name\": \"Workspace App\",\n" - " \"author\": \"Workspace Author\"\n" - " },\n" - " \"render\": {\n" - " \"accept_hooks\": \"yes\",\n" - " \"existing_path_behavior\": \"skip\"\n" - " }\n" - "}\n"); + write_text_file(global_path, "{\n" + " \"defaults\": {\n" + " \"project_name\": \"Global App\",\n" + " \"include_ci\": false\n" + " },\n" + " \"render\": {\n" + " \"accept_hooks\": \"no\",\n" + " \"hook_timeout_ms\": 1234\n" + " }\n" + "}\n"); + write_text_file(workspace_path, "{\n" + " \"defaults\": {\n" + " \"project_name\": \"Workspace App\",\n" + " \"author\": \"Workspace Author\"\n" + " },\n" + " \"render\": {\n" + " \"accept_hooks\": \"yes\",\n" + " \"existing_path_behavior\": \"skip\"\n" + " }\n" + "}\n"); const tempify::TempifyConfig global = tempify::load_tempify_config_file(global_path); const tempify::TempifyConfig workspace = tempify::load_tempify_config_file(workspace_path); @@ -194,10 +184,9 @@ TEST_CASE(TempifyConfig_load_and_merge_support_defaults_and_render_overlays) { TEST_CASE(TempifyConfig_unknown_keys_throw) { ScopedDirectoryCleanup root(std::filesystem::temp_directory_path() / "tempify-config-unknown-key-test"); const std::filesystem::path config_path = root.path() / "bad.json"; - write_text_file(config_path, - "{\n" - " \"mystery\": true\n" - "}\n"); + write_text_file(config_path, "{\n" + " \"mystery\": true\n" + "}\n"); REQUIRE_THROWS_AS(tempify::load_tempify_config_file(config_path), tempify::TempifyError); } diff --git a/tests/tempify/LuaEngineManifestAndHookTests.cpp b/tests/tempify/LuaEngineManifestAndHookTests.cpp index a77a28d..07b21d3 100644 --- a/tests/tempify/LuaEngineManifestAndHookTests.cpp +++ b/tests/tempify/LuaEngineManifestAndHookTests.cpp @@ -1,6 +1,5 @@ -#include "TestHarness.h" #include "TempifyTestSupport.h" - +#include "TestHarness.h" #include "tempify/lua/LuaEngine.h" #include "tempify/prebyte/PrebyteRenderer.h" #include "tempify/support/Errors.h" @@ -16,9 +15,8 @@ namespace { class ScopedDirectoryCleanup { -public: - explicit ScopedDirectoryCleanup(std::filesystem::path path) - : path_(std::move(path)) { + public: + explicit ScopedDirectoryCleanup(std::filesystem::path path) : path_(std::move(path)) { std::filesystem::remove_all(path_); } @@ -26,32 +24,33 @@ class ScopedDirectoryCleanup { std::filesystem::remove_all(path_); } - const std::filesystem::path& path() const noexcept { + const std::filesystem::path &path() const noexcept { return path_; } -private: + private: std::filesystem::path path_; }; -std::string read_text_file(const std::filesystem::path& path) { +std::string read_text_file(const std::filesystem::path &path) { std::ifstream input(path, std::ios::binary); std::ostringstream stream; stream << input.rdbuf(); return stream.str(); } -void write_text_file(const std::filesystem::path& path, const std::string& text) { +void write_text_file(const std::filesystem::path &path, const std::string &text) { std::filesystem::create_directories(path.parent_path()); std::ofstream output(path, std::ios::binary); output << text; } -} +} // namespace TEST_CASE(LuaEngine_load_partial_manifest_loads_layout_scripts_env_and_hooks) { tempify::LuaEngine lua_engine; - const tempify::TemplateManifest manifest = lua_engine.load_partial_manifest(tempify::test_support::test_template_path("advanced_hooks_layout")); + const tempify::TemplateManifest manifest = + lua_engine.load_partial_manifest(tempify::test_support::test_template_path("advanced_hooks_layout")); REQUIRE_EQ(manifest.info.id, std::string("advanced_hooks_layout")); REQUIRE_EQ(manifest.layout_rules.size(), static_cast(3)); @@ -68,13 +67,15 @@ TEST_CASE(LuaEngine_load_partial_manifest_loads_layout_scripts_env_and_hooks) { TEST_CASE(LuaEngine_load_template_info_and_ignores_pbc_artifacts) { tempify::LuaEngine lua_engine; - const tempify::TemplateInfo info = lua_engine.load_template_info(tempify::test_support::test_template_path("basic_cpp")); - const tempify::TemplateManifest manifest = lua_engine.load_partial_manifest(tempify::test_support::test_template_path("basic_cpp")); + const tempify::TemplateInfo info = + lua_engine.load_template_info(tempify::test_support::test_template_path("basic_cpp")); + const tempify::TemplateManifest manifest = + lua_engine.load_partial_manifest(tempify::test_support::test_template_path("basic_cpp")); REQUIRE_EQ(info.id, std::string("basic_cpp")); REQUIRE_EQ(info.name, std::string("Basic C++ App")); - for (const auto& file : manifest.files) { + for (const auto &file : manifest.files) { REQUIRE(file.relative_path.ends_with(".pbc") == false); } } @@ -84,7 +85,8 @@ TEST_CASE(LuaEngine_run_hook_supports_file_ops_process_string_process_file_and_s std::filesystem::create_directories(build_root.path()); tempify::LuaEngine lua_engine; - const tempify::TemplateManifest manifest = lua_engine.load_partial_manifest(tempify::test_support::test_template_path("advanced_hooks_layout")); + const tempify::TemplateManifest manifest = + lua_engine.load_partial_manifest(tempify::test_support::test_template_path("advanced_hooks_layout")); const tempify::PrebyteRenderer renderer; const tempify::BuildContext context{ .template_root = manifest.root, @@ -95,22 +97,23 @@ TEST_CASE(LuaEngine_run_hook_supports_file_ops_process_string_process_file_and_s const std::filesystem::path hook_path = build_root.path() / "hook.lua"; { std::ofstream output(hook_path, std::ios::binary); - output - << "mkdir('nested')\n" - << "write_file('nested/raw.txt', process_string('Name={{ project_name }}'))\n" - << "process_file('files/docs/readme.txt.pbt', 'nested/readme.md')\n" - << "copy('files/raw/static.txt.pbt', 'nested/copied.txt')\n" - << "script('create_marker')\n" - << "if not exists('nested/readme.md') then error('missing readme') end\n" - << "local files = list_files('nested')\n" - << "write_file('nested/count.txt', tostring(#files))\n"; + output << "mkdir('nested')\n" + << "write_file('nested/raw.txt', process_string('Name={{ project_name }}'))\n" + << "process_file('files/docs/readme.txt.pbt', 'nested/readme.md')\n" + << "copy('files/raw/static.txt.pbt', 'nested/copied.txt')\n" + << "script('create_marker')\n" + << "if not exists('nested/readme.md') then error('missing readme') end\n" + << "local files = list_files('nested')\n" + << "write_file('nested/count.txt', tostring(#files))\n"; } lua_engine.run_hook(hook_path, manifest, context, renderer); REQUIRE_EQ(read_text_file(build_root.path() / "nested" / "raw.txt"), std::string("Name=Hook Demo")); REQUIRE(read_text_file(build_root.path() / "nested" / "readme.md").find("# Hook Demo") != std::string::npos); - REQUIRE(read_text_file(build_root.path() / "nested" / "copied.txt").find("This should stay raw: {{ project_name }}") != std::string::npos); + REQUIRE( + read_text_file(build_root.path() / "nested" / "copied.txt").find("This should stay raw: {{ project_name }}") != + std::string::npos); REQUIRE_EQ(read_text_file(build_root.path() / "script-marker.txt"), std::string("script-ran\n")); REQUIRE_EQ(read_text_file(build_root.path() / "nested" / "count.txt"), std::string("3")); } @@ -120,7 +123,8 @@ TEST_CASE(LuaEngine_run_hook_unknown_script_throws) { std::filesystem::create_directories(build_root.path()); tempify::LuaEngine lua_engine; - const tempify::TemplateManifest manifest = lua_engine.load_partial_manifest(tempify::test_support::test_template_path("advanced_hooks_layout")); + const tempify::TemplateManifest manifest = + lua_engine.load_partial_manifest(tempify::test_support::test_template_path("advanced_hooks_layout")); const tempify::PrebyteRenderer renderer; const tempify::BuildContext context{ .template_root = manifest.root, @@ -139,7 +143,8 @@ TEST_CASE(LuaEngine_run_hook_exec_respects_timeout) { std::filesystem::create_directories(build_root.path()); tempify::LuaEngine lua_engine; - const tempify::TemplateManifest manifest = lua_engine.load_partial_manifest(tempify::test_support::test_template_path("advanced_hooks_layout")); + const tempify::TemplateManifest manifest = + lua_engine.load_partial_manifest(tempify::test_support::test_template_path("advanced_hooks_layout")); const tempify::PrebyteRenderer renderer; const tempify::BuildContext context{ .template_root = manifest.root, @@ -158,7 +163,7 @@ TEST_CASE(LuaEngine_run_hook_exec_respects_timeout) { try { lua_engine.run_hook(hook_path, manifest, context, renderer, std::chrono::milliseconds(25)); REQUIRE(false); - } catch (const tempify::TempifyError& error) { + } catch (const tempify::TempifyError &error) { REQUIRE(std::string(error.what()).find("timed out after 25 ms") != std::string::npos); #ifdef _WIN32 REQUIRE(std::string(error.what()).find("running command: ping -n 2 127.0.0.1 > nul") != std::string::npos); @@ -170,9 +175,7 @@ TEST_CASE(LuaEngine_run_hook_exec_respects_timeout) { TEST_CASE(LuaEngine_load_partial_manifest_throws_for_missing_source_dir) { ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-lua-missing-source-dir-test"); - write_text_file( - workspace.path() / "template.lua", - "return { id = 'broken_tpl', source_dir = 'files' }\n"); + write_text_file(workspace.path() / "template.lua", "return { id = 'broken_tpl', source_dir = 'files' }\n"); tempify::LuaEngine lua_engine; REQUIRE_THROWS_AS(lua_engine.load_partial_manifest(workspace.path()), tempify::TempifyError); @@ -184,10 +187,13 @@ TEST_CASE(LuaEngine_load_partial_manifest_throws_for_invalid_layout_and_question std::filesystem::create_directories(layout_workspace.path() / "files"); write_text_file(layout_workspace.path() / "layout.lua", "return { 'bad' }\n"); - ScopedDirectoryCleanup question_workspace(std::filesystem::temp_directory_path() / "tempify-lua-invalid-questions-test"); - write_text_file(question_workspace.path() / "template.lua", "return { id = 'question_bad', source_dir = 'files' }\n"); + ScopedDirectoryCleanup question_workspace(std::filesystem::temp_directory_path() / + "tempify-lua-invalid-questions-test"); + write_text_file(question_workspace.path() / "template.lua", + "return { id = 'question_bad', source_dir = 'files' }\n"); std::filesystem::create_directories(question_workspace.path() / "files"); - write_text_file(question_workspace.path() / "questions.lua", "return { order = { 'Project' }, groups = { Project = 'bad' } }\n"); + write_text_file(question_workspace.path() / "questions.lua", + "return { order = { 'Project' }, groups = { Project = 'bad' } }\n"); tempify::LuaEngine lua_engine; REQUIRE_THROWS_AS(lua_engine.load_partial_manifest(layout_workspace.path()), tempify::TempifyError); diff --git a/tests/tempify/LuaEngineQuestionTests.cpp b/tests/tempify/LuaEngineQuestionTests.cpp index 2eceebd..697cd11 100644 --- a/tests/tempify/LuaEngineQuestionTests.cpp +++ b/tests/tempify/LuaEngineQuestionTests.cpp @@ -1,6 +1,5 @@ -#include "TestHarness.h" #include "TempifyTestSupport.h" - +#include "TestHarness.h" #include "tempify/lua/LuaEngine.h" #include @@ -9,8 +8,8 @@ namespace { -tempify::QuestionDefinition find_question(const tempify::TemplateManifest& manifest, const std::string& key) { - for (const auto& question : manifest.questions) { +tempify::QuestionDefinition find_question(const tempify::TemplateManifest &manifest, const std::string &key) { + for (const auto &question : manifest.questions) { if (question.key == key) { return question; } @@ -23,7 +22,7 @@ tempify::TemplateManifest load_basic_cpp_manifest() { return lua_engine.load_partial_manifest(tempify::test_support::test_template_path("basic_cpp")); } -} +} // namespace TEST_CASE(LuaEngine_evaluate_default_condition_and_validate_for_questions) { tempify::LuaEngine lua_engine; diff --git a/tests/tempify/QuestionProcessorTests.cpp b/tests/tempify/QuestionProcessorTests.cpp index 70f5830..42fa33a 100644 --- a/tests/tempify/QuestionProcessorTests.cpp +++ b/tests/tempify/QuestionProcessorTests.cpp @@ -1,6 +1,5 @@ -#include "TestHarness.h" #include "TempifyTestSupport.h" - +#include "TestHarness.h" #include "tempify/frontend/IQuestionFrontend.h" #include "tempify/lua/LuaEngine.h" #include "tempify/question/QuestionProcessor.h" @@ -17,11 +16,10 @@ namespace { class StubFrontend final : public tempify::IQuestionFrontend { -public: - explicit StubFrontend(std::vector answers) - : answers_(std::move(answers)) {} + public: + explicit StubFrontend(std::vector answers) : answers_(std::move(answers)) {} - std::optional prompt(const std::string& text, const bool sensitive = false) override { + std::optional prompt(const std::string &text, const bool sensitive = false) override { prompts.push_back(text); sensitive_prompts.push_back(sensitive); if (index_ >= answers_.size()) { @@ -37,11 +35,11 @@ class StubFrontend final : public tempify::IQuestionFrontend { return tempify::PromptResult{.action = tempify::FrontendAction::Submit, .value = value}; } - void write_line(const std::string& text) override { + void write_line(const std::string &text) override { lines.push_back(text); } - void begin_group(const std::string& name, const std::size_t index, const std::size_t total) override { + void begin_group(const std::string &name, const std::size_t index, const std::size_t total) override { groups.push_back(name + ":" + std::to_string(index) + "/" + std::to_string(total)); } @@ -55,7 +53,7 @@ class StubFrontend final : public tempify::IQuestionFrontend { std::vector groups; int ends = 0; -private: + private: std::vector answers_; std::size_t index_ = 0; }; @@ -65,7 +63,7 @@ tempify::TemplateManifest load_basic_cpp_manifest() { return lua_engine.load_partial_manifest(tempify::test_support::test_template_path("basic_cpp")); } -} +} // namespace TEST_CASE(QuestionProcessor_collect_prompt_loop_and_condition) { const tempify::TemplateManifest manifest = load_basic_cpp_manifest(); @@ -124,15 +122,13 @@ TEST_CASE(QuestionProcessor_cli_values_bypass_prompts_and_validate) { StubFrontend frontend({}); tempify::QuestionProcessor processor(lua_engine, frontend); - const std::map values = processor.collect( - manifest, - { - {"project_name", "CLI App"}, - {"project_slug", "cli-app"}, - {"namespace", "cli_ns"}, - {"include_ci", "false"}, - {"author", "Leodoras"}, - }); + const std::map values = processor.collect(manifest, { + {"project_name", "CLI App"}, + {"project_slug", "cli-app"}, + {"namespace", "cli_ns"}, + {"include_ci", "false"}, + {"author", "Leodoras"}, + }); REQUIRE_EQ(values.at("project_name"), std::string("CLI App")); REQUIRE_EQ(values.at("project_slug"), std::string("cli-app")); @@ -148,16 +144,14 @@ TEST_CASE(QuestionProcessor_invalid_cli_value_fails_validation) { tempify::QuestionProcessor processor(lua_engine, frontend); - REQUIRE_THROWS_AS( - processor.collect( - manifest, - { - {"project_name", "Ok Name"}, - {"project_slug", "Bad Slug"}, - {"namespace", "cli_ns"}, - {"include_ci", "false"}, - }), - tempify::TempifyError); + REQUIRE_THROWS_AS(processor.collect(manifest, + { + {"project_name", "Ok Name"}, + {"project_slug", "Bad Slug"}, + {"namespace", "cli_ns"}, + {"include_ci", "false"}, + }), + tempify::TempifyError); } TEST_CASE(QuestionProcessor_manual_int_help_and_optional_skip) { @@ -203,14 +197,12 @@ TEST_CASE(QuestionProcessor_alias_cli_value_maps_to_canonical_key) { StubFrontend frontend({}); tempify::QuestionProcessor processor(lua_engine, frontend); - const std::map values = processor.collect( - manifest, - { - {"project_name", "Alias App"}, - {"name_slug", "alias-slug"}, - {"namespace", "alias_ns"}, - {"include_ci", "false"}, - }); + const std::map values = processor.collect(manifest, { + {"project_name", "Alias App"}, + {"name_slug", "alias-slug"}, + {"namespace", "alias_ns"}, + {"include_ci", "false"}, + }); REQUIRE_EQ(values.at("project_slug"), std::string("alias-slug")); REQUIRE_EQ(values.at("name_slug"), std::string("alias-slug")); @@ -222,16 +214,15 @@ TEST_CASE(QuestionProcessor_hidden_cli_values_are_ignored_when_condition_false) StubFrontend frontend({}); tempify::QuestionProcessor processor(lua_engine, frontend); - const std::map values = processor.collect( - manifest, - { - {"project_name", "Alias App"}, - {"project_slug", "alias-app"}, - {"namespace", "alias_ns"}, - {"include_ci", "false"}, - {"ci_provider", "gitlab"}, - {"docs_url", "https://docs.example"}, - }); + const std::map values = + processor.collect(manifest, { + {"project_name", "Alias App"}, + {"project_slug", "alias-app"}, + {"namespace", "alias_ns"}, + {"include_ci", "false"}, + {"ci_provider", "gitlab"}, + {"docs_url", "https://docs.example"}, + }); REQUIRE(values.find("ci_provider") == values.end()); REQUIRE(values.find("docs_url") == values.end()); @@ -283,14 +274,8 @@ TEST_CASE(QuestionProcessor_review_stage_can_go_back_and_confirm) { }); tempify::QuestionProcessor processor(lua_engine, frontend); - const std::map values = processor.collect( - manifest, - {{"author", "Leodoras"}}, - {}, - {}, - false, - false, - true); + const std::map values = + processor.collect(manifest, {{"author", "Leodoras"}}, {}, {}, false, false, true); REQUIRE_EQ(values.at("project_name"), std::string("Second App")); REQUIRE_EQ(values.at("project_slug"), std::string("second-app")); @@ -328,14 +313,7 @@ TEST_CASE(QuestionProcessor_sensitive_questions_redact_review_defaults_and_mark_ }); tempify::QuestionProcessor processor(lua_engine, frontend); - const std::map values = processor.collect( - manifest, - {}, - {}, - {}, - false, - false, - true); + const std::map values = processor.collect(manifest, {}, {}, {}, false, false, true); REQUIRE_EQ(values.at("api_token"), std::string("secret-default")); REQUIRE_EQ(values.at("project_name"), std::string("Stone App")); @@ -351,17 +329,15 @@ TEST_CASE(QuestionProcessor_conflicting_alias_and_canonical_cli_values_throw) { tempify::QuestionProcessor processor(lua_engine, frontend); - REQUIRE_THROWS_AS( - processor.collect( - manifest, - { - {"project_name", "Alias App"}, - {"project_slug", "slug-a"}, - {"name_slug", "slug-b"}, - {"namespace", "alias_ns"}, - {"include_ci", "false"}, - }), - tempify::TempifyError); + REQUIRE_THROWS_AS(processor.collect(manifest, + { + {"project_name", "Alias App"}, + {"project_slug", "slug-a"}, + {"name_slug", "slug-b"}, + {"namespace", "alias_ns"}, + {"include_ci", "false"}, + }), + tempify::TempifyError); } TEST_CASE(QuestionProcessor_cli_bool_and_choice_numeric_values_are_coerced) { @@ -370,16 +346,15 @@ TEST_CASE(QuestionProcessor_cli_bool_and_choice_numeric_values_are_coerced) { StubFrontend frontend({}); tempify::QuestionProcessor processor(lua_engine, frontend); - const std::map values = processor.collect( - manifest, - { - {"project_name", "Numeric Choice App"}, - {"project_slug", "numeric-choice-app"}, - {"namespace", "numeric_ns"}, - {"include_ci", "1"}, - {"ci_provider", "2"}, - {"docs_url", ""}, - }); + const std::map values = + processor.collect(manifest, { + {"project_name", "Numeric Choice App"}, + {"project_slug", "numeric-choice-app"}, + {"namespace", "numeric_ns"}, + {"include_ci", "1"}, + {"ci_provider", "2"}, + {"docs_url", ""}, + }); REQUIRE_EQ(values.at("include_ci"), std::string("true")); REQUIRE_EQ(values.at("ci_provider"), std::string("gitlab")); @@ -427,36 +402,22 @@ TEST_CASE(QuestionProcessor_config_defaults_override_env_but_answers_file_and_cl StubFrontend default_frontend({""}); tempify::QuestionProcessor default_processor(lua_engine, default_frontend); - const std::map config_default_values = default_processor.collect( - manifest, - {}, - {{"flavor", "config-default"}}, - {}, - false, - false); + const std::map config_default_values = + default_processor.collect(manifest, {}, {{"flavor", "config-default"}}, {}, false, false); REQUIRE_EQ(config_default_values.at("flavor"), std::string("config-default")); REQUIRE_EQ(default_frontend.prompts.at(0), std::string("Flavor (default: config-default): ")); StubFrontend imported_frontend({}); tempify::QuestionProcessor imported_processor(lua_engine, imported_frontend); const std::map answer_over_config = imported_processor.collect( - manifest, - {}, - {{"flavor", "config-default"}}, - {{"flavor", "answer-default"}}, - true, - false); + manifest, {}, {{"flavor", "config-default"}}, {{"flavor", "answer-default"}}, true, false); REQUIRE_EQ(answer_over_config.at("flavor"), std::string("answer-default")); StubFrontend cli_frontend({}); tempify::QuestionProcessor cli_processor(lua_engine, cli_frontend); - const std::map cli_over_config = cli_processor.collect( - manifest, - {{"flavor", "cli-default"}}, - {{"flavor", "config-default"}}, - {{"flavor", "answer-default"}}, - true, - false); + const std::map cli_over_config = + cli_processor.collect(manifest, {{"flavor", "cli-default"}}, {{"flavor", "config-default"}}, + {{"flavor", "answer-default"}}, true, false); REQUIRE_EQ(cli_over_config.at("flavor"), std::string("cli-default")); } @@ -466,16 +427,13 @@ TEST_CASE(QuestionProcessor_imported_values_bypass_prompts_and_support_aliases) StubFrontend frontend({}); tempify::QuestionProcessor processor(lua_engine, frontend); - const std::map values = processor.collect( - manifest, - {}, - {}, - { - {"project_name", "Imported App"}, - {"name_slug", "imported-app"}, - {"namespace", "imported_ns"}, - {"include_ci", "false"}, - }); + const std::map values = processor.collect(manifest, {}, {}, + { + {"project_name", "Imported App"}, + {"name_slug", "imported-app"}, + {"namespace", "imported_ns"}, + {"include_ci", "false"}, + }); REQUIRE_EQ(values.at("project_name"), std::string("Imported App")); REQUIRE_EQ(values.at("project_slug"), std::string("imported-app")); @@ -537,9 +495,8 @@ TEST_CASE(QuestionProcessor_strict_rejects_unknown_imported_keys) { StubFrontend frontend({}); tempify::QuestionProcessor processor(lua_engine, frontend); - REQUIRE_THROWS_AS( - processor.collect(manifest, {}, {}, {{"mystery_key", "value"}}, false, true), - tempify::TempifyError); + REQUIRE_THROWS_AS(processor.collect(manifest, {}, {}, {{"mystery_key", "value"}}, false, true), + tempify::TempifyError); } TEST_CASE(QuestionProcessor_quit_aborts_collection) { @@ -573,9 +530,7 @@ TEST_CASE(QuestionProcessor_invalid_choice_configuration_and_unknown_type_throw) .type = "choice", }, }; - REQUIRE_THROWS_AS( - processor.collect(missing_choices_manifest, {{"provider", "1"}}), - tempify::TempifyError); + REQUIRE_THROWS_AS(processor.collect(missing_choices_manifest, {{"provider", "1"}}), tempify::TempifyError); tempify::TemplateManifest unknown_type_manifest; unknown_type_manifest.question_group_order = {"General"}; @@ -585,7 +540,5 @@ TEST_CASE(QuestionProcessor_invalid_choice_configuration_and_unknown_type_throw) .type = "float", }, }; - REQUIRE_THROWS_AS( - processor.collect(unknown_type_manifest, {{"ratio", "1.5"}}), - tempify::TempifyError); + REQUIRE_THROWS_AS(processor.collect(unknown_type_manifest, {{"ratio", "1.5"}}), tempify::TempifyError); } diff --git a/tests/tempify/SharedTemplateStoreIntegrationTests.cpp b/tests/tempify/SharedTemplateStoreIntegrationTests.cpp index 60074e0..54330d8 100644 --- a/tests/tempify/SharedTemplateStoreIntegrationTests.cpp +++ b/tests/tempify/SharedTemplateStoreIntegrationTests.cpp @@ -4,18 +4,19 @@ #include namespace { +using tempify::test_support::create_shared_template; +using tempify::test_support::read_text_file; using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::ScopedStdoutCapture; using tempify::test_support::ScopedTempifyDataHome; -using tempify::test_support::create_shared_template; -using tempify::test_support::read_text_file; -} +} // namespace TEST_CASE(TempifyApp_refresh_indexes_shared_templates_and_renders_them) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-store-render-data-home"); ScopedDirectoryCleanup output_root(std::filesystem::temp_directory_path() / "tempify-shared-store-render-output"); - create_shared_template(data_home.shared_root(), "shared_cpp", "Shared Template", "1.0.0", "From shared store", "Installed from shared store."); + create_shared_template(data_home.shared_root(), "shared_cpp", "Shared Template", "1.0.0", "From shared store", + "Installed from shared store."); tempify::TempifyApp app; @@ -49,32 +50,42 @@ TEST_CASE(TempifyApp_refresh_indexes_shared_templates_and_renders_them) { const std::filesystem::path generated = output_root.path() / "from-shared"; REQUIRE_EQ(app.run({ - "shared_cpp", - generated.string(), - "--set", "project_name=Shared App", - "--set", "project_slug=shared-app", - }), 0); + "shared_cpp", + generated.string(), + "--set", + "project_name=Shared App", + "--set", + "project_slug=shared-app", + }), + 0); REQUIRE(read_text_file(generated / "README.md").find("Installed from shared store.") != std::string::npos); } TEST_CASE(TempifyApp_workspace_template_overrides_shared_template_with_same_id) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-store-override-data-home"); ScopedDirectoryCleanup output_root(std::filesystem::temp_directory_path() / "tempify-shared-store-override-output"); - create_shared_template(data_home.shared_root(), "basic_cpp", "Shared Basic", "1.0.0", "From shared store", "FROM SHARED STORE"); + create_shared_template(data_home.shared_root(), "basic_cpp", "Shared Basic", "1.0.0", "From shared store", + "FROM SHARED STORE"); tempify::TempifyApp app; REQUIRE_EQ(app.run({"refresh"}), 0); const std::filesystem::path generated = output_root.path() / "workspace-wins"; REQUIRE_EQ(app.run({ - "basic_cpp", - generated.string(), - "--set", "project_name=Workspace App", - "--set", "name_slug=workspace-app", - "--set", "namespace=workspace_ns", - "--set", "include_ci=false", - "--set", "author=Tester", - }), 0); + "basic_cpp", + generated.string(), + "--set", + "project_name=Workspace App", + "--set", + "name_slug=workspace-app", + "--set", + "namespace=workspace_ns", + "--set", + "include_ci=false", + "--set", + "author=Tester", + }), + 0); const std::string readme = read_text_file(generated / "README.md"); REQUIRE(readme.find("FROM SHARED STORE") == std::string::npos); @@ -82,8 +93,10 @@ TEST_CASE(TempifyApp_workspace_template_overrides_shared_template_with_same_id) } TEST_CASE(TempifyApp_list_info_and_catalog_prefer_workspace_template_over_shared_template) { - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-store-catalog-override-data-home"); - create_shared_template(data_home.shared_root(), "basic_cpp", "Shared Basic", "9.9.9", "Shared desc", "FROM SHARED STORE"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-shared-store-catalog-override-data-home"); + create_shared_template(data_home.shared_root(), "basic_cpp", "Shared Basic", "9.9.9", "Shared desc", + "FROM SHARED STORE"); tempify::TempifyApp app; REQUIRE_EQ(app.run({"refresh"}), 0); @@ -107,7 +120,8 @@ TEST_CASE(TempifyApp_list_info_and_catalog_prefer_workspace_template_over_shared } TEST_CASE(TempifyApp_stale_shared_index_entry_missing_on_disk_is_not_listed_and_info_errors_cleanly) { - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-store-stale-index-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-shared-store-stale-index-data-home"); create_shared_template(data_home.shared_root(), "ghost_tpl", "Ghost Template", "1.0.0", "Ghost desc"); tempify::TempifyApp app; diff --git a/tests/tempify/TempifyAppCliCatalogIntegrationTests.cpp b/tests/tempify/TempifyAppCliCatalogIntegrationTests.cpp index 336ae18..9179a86 100644 --- a/tests/tempify/TempifyAppCliCatalogIntegrationTests.cpp +++ b/tests/tempify/TempifyAppCliCatalogIntegrationTests.cpp @@ -5,15 +5,15 @@ namespace { +using tempify::test_support::create_basic_template_at; using tempify::test_support::ScopedCurrentPath; using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::ScopedStdoutCapture; using tempify::test_support::ScopedTempifyDataHome; -using tempify::test_support::create_basic_template_at; using tempify::test_support::write_available_template_cache; using tempify::test_support::write_text_file; -} +} // namespace TEST_CASE(TempifyApp_refresh_creates_shared_index_without_templates) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-refresh-empty-data-home"); @@ -336,13 +336,11 @@ TEST_CASE(TempifyApp_completion_outputs_shell_scripts) { TEST_CASE(TempifyApp_broken_workspace_template_gets_skipped_while_valid_template_remains_visible) { ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-app-broken-workspace-skip"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-broken-workspace-skip-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-broken-workspace-skip-data-home"); std::filesystem::create_directories(workspace.path() / "templates"); static_cast(create_basic_template_at(workspace.path() / "templates" / "visible_workspace", - "visible_workspace", - "Visible Workspace", - "1.0.0", - "Visible desc", + "visible_workspace", "Visible Workspace", "1.0.0", "Visible desc", "VISIBLE WORKSPACE\n")); std::filesystem::create_directories(workspace.path() / "templates" / "broken_workspace"); write_text_file(workspace.path() / "templates" / "broken_workspace" / "questions.lua", "return {}\n"); @@ -366,18 +364,13 @@ TEST_CASE(TempifyApp_broken_workspace_template_gets_skipped_while_valid_template TEST_CASE(TempifyApp_duplicate_workspace_template_ids_are_rejected) { ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-app-duplicate-workspace-id"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-duplicate-workspace-id-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-duplicate-workspace-id-data-home"); std::filesystem::create_directories(workspace.path() / "templates"); - static_cast(create_basic_template_at(workspace.path() / "templates" / "alpha", - "dup_workspace", - "Alpha", - "1.0.0", - "Alpha desc")); - static_cast(create_basic_template_at(workspace.path() / "templates" / "beta", - "dup_workspace", - "Beta", - "1.0.0", - "Beta desc")); + static_cast(create_basic_template_at(workspace.path() / "templates" / "alpha", "dup_workspace", "Alpha", + "1.0.0", "Alpha desc")); + static_cast(create_basic_template_at(workspace.path() / "templates" / "beta", "dup_workspace", "Beta", + "1.0.0", "Beta desc")); ScopedCurrentPath cwd(workspace.path()); tempify::TempifyApp app; @@ -385,28 +378,28 @@ TEST_CASE(TempifyApp_duplicate_workspace_template_ids_are_rejected) { } TEST_CASE(TempifyApp_list_json_includes_available_cached_templates) { - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-available-cache-list-data-home"); - write_available_template_cache(data_home.shared_root(), - "{\n" - " \"schemaVersion\": 1,\n" - " \"templates\": [\n" - " {\n" - " \"id\": \"java-xyz\",\n" - " \"name\": \"Java XYZ\",\n" - " \"description\": \"Small Java starter template\",\n" - " \"version\": \"0.1.0\",\n" - " \"tags\": [\"java\", \"starter\"],\n" - " \"source\": {\n" - " \"type\": \"git\",\n" - " \"url\": \"../tempify-templates\",\n" - " \"subdir\": \"java-xyz\"\n" - " },\n" - " \"repository\": {\n" - " \"id\": \"local-registry\"\n" - " }\n" - " }\n" - " ]\n" - "}\n"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-available-cache-list-data-home"); + write_available_template_cache(data_home.shared_root(), "{\n" + " \"schemaVersion\": 1,\n" + " \"templates\": [\n" + " {\n" + " \"id\": \"java-xyz\",\n" + " \"name\": \"Java XYZ\",\n" + " \"description\": \"Small Java starter template\",\n" + " \"version\": \"0.1.0\",\n" + " \"tags\": [\"java\", \"starter\"],\n" + " \"source\": {\n" + " \"type\": \"git\",\n" + " \"url\": \"../tempify-templates\",\n" + " \"subdir\": \"java-xyz\"\n" + " },\n" + " \"repository\": {\n" + " \"id\": \"local-registry\"\n" + " }\n" + " }\n" + " ]\n" + "}\n"); tempify::TempifyApp app; ScopedStdoutCapture capture; @@ -420,19 +413,19 @@ TEST_CASE(TempifyApp_list_json_includes_available_cached_templates) { } TEST_CASE(TempifyApp_list_prefers_workspace_template_over_available_cache_duplicate) { - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-available-cache-override-data-home"); - write_available_template_cache(data_home.shared_root(), - "{\n" - " \"schemaVersion\": 1,\n" - " \"templates\": [\n" - " {\n" - " \"id\": \"basic_cpp\",\n" - " \"name\": \"Cached Basic\",\n" - " \"description\": \"Cached desc\",\n" - " \"version\": \"9.9.9\"\n" - " }\n" - " ]\n" - "}\n"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-available-cache-override-data-home"); + write_available_template_cache(data_home.shared_root(), "{\n" + " \"schemaVersion\": 1,\n" + " \"templates\": [\n" + " {\n" + " \"id\": \"basic_cpp\",\n" + " \"name\": \"Cached Basic\",\n" + " \"description\": \"Cached desc\",\n" + " \"version\": \"9.9.9\"\n" + " }\n" + " ]\n" + "}\n"); tempify::TempifyApp app; ScopedStdoutCapture capture; @@ -446,28 +439,28 @@ TEST_CASE(TempifyApp_list_prefers_workspace_template_over_available_cache_duplic } TEST_CASE(TempifyApp_info_json_falls_back_to_available_cache_metadata) { - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-available-cache-info-data-home"); - write_available_template_cache(data_home.shared_root(), - "{\n" - " \"schemaVersion\": 1,\n" - " \"templates\": [\n" - " {\n" - " \"id\": \"java-xyz\",\n" - " \"name\": \"Java XYZ\",\n" - " \"description\": \"Small Java starter template\",\n" - " \"version\": \"0.1.0\",\n" - " \"source\": {\n" - " \"type\": \"git\",\n" - " \"url\": \"../tempify-templates\",\n" - " \"ref\": \"main\",\n" - " \"subdir\": \"java-xyz\"\n" - " },\n" - " \"repository\": {\n" - " \"id\": \"local-registry\"\n" - " }\n" - " }\n" - " ]\n" - "}\n"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-available-cache-info-data-home"); + write_available_template_cache(data_home.shared_root(), "{\n" + " \"schemaVersion\": 1,\n" + " \"templates\": [\n" + " {\n" + " \"id\": \"java-xyz\",\n" + " \"name\": \"Java XYZ\",\n" + " \"description\": \"Small Java starter template\",\n" + " \"version\": \"0.1.0\",\n" + " \"source\": {\n" + " \"type\": \"git\",\n" + " \"url\": \"../tempify-templates\",\n" + " \"ref\": \"main\",\n" + " \"subdir\": \"java-xyz\"\n" + " },\n" + " \"repository\": {\n" + " \"id\": \"local-registry\"\n" + " }\n" + " }\n" + " ]\n" + "}\n"); tempify::TempifyApp app; ScopedStdoutCapture capture; diff --git a/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp b/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp index 3ed6793..e4df087 100644 --- a/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp +++ b/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp @@ -5,39 +5,47 @@ namespace { -using tempify::test_support::ScopedCurrentPath; -using tempify::test_support::ScopedDirectoryCleanup; -using tempify::test_support::ScopedStdoutCapture; -using tempify::test_support::ScopedTempifyConfigHome; -using tempify::test_support::ScopedTempifyDataHome; using tempify::test_support::create_basic_template_at; using tempify::test_support::create_required_only_template; using tempify::test_support::create_sensitive_template; using tempify::test_support::json_escaped_path; using tempify::test_support::link_test_templates_into_workspace; using tempify::test_support::read_text_file; +using tempify::test_support::ScopedCurrentPath; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedStdoutCapture; +using tempify::test_support::ScopedTempifyConfigHome; +using tempify::test_support::ScopedTempifyDataHome; using tempify::test_support::write_text_file; -} +} // namespace TEST_CASE(TempifyApp_answers_file_round_trip_supports_non_interactive_render) { ScopedDirectoryCleanup answers_root(std::filesystem::temp_directory_path() / "tempify-app-answers-roundtrip-root"); ScopedDirectoryCleanup first_target(std::filesystem::temp_directory_path() / "tempify-app-answers-roundtrip-first"); - ScopedDirectoryCleanup second_target(std::filesystem::temp_directory_path() / "tempify-app-answers-roundtrip-second"); + ScopedDirectoryCleanup second_target(std::filesystem::temp_directory_path() / + "tempify-app-answers-roundtrip-second"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-answers-roundtrip-data-home"); const std::filesystem::path answers_file = answers_root.path() / "answers.json"; tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - first_target.path().string(), - "--set", "project_name=Round Trip App", - "--set", "name_slug=round-trip-app", - "--set", "namespace=round_trip_ns", - "--set", "include_ci=false", - "--set", "author=Round Trip Tester", - "--write-answers", answers_file.string(), - }), 0); + "basic_cpp", + first_target.path().string(), + "--set", + "project_name=Round Trip App", + "--set", + "name_slug=round-trip-app", + "--set", + "namespace=round_trip_ns", + "--set", + "include_ci=false", + "--set", + "author=Round Trip Tester", + "--write-answers", + answers_file.string(), + }), + 0); const std::string answer_text = read_text_file(answers_file); REQUIRE(answer_text.find("project_name") != std::string::npos); @@ -45,33 +53,38 @@ TEST_CASE(TempifyApp_answers_file_round_trip_supports_non_interactive_render) { REQUIRE(answer_text.find("name_slug") == std::string::npos); REQUIRE_EQ(app.run({ - "basic_cpp", - second_target.path().string(), - "--answers", answers_file.string(), - "--set", "author=Round Trip Tester", - "--non-interactive", - "--strict", - }), 0); + "basic_cpp", + second_target.path().string(), + "--answers", + answers_file.string(), + "--set", + "author=Round Trip Tester", + "--non-interactive", + "--strict", + }), + 0); REQUIRE(read_text_file(second_target.path() / "README.md").find("# Round Trip App") != std::string::npos); - REQUIRE(read_text_file(second_target.path() / ".tempify-summary.txt").find("slug=round-trip-app") != std::string::npos); + REQUIRE(read_text_file(second_target.path() / ".tempify-summary.txt").find("slug=round-trip-app") != + std::string::npos); } TEST_CASE(TempifyApp_non_interactive_missing_required_answer_throws) { - ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-non-interactive-required-template"); + ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / + "tempify-app-non-interactive-required-template"); ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-non-interactive-missing"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-non-interactive-missing-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-non-interactive-missing-data-home"); const std::filesystem::path template_path = create_required_only_template(template_root.path()); tempify::TempifyApp app; - REQUIRE_THROWS_AS( - app.run({ - template_path.string(), - target.path().string(), - "--non-interactive", - "--strict", - }), - tempify::TempifyError); + REQUIRE_THROWS_AS(app.run({ + template_path.string(), + target.path().string(), + "--non-interactive", + "--strict", + }), + tempify::TempifyError); } TEST_CASE(TempifyApp_strict_rejects_unknown_answers_file_keys) { @@ -82,16 +95,17 @@ TEST_CASE(TempifyApp_strict_rejects_unknown_answers_file_keys) { write_text_file(answers_file, "{\n \"project_name\": \"Strict App\",\n \"mystery\": \"value\"\n}\n"); tempify::TempifyApp app; - REQUIRE_THROWS_AS( - app.run({ - "basic_cpp", - target.path().string(), - "--answers", answers_file.string(), - "--set", "author=Strict Tester", - "--non-interactive", - "--strict", - }), - tempify::TempifyError); + REQUIRE_THROWS_AS(app.run({ + "basic_cpp", + target.path().string(), + "--answers", + answers_file.string(), + "--set", + "author=Strict Tester", + "--non-interactive", + "--strict", + }), + tempify::TempifyError); } TEST_CASE(TempifyApp_config_hierarchy_uses_global_and_workspace_defaults_but_answers_and_cli_override_them) { @@ -104,47 +118,47 @@ TEST_CASE(TempifyApp_config_hierarchy_uses_global_and_workspace_defaults_but_ans std::filesystem::create_directories(workspace.path()); link_test_templates_into_workspace(workspace.path()); std::filesystem::create_directories(workspace.path() / ".tempify"); - write_text_file(config_home.path() / "tempify" / "config.json", - "{\n" - " \"defaults\": {\n" - " \"project_name\": \"Global App\",\n" - " \"namespace\": \"global_ns\",\n" - " \"include_ci\": false,\n" - " \"author\": \"Global Author\"\n" - " },\n" - " \"render\": {\n" - " \"accept_hooks\": \"no\",\n" - " \"hook_timeout_ms\": 1234\n" - " }\n" - "}\n"); - write_text_file(workspace.path() / ".tempify" / "config.json", - "{\n" - " \"defaults\": {\n" - " \"project_name\": \"Workspace App\",\n" - " \"namespace\": \"workspace_ns\",\n" - " \"author\": \"Workspace Author\"\n" - " },\n" - " \"render\": {\n" - " \"accept_hooks\": \"yes\",\n" - " \"existing_path_behavior\": \"skip\"\n" - " }\n" - "}\n"); - write_text_file(answers_file, - "{\n" - " \"project_name\": \"Answer App\",\n" - " \"namespace\": \"answer_ns\"\n" - "}\n"); + write_text_file(config_home.path() / "tempify" / "config.json", "{\n" + " \"defaults\": {\n" + " \"project_name\": \"Global App\",\n" + " \"namespace\": \"global_ns\",\n" + " \"include_ci\": false,\n" + " \"author\": \"Global Author\"\n" + " },\n" + " \"render\": {\n" + " \"accept_hooks\": \"no\",\n" + " \"hook_timeout_ms\": 1234\n" + " }\n" + "}\n"); + write_text_file(workspace.path() / ".tempify" / "config.json", "{\n" + " \"defaults\": {\n" + " \"project_name\": \"Workspace App\",\n" + " \"namespace\": \"workspace_ns\",\n" + " \"author\": \"Workspace Author\"\n" + " },\n" + " \"render\": {\n" + " \"accept_hooks\": \"yes\",\n" + " \"existing_path_behavior\": \"skip\"\n" + " }\n" + "}\n"); + write_text_file(answers_file, "{\n" + " \"project_name\": \"Answer App\",\n" + " \"namespace\": \"answer_ns\"\n" + "}\n"); ScopedCurrentPath cwd(workspace.path()); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--answers", answers_file.string(), - "--set", "author=CLI Author", - "--non-interactive", - "--strict", - }), 0); + "basic_cpp", + target.path().string(), + "--answers", + answers_file.string(), + "--set", + "author=CLI Author", + "--non-interactive", + "--strict", + }), + 0); const std::string readme = read_text_file(target.path() / "README.md"); const std::string summary = read_text_file(target.path() / ".tempify-summary.txt"); @@ -162,46 +176,39 @@ TEST_CASE(TempifyApp_config_hierarchy_uses_global_and_workspace_defaults_but_ans TEST_CASE(TempifyApp_nested_workspace_cwd_uses_nearest_templates_and_config) { ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-app-nested-workspace-cwd"); ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-nested-workspace-cwd-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-nested-workspace-cwd-data-home"); - ScopedTempifyConfigHome config_home(std::filesystem::temp_directory_path() / "tempify-app-nested-workspace-cwd-config-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-nested-workspace-cwd-data-home"); + ScopedTempifyConfigHome config_home(std::filesystem::temp_directory_path() / + "tempify-app-nested-workspace-cwd-config-home"); const std::filesystem::path outer = workspace.path() / "outer"; const std::filesystem::path inner = outer / "apps" / "inner"; const std::filesystem::path nested = inner / "deep" / "src"; std::filesystem::create_directories(nested); - static_cast(create_basic_template_at(outer / "templates" / "nested_app", - "nested_app", - "Outer Template", - "1.0.0", - "Outer desc", - "OUTER TEMPLATE\n")); - static_cast(create_basic_template_at(inner / "templates" / "nested_app", - "nested_app", - "Inner Template", - "1.0.0", - "Inner desc", - "INNER TEMPLATE\n")); - write_text_file(outer / ".tempify" / "config.json", - "{\n" - " \"defaults\": {\n" - " \"project_name\": \"Outer Config App\"\n" - " }\n" - "}\n"); - write_text_file(inner / ".tempify" / "config.json", - "{\n" - " \"defaults\": {\n" - " \"project_name\": \"Inner Config App\"\n" - " }\n" - "}\n"); + static_cast(create_basic_template_at(outer / "templates" / "nested_app", "nested_app", "Outer Template", + "1.0.0", "Outer desc", "OUTER TEMPLATE\n")); + static_cast(create_basic_template_at(inner / "templates" / "nested_app", "nested_app", "Inner Template", + "1.0.0", "Inner desc", "INNER TEMPLATE\n")); + write_text_file(outer / ".tempify" / "config.json", "{\n" + " \"defaults\": {\n" + " \"project_name\": \"Outer Config App\"\n" + " }\n" + "}\n"); + write_text_file(inner / ".tempify" / "config.json", "{\n" + " \"defaults\": {\n" + " \"project_name\": \"Inner Config App\"\n" + " }\n" + "}\n"); ScopedCurrentPath cwd(nested); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "nested_app", - target.path().string(), - "--non-interactive", - "--strict", - }), 0); + "nested_app", + target.path().string(), + "--non-interactive", + "--strict", + }), + 0); const std::string readme = read_text_file(target.path() / "README.md"); REQUIRE(readme.find("# Inner Config App") != std::string::npos); @@ -216,13 +223,17 @@ TEST_CASE(TempifyApp_dry_run_outputs_plan_and_writes_no_files) { ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "advanced_hooks_layout", - target.path().string(), - "--set", "project_name=Dry Run App", - "--set", "project_slug=dry-run-app", - "--set", "use_notes=false", - "--dry-run", - }), 0); + "advanced_hooks_layout", + target.path().string(), + "--set", + "project_name=Dry Run App", + "--set", + "project_slug=dry-run-app", + "--set", + "use_notes=false", + "--dry-run", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("Build root:") != std::string::npos); @@ -240,15 +251,21 @@ TEST_CASE(TempifyApp_plan_json_outputs_json_and_writes_no_files) { ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Plan App", - "--set", "name_slug=plan-app", - "--set", "namespace=plan_ns", - "--set", "include_ci=false", - "--set", "author=Planner", - "--plan-json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Plan App", + "--set", + "name_slug=plan-app", + "--set", + "namespace=plan_ns", + "--set", + "include_ci=false", + "--set", + "author=Planner", + "--plan-json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"build_root\"") != std::string::npos); @@ -264,14 +281,20 @@ TEST_CASE(TempifyApp_render_writes_generation_lock_file) { tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Lock App", - "--set", "name_slug=lock-app", - "--set", "namespace=lock_ns", - "--set", "include_ci=false", - "--set", "author=Locker", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Lock App", + "--set", + "name_slug=lock-app", + "--set", + "namespace=lock_ns", + "--set", + "include_ci=false", + "--set", + "author=Locker", + }), + 0); const std::string lock = read_text_file(target.path() / ".tempify-lock.json"); REQUIRE(lock.find("\"tempify_version\"") != std::string::npos); @@ -288,11 +311,14 @@ TEST_CASE(TempifyApp_render_redacts_sensitive_values_in_generation_lock) { tempify::TempifyApp app; REQUIRE_EQ(app.run({ - template_root.string(), - target.path().string(), - "--set", "project_name=Secure App", - "--set", "api_token=super-secret-token", - }), 0); + template_root.string(), + target.path().string(), + "--set", + "project_name=Secure App", + "--set", + "api_token=super-secret-token", + }), + 0); const std::string lock = read_text_file(target.path() / ".tempify-lock.json"); REQUIRE(lock.find("\"api_token\": \"\"") != std::string::npos); diff --git a/tests/tempify/TempifyAppDiffIntegrationTests.cpp b/tests/tempify/TempifyAppDiffIntegrationTests.cpp index 3ffb182..aabd1a8 100644 --- a/tests/tempify/TempifyAppDiffIntegrationTests.cpp +++ b/tests/tempify/TempifyAppDiffIntegrationTests.cpp @@ -5,14 +5,14 @@ namespace { +using tempify::test_support::create_required_only_template_with_version; +using tempify::test_support::read_text_file; using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::ScopedStdoutCapture; using tempify::test_support::ScopedTempifyDataHome; -using tempify::test_support::create_required_only_template_with_version; -using tempify::test_support::read_text_file; using tempify::test_support::write_text_file; -} +} // namespace TEST_CASE(TempifyApp_diff_reports_managed_changes_without_mutation) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-diff-target"); @@ -20,14 +20,20 @@ TEST_CASE(TempifyApp_diff_reports_managed_changes_without_mutation) { tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Diff App", - "--set", "name_slug=diff-app", - "--set", "namespace=diff_ns", - "--set", "include_ci=false", - "--set", "author=Diff Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Diff App", + "--set", + "name_slug=diff-app", + "--set", + "namespace=diff_ns", + "--set", + "include_ci=false", + "--set", + "author=Diff Tester", + }), + 0); const std::string original_main = read_text_file(target.path() / "src" / "main.cpp"); write_text_file(target.path() / "README.md", "# user edited\n"); @@ -35,15 +41,21 @@ TEST_CASE(TempifyApp_diff_reports_managed_changes_without_mutation) { ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Diff App", - "--set", "name_slug=diff-app", - "--set", "namespace=diff_ns", - "--set", "include_ci=false", - "--set", "author=Diff Tester", - "--diff", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Diff App", + "--set", + "name_slug=diff-app", + "--set", + "namespace=diff_ns", + "--set", + "include_ci=false", + "--set", + "author=Diff Tester", + "--diff", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("Diff ") != std::string::npos); @@ -71,28 +83,40 @@ TEST_CASE(TempifyApp_diff_json_outputs_machine_readable_report) { tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Diff Json App", - "--set", "name_slug=diff-json-app", - "--set", "namespace=diff_json_ns", - "--set", "include_ci=false", - "--set", "author=Diff Json Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Diff Json App", + "--set", + "name_slug=diff-json-app", + "--set", + "namespace=diff_json_ns", + "--set", + "include_ci=false", + "--set", + "author=Diff Json Tester", + }), + 0); write_text_file(target.path() / "README.md", "# changed\n"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Diff Json App", - "--set", "name_slug=diff-json-app", - "--set", "namespace=diff_json_ns", - "--set", "include_ci=false", - "--set", "author=Diff Json Tester", - "--diff", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Diff Json App", + "--set", + "name_slug=diff-json-app", + "--set", + "namespace=diff_json_ns", + "--set", + "include_ci=false", + "--set", + "author=Diff Json Tester", + "--diff", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"build_root\": ") != std::string::npos); @@ -112,31 +136,44 @@ TEST_CASE(TempifyApp_diff_json_outputs_machine_readable_report) { TEST_CASE(TempifyApp_diff_classifies_template_updates_from_generation_baseline) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-diff-template-update-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-diff-template-update-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-diff-template-update-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Template Update App", - "--set", "name_slug=template-update-app", - "--set", "namespace=template_update_ns", - "--set", "include_ci=false", - "--set", "author=Old Author", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Template Update App", + "--set", + "name_slug=template-update-app", + "--set", + "namespace=template_update_ns", + "--set", + "include_ci=false", + "--set", + "author=Old Author", + }), + 0); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Template Update App", - "--set", "name_slug=template-update-app", - "--set", "namespace=template_update_ns", - "--set", "include_ci=false", - "--set", "author=New Author", - "--diff", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Template Update App", + "--set", + "name_slug=template-update-app", + "--set", + "namespace=template_update_ns", + "--set", + "include_ci=false", + "--set", + "author=New Author", + "--diff", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"template\": {") != std::string::npos); @@ -153,28 +190,40 @@ TEST_CASE(TempifyApp_diff_classifies_conflicts_when_local_and_desired_both_diver tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Conflict App", - "--set", "name_slug=conflict-app", - "--set", "namespace=conflict_ns", - "--set", "include_ci=false", - "--set", "author=Old Author", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Conflict App", + "--set", + "name_slug=conflict-app", + "--set", + "namespace=conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Old Author", + }), + 0); write_text_file(target.path() / "README.md", "# local change\n"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Conflict App", - "--set", "name_slug=conflict-app", - "--set", "namespace=conflict_ns", - "--set", "include_ci=false", - "--set", "author=New Author", - "--diff", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Conflict App", + "--set", + "name_slug=conflict-app", + "--set", + "namespace=conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=New Author", + "--diff", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"reapply\": {") != std::string::npos); @@ -190,52 +239,63 @@ TEST_CASE(TempifyApp_diff_detects_delete_candidates_from_lockfile) { tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Diff Delete App", - "--set", "name_slug=diff-delete-app", - "--set", "namespace=diff_delete_ns", - "--set", "include_ci=false", - "--set", "author=Diff Delete Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Diff Delete App", + "--set", + "name_slug=diff-delete-app", + "--set", + "namespace=diff_delete_ns", + "--set", + "include_ci=false", + "--set", + "author=Diff Delete Tester", + }), + 0); write_text_file(target.path() / "old-managed.txt", "stale\n"); - write_text_file(target.path() / ".tempify-lock.json", - "{\n" - " \"tempify_version\": \"v0.1.2\",\n" - " \"template\": {\n" - " \"id\": \"basic_cpp\",\n" - " \"name\": \"Basic C++ App\",\n" - " \"version\": \"0.1.0\",\n" - " \"root\": \"/tmp/template\"\n" - " },\n" - " \"build_root\": \"/tmp/output\",\n" - " \"generated_at\": \"2026-05-17T00:00:00Z\",\n" - " \"existing_path_behavior\": \"error\",\n" - " \"hook_acceptance\": \"yes\",\n" - " \"hooks_disabled\": false,\n" - " \"managed_files\": [\n" - " \"CMakeLists.txt\",\n" - " \"README.md\",\n" - " \"old-managed.txt\",\n" - " \"src/main.cpp\"\n" - " ],\n" - " \"values\": {\n" - " \"project_name\": \"Diff Delete App\"\n" - " }\n" - "}\n"); + write_text_file(target.path() / ".tempify-lock.json", "{\n" + " \"tempify_version\": \"v0.1.2\",\n" + " \"template\": {\n" + " \"id\": \"basic_cpp\",\n" + " \"name\": \"Basic C++ App\",\n" + " \"version\": \"0.1.0\",\n" + " \"root\": \"/tmp/template\"\n" + " },\n" + " \"build_root\": \"/tmp/output\",\n" + " \"generated_at\": \"2026-05-17T00:00:00Z\",\n" + " \"existing_path_behavior\": \"error\",\n" + " \"hook_acceptance\": \"yes\",\n" + " \"hooks_disabled\": false,\n" + " \"managed_files\": [\n" + " \"CMakeLists.txt\",\n" + " \"README.md\",\n" + " \"old-managed.txt\",\n" + " \"src/main.cpp\"\n" + " ],\n" + " \"values\": {\n" + " \"project_name\": \"Diff Delete App\"\n" + " }\n" + "}\n"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Diff Delete App", - "--set", "name_slug=diff-delete-app", - "--set", "namespace=diff_delete_ns", - "--set", "include_ci=false", - "--set", "author=Diff Delete Tester", - "--diff", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Diff Delete App", + "--set", + "name_slug=diff-delete-app", + "--set", + "namespace=diff_delete_ns", + "--set", + "include_ci=false", + "--set", + "author=Diff Delete Tester", + "--diff", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("Delete: 1") != std::string::npos); @@ -245,31 +305,44 @@ TEST_CASE(TempifyApp_diff_detects_delete_candidates_from_lockfile) { TEST_CASE(TempifyApp_diff_surfaces_origin_template_mismatch_for_reapply) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-diff-origin-mismatch-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-diff-origin-mismatch-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-diff-origin-mismatch-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "layered_cpp_product", - target.path().string(), - "--set", "project_name=Origin Mismatch App", - "--set", "project_slug=origin-mismatch-app", - "--set", "language_standard=c++23", - "--set", "include_ci=true", - "--set", "ci_provider=github", - }), 0); + "layered_cpp_product", + target.path().string(), + "--set", + "project_name=Origin Mismatch App", + "--set", + "project_slug=origin-mismatch-app", + "--set", + "language_standard=c++23", + "--set", + "include_ci=true", + "--set", + "ci_provider=github", + }), + 0); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Origin Mismatch App", - "--set", "name_slug=origin-mismatch-app", - "--set", "namespace=origin_mismatch_ns", - "--set", "include_ci=false", - "--set", "author=Origin Tester", - "--diff", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Origin Mismatch App", + "--set", + "name_slug=origin-mismatch-app", + "--set", + "namespace=origin_mismatch_ns", + "--set", + "include_ci=false", + "--set", + "author=Origin Tester", + "--diff", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"origin\": {") != std::string::npos); @@ -284,18 +357,25 @@ TEST_CASE(TempifyApp_diff_surfaces_origin_template_mismatch_for_reapply) { TEST_CASE(TempifyApp_diff_surfaces_pre_1_0_minor_upgrade_policy_for_update_workflow) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-diff-version-change-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-diff-version-change-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-diff-version-change-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Version Change App", - "--set", "name_slug=version-change-app", - "--set", "namespace=version_change_ns", - "--set", "include_ci=false", - "--set", "author=Version Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Version Change App", + "--set", + "name_slug=version-change-app", + "--set", + "namespace=version_change_ns", + "--set", + "include_ci=false", + "--set", + "author=Version Tester", + }), + 0); std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); const std::string old_version = "\"version\": \"0.1.0\""; @@ -306,16 +386,22 @@ TEST_CASE(TempifyApp_diff_surfaces_pre_1_0_minor_upgrade_policy_for_update_workf ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Version Change App", - "--set", "name_slug=version-change-app", - "--set", "namespace=version_change_ns", - "--set", "include_ci=false", - "--set", "author=Version Tester", - "--diff", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Version Change App", + "--set", + "name_slug=version-change-app", + "--set", + "namespace=version_change_ns", + "--set", + "include_ci=false", + "--set", + "author=Version Tester", + "--diff", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"matches_requested_template\": true") != std::string::npos); @@ -332,17 +418,24 @@ TEST_CASE(TempifyApp_diff_surfaces_pre_1_0_minor_upgrade_policy_for_update_workf TEST_CASE(TempifyApp_diff_treats_semver_prerelease_release_transition_as_upgrade) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-diff-semver-prerelease-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-diff-semver-prerelease-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-diff-semver-prerelease-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Semver Prerelease App", - "--set", "name_slug=semver-prerelease-app", - "--set", "namespace=semver_prerelease_ns", - "--set", "include_ci=false", - "--set", "author=Semver Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Semver Prerelease App", + "--set", + "name_slug=semver-prerelease-app", + "--set", + "namespace=semver_prerelease_ns", + "--set", + "include_ci=false", + "--set", + "author=Semver Tester", + }), + 0); std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); const std::string old_version = "\"version\": \"0.1.0\""; @@ -353,16 +446,22 @@ TEST_CASE(TempifyApp_diff_treats_semver_prerelease_release_transition_as_upgrade ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Semver Prerelease App", - "--set", "name_slug=semver-prerelease-app", - "--set", "namespace=semver_prerelease_ns", - "--set", "include_ci=false", - "--set", "author=Semver Tester", - "--diff", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Semver Prerelease App", + "--set", + "name_slug=semver-prerelease-app", + "--set", + "namespace=semver_prerelease_ns", + "--set", + "include_ci=false", + "--set", + "author=Semver Tester", + "--diff", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"kind\": \"upgrade\"") != std::string::npos); @@ -376,14 +475,20 @@ TEST_CASE(TempifyApp_diff_treats_semver_build_metadata_change_as_equivalent_vers ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-diff-semver-build-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Semver Build App", - "--set", "name_slug=semver-build-app", - "--set", "namespace=semver_build_ns", - "--set", "include_ci=false", - "--set", "author=Semver Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Semver Build App", + "--set", + "name_slug=semver-build-app", + "--set", + "namespace=semver_build_ns", + "--set", + "include_ci=false", + "--set", + "author=Semver Tester", + }), + 0); std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); const std::string old_version = "\"version\": \"0.1.0\""; @@ -394,16 +499,22 @@ TEST_CASE(TempifyApp_diff_treats_semver_build_metadata_change_as_equivalent_vers ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Semver Build App", - "--set", "name_slug=semver-build-app", - "--set", "namespace=semver_build_ns", - "--set", "include_ci=false", - "--set", "author=Semver Tester", - "--diff", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Semver Build App", + "--set", + "name_slug=semver-build-app", + "--set", + "namespace=semver_build_ns", + "--set", + "include_ci=false", + "--set", + "author=Semver Tester", + "--diff", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"matches_requested_version\": false") != std::string::npos); @@ -417,14 +528,17 @@ TEST_CASE(TempifyApp_diff_surfaces_major_upgrade_policy_review) { ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-major-upgrade-template"); ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-major-upgrade-target"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-major-upgrade-data-home"); - const std::filesystem::path template_path = create_required_only_template_with_version(template_root.path(), "1.0.0"); + const std::filesystem::path template_path = + create_required_only_template_with_version(template_root.path(), "1.0.0"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - template_path.string(), - target.path().string(), - "--set", "required_name=Major Upgrade App", - }), 0); + template_path.string(), + target.path().string(), + "--set", + "required_name=Major Upgrade App", + }), + 0); std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); const std::string current_version = "\"version\": \"1.0.0\""; @@ -435,12 +549,14 @@ TEST_CASE(TempifyApp_diff_surfaces_major_upgrade_policy_review) { ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - template_path.string(), - target.path().string(), - "--set", "required_name=Major Upgrade App", - "--diff", - "--json", - }), 0); + template_path.string(), + target.path().string(), + "--set", + "required_name=Major Upgrade App", + "--diff", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"kind\": \"upgrade\"") != std::string::npos); diff --git a/tests/tempify/TempifyAppHooksIntegrationTests.cpp b/tests/tempify/TempifyAppHooksIntegrationTests.cpp index 3373d7f..93326d1 100644 --- a/tests/tempify/TempifyAppHooksIntegrationTests.cpp +++ b/tests/tempify/TempifyAppHooksIntegrationTests.cpp @@ -5,16 +5,16 @@ namespace { +using tempify::test_support::create_slow_hook_template; +using tempify::test_support::json_escaped_path; +using tempify::test_support::read_text_file; using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::ScopedStdinCapture; using tempify::test_support::ScopedStdoutCapture; using tempify::test_support::ScopedTempifyDataHome; -using tempify::test_support::create_slow_hook_template; -using tempify::test_support::json_escaped_path; -using tempify::test_support::read_text_file; using tempify::test_support::test_template_path; -} +} // namespace TEST_CASE(TempifyApp_no_hooks_skips_hook_side_effects) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-no-hooks-test"); @@ -24,10 +24,14 @@ TEST_CASE(TempifyApp_no_hooks_skips_hook_side_effects) { const int result = app.run({ "advanced_hooks_layout", target.path().string(), - "--accept-hooks", "no", - "--set", "project_name=No Hooks", - "--set", "project_slug=no-hooks", - "--set", "use_notes=false", + "--accept-hooks", + "no", + "--set", + "project_name=No Hooks", + "--set", + "project_slug=no-hooks", + "--set", + "use_notes=false", }); REQUIRE_EQ(result, 0); @@ -48,10 +52,14 @@ TEST_CASE(TempifyApp_accept_hooks_ask_without_tty_runs_hooks) { const int result = app.run({ "advanced_hooks_layout", target.path().string(), - "--accept-hooks", "ask", - "--set", "project_name=Ask Hooks", - "--set", "project_slug=ask-hooks", - "--set", "use_notes=false", + "--accept-hooks", + "ask", + "--set", + "project_name=Ask Hooks", + "--set", + "project_slug=ask-hooks", + "--set", + "use_notes=false", }); REQUIRE_EQ(result, 0); @@ -72,13 +80,18 @@ TEST_CASE(TempifyApp_accept_hooks_ask_tty_shows_summary_and_persists_trust) { ScopedStdinCapture input("yes\n"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "advanced_hooks_layout", - target.path().string(), - "--accept-hooks", "ask", - "--set", "project_name=Trusted Hooks", - "--set", "project_slug=trusted-hooks", - "--set", "use_notes=false", - }), 0); + "advanced_hooks_layout", + target.path().string(), + "--accept-hooks", + "ask", + "--set", + "project_name=Trusted Hooks", + "--set", + "project_slug=trusted-hooks", + "--set", + "use_notes=false", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("Template defines hooks:") != std::string::npos); REQUIRE(output.find("- pre: ") != std::string::npos); @@ -90,21 +103,29 @@ TEST_CASE(TempifyApp_accept_hooks_ask_tty_shows_summary_and_persists_trust) { const std::filesystem::path trust_store = data_home.shared_root() / "trust" / "hooks.json"; REQUIRE(std::filesystem::is_regular_file(trust_store)); - REQUIRE(read_text_file(trust_store).find(json_escaped_path(std::filesystem::weakly_canonical(test_template_path("advanced_hooks_layout")))) != std::string::npos); + REQUIRE(read_text_file(trust_store) + .find(json_escaped_path(std::filesystem::weakly_canonical( + test_template_path("advanced_hooks_layout")))) != std::string::npos); { - ScopedDirectoryCleanup second_target(std::filesystem::temp_directory_path() / "tempify-app-hook-trust-target-2"); + ScopedDirectoryCleanup second_target(std::filesystem::temp_directory_path() / + "tempify-app-hook-trust-target-2"); ScopedStdinCapture input(""); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "advanced_hooks_layout", - second_target.path().string(), - "--accept-hooks", "ask", - "--set", "project_name=Trusted Hooks Again", - "--set", "project_slug=trusted-hooks-again", - "--set", "use_notes=false", - "--tui", - }), 0); + "advanced_hooks_layout", + second_target.path().string(), + "--accept-hooks", + "ask", + "--set", + "project_name=Trusted Hooks Again", + "--set", + "project_slug=trusted-hooks-again", + "--set", + "use_notes=false", + "--tui", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("Template hooks trusted from previous approval.") != std::string::npos); REQUIRE(output.find("Run hooks and trust this template next time?") == std::string::npos); @@ -122,11 +143,13 @@ TEST_CASE(TempifyApp_render_hook_timeout_flag_aborts_slow_hook_with_phase_diagno static_cast(app.run({ template_root.string(), target.path().string(), - "--hook-timeout-ms", "25", - "--set", "project_name=Slow App", + "--hook-timeout-ms", + "25", + "--set", + "project_name=Slow App", })); REQUIRE(false); - } catch (const tempify::TempifyError& error) { + } catch (const tempify::TempifyError &error) { REQUIRE(std::string(error.what()).find("Hook phase 'post' failed") != std::string::npos); REQUIRE(std::string(error.what()).find("post.lua") != std::string::npos); REQUIRE(std::string(error.what()).find("timed out after 25 ms") != std::string::npos); diff --git a/tests/tempify/TempifyAppQuestionsTuiIntegrationTests.cpp b/tests/tempify/TempifyAppQuestionsTuiIntegrationTests.cpp index 8931fc9..9abf654 100644 --- a/tests/tempify/TempifyAppQuestionsTuiIntegrationTests.cpp +++ b/tests/tempify/TempifyAppQuestionsTuiIntegrationTests.cpp @@ -5,14 +5,14 @@ namespace { +using tempify::test_support::create_sensitive_template; +using tempify::test_support::read_text_file; using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::ScopedStdinCapture; using tempify::test_support::ScopedStdoutCapture; using tempify::test_support::ScopedTempifyDataHome; -using tempify::test_support::create_sensitive_template; -using tempify::test_support::read_text_file; -} +} // namespace TEST_CASE(TempifyApp_render_help_mentions_diff_mode) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-diff-help-data-home"); @@ -44,18 +44,17 @@ TEST_CASE(TempifyApp_tui_review_screen_allows_back_before_generate) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-tui-review-target"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-tui-review-data-home"); tempify::TempifyApp app; - ScopedStdinCapture input( - "First App\n" - "\n" - "core\n" - "no\n" - "n\n" - ":back\n" - "Second App\n" - "\n" - "core2\n" - "no\n" - "yes\n"); + ScopedStdinCapture input("First App\n" + "\n" + "core\n" + "no\n" + "n\n" + ":back\n" + "Second App\n" + "\n" + "core2\n" + "no\n" + "yes\n"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({"basic_cpp", target.path().string(), "--set", "author=Review Tester", "--tui"}), 0); @@ -158,7 +157,5 @@ TEST_CASE(TempifyApp_questions_unknown_template_throws) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-q-missing-data-home"); tempify::TempifyApp app; - REQUIRE_THROWS_AS( - app.run({"definitely_missing_template_12345", "-q"}), - tempify::TempifyError); + REQUIRE_THROWS_AS(app.run({"definitely_missing_template_12345", "-q"}), tempify::TempifyError); } diff --git a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp index a9816d2..d5e2aed 100644 --- a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp +++ b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp @@ -21,7 +21,7 @@ std::string shell_quote(std::string value) { return quoted; } -const char* shell_null_device() { +const char *shell_null_device() { #ifdef _WIN32 return "NUL"; #else @@ -29,10 +29,9 @@ const char* shell_null_device() { #endif } -int run_tempify_and_capture_stderr(const std::vector& args, - const std::filesystem::path& stderr_file) { +int run_tempify_and_capture_stderr(const std::vector &args, const std::filesystem::path &stderr_file) { std::string command = shell_quote(tempify::test_support::tempify_binary_path().string()); - for (const auto& arg : args) { + for (const auto &arg : args) { command.push_back(' '); if (arg.find_first_of(" \t\"") == std::string::npos) { command += arg; @@ -47,14 +46,14 @@ int run_tempify_and_capture_stderr(const std::vector& args, return std::system(command.c_str()); } +using tempify::test_support::read_text_file; using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::ScopedStdoutCapture; using tempify::test_support::ScopedTempifyDataHome; -using tempify::test_support::read_text_file; using tempify::test_support::tempify_binary_path; using tempify::test_support::write_text_file; -} +} // namespace TEST_CASE(TempifyApp_reapply_blocks_version_downgrade_with_structured_details) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-downgrade-target"); @@ -62,14 +61,20 @@ TEST_CASE(TempifyApp_reapply_blocks_version_downgrade_with_structured_details) { tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Downgrade App", - "--set", "name_slug=downgrade-app", - "--set", "namespace=downgrade_ns", - "--set", "include_ci=false", - "--set", "author=Downgrade Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Downgrade App", + "--set", + "name_slug=downgrade-app", + "--set", + "namespace=downgrade_ns", + "--set", + "include_ci=false", + "--set", + "author=Downgrade Tester", + }), + 0); std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); const std::string current_version = "\"version\": \"0.1.0\""; @@ -82,38 +87,52 @@ TEST_CASE(TempifyApp_reapply_blocks_version_downgrade_with_structured_details) { static_cast(app.run({ "basic_cpp", target.path().string(), - "--set", "project_name=Downgrade App", - "--set", "name_slug=downgrade-app", - "--set", "namespace=downgrade_ns", - "--set", "include_ci=false", - "--set", "author=Downgrade Tester", + "--set", + "project_name=Downgrade App", + "--set", + "name_slug=downgrade-app", + "--set", + "namespace=downgrade_ns", + "--set", + "include_ci=false", + "--set", + "author=Downgrade Tester", "--reapply", })); REQUIRE(false); - } catch (const tempify::ReapplyBlockedError& error) { + } catch (const tempify::ReapplyBlockedError &error) { REQUIRE(std::string(error.what()).find("version downgrade") != std::string::npos); REQUIRE(error.version_transition().has_value()); REQUIRE_EQ(error.version_transition()->kind, std::string("downgrade")); REQUIRE_EQ(error.version_transition()->reason, std::string("backward_version_change")); REQUIRE_EQ(error.version_transition()->from_version, std::string("0.2.0")); REQUIRE_EQ(error.version_transition()->to_version, std::string("0.1.0")); - REQUIRE(std::find(error.review_paths().begin(), error.review_paths().end(), ".tempify-lock.json") != error.review_paths().end()); + REQUIRE(std::find(error.review_paths().begin(), error.review_paths().end(), ".tempify-lock.json") != + error.review_paths().end()); } } TEST_CASE(TempifyApp_reapply_blocks_pre_1_0_minor_upgrade_with_structured_details) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-pre-1-0-upgrade-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-pre-1-0-upgrade-data-home"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-pre-1-0-upgrade-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-pre-1-0-upgrade-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Pre1 Upgrade App", - "--set", "name_slug=pre1-upgrade-app", - "--set", "namespace=pre1_upgrade_ns", - "--set", "include_ci=false", - "--set", "author=Upgrade Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Pre1 Upgrade App", + "--set", + "name_slug=pre1-upgrade-app", + "--set", + "namespace=pre1_upgrade_ns", + "--set", + "include_ci=false", + "--set", + "author=Upgrade Tester", + }), + 0); std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); const std::string current_version = "\"version\": \"0.1.0\""; @@ -126,22 +145,28 @@ TEST_CASE(TempifyApp_reapply_blocks_pre_1_0_minor_upgrade_with_structured_detail static_cast(app.run({ "basic_cpp", target.path().string(), - "--set", "project_name=Pre1 Upgrade App", - "--set", "name_slug=pre1-upgrade-app", - "--set", "namespace=pre1_upgrade_ns", - "--set", "include_ci=false", - "--set", "author=Upgrade Tester", + "--set", + "project_name=Pre1 Upgrade App", + "--set", + "name_slug=pre1-upgrade-app", + "--set", + "namespace=pre1_upgrade_ns", + "--set", + "include_ci=false", + "--set", + "author=Upgrade Tester", "--reapply", })); REQUIRE(false); - } catch (const tempify::ReapplyBlockedError& error) { + } catch (const tempify::ReapplyBlockedError &error) { REQUIRE(std::string(error.what()).find("pre-1.0 minor upgrade") != std::string::npos); REQUIRE(error.version_transition().has_value()); REQUIRE_EQ(error.version_transition()->kind, std::string("upgrade")); REQUIRE_EQ(error.version_transition()->reason, std::string("pre_1_0_minor_upgrade")); REQUIRE_EQ(error.version_transition()->from_version, std::string("0.0.9")); REQUIRE_EQ(error.version_transition()->to_version, std::string("0.1.0")); - REQUIRE(std::find(error.review_paths().begin(), error.review_paths().end(), ".tempify-lock.json") != error.review_paths().end()); + REQUIRE(std::find(error.review_paths().begin(), error.review_paths().end(), ".tempify-lock.json") != + error.review_paths().end()); } } @@ -150,29 +175,41 @@ TEST_CASE(TempifyApp_reapply_updates_safe_files_keeps_local_edits_and_skips_hook ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply App", - "--set", "name_slug=old-slug", - "--set", "namespace=reapply_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply App", + "--set", + "name_slug=old-slug", + "--set", + "namespace=reapply_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Tester", + }), + 0); write_text_file(target.path() / "README.md", "# local readme\n"); const std::string original_summary = read_text_file(target.path() / ".tempify-summary.txt"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply App", - "--set", "name_slug=new-slug", - "--set", "namespace=reapply_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Tester", - "--reapply", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply App", + "--set", + "name_slug=new-slug", + "--set", + "namespace=reapply_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Tester", + "--reapply", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("Reapplied basic_cpp") != std::string::npos); @@ -187,16 +224,22 @@ TEST_CASE(TempifyApp_reapply_updates_safe_files_keeps_local_edits_and_skips_hook ScopedStdoutCapture diff_capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply App", - "--set", "name_slug=new-slug", - "--set", "namespace=reapply_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Tester", - "--diff", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply App", + "--set", + "name_slug=new-slug", + "--set", + "namespace=reapply_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Tester", + "--diff", + "--json", + }), + 0); const std::string diff_output = diff_capture.str(); REQUIRE(diff_output.find("\"path\": \"README.md\"") != std::string::npos); REQUIRE(diff_output.find("\"reason\": \"local_edit\"") != std::string::npos); @@ -210,29 +253,41 @@ TEST_CASE(TempifyApp_reapply_json_outputs_machine_readable_success_result) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-json-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply Json App", - "--set", "name_slug=old-json-slug", - "--set", "namespace=reapply_json_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Json Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Json App", + "--set", + "name_slug=old-json-slug", + "--set", + "namespace=reapply_json_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Json Tester", + }), + 0); write_text_file(target.path() / "README.md", "# local readme\n"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply Json App", - "--set", "name_slug=new-json-slug", - "--set", "namespace=reapply_json_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Json Tester", - "--reapply", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Json App", + "--set", + "name_slug=new-json-slug", + "--set", + "namespace=reapply_json_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Json Tester", + "--reapply", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"status\": \"ok\"") != std::string::npos); @@ -252,33 +307,47 @@ TEST_CASE(TempifyApp_reapply_json_outputs_machine_readable_success_result) { } TEST_CASE(TempifyApp_reapply_subcommand_json_outputs_machine_readable_success_result) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-subcommand-json-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-subcommand-json-data-home"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-subcommand-json-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-subcommand-json-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply Subcommand App", - "--set", "name_slug=old-subcommand-slug", - "--set", "namespace=reapply_subcommand_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Json Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Subcommand App", + "--set", + "name_slug=old-subcommand-slug", + "--set", + "namespace=reapply_subcommand_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Json Tester", + }), + 0); write_text_file(target.path() / "README.md", "# local readme\n"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "reapply", - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply Subcommand App", - "--set", "name_slug=new-subcommand-slug", - "--set", "namespace=reapply_subcommand_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Json Tester", - "--json", - }), 0); + "reapply", + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Subcommand App", + "--set", + "name_slug=new-subcommand-slug", + "--set", + "namespace=reapply_subcommand_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Json Tester", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"status\": \"ok\"") != std::string::npos); @@ -294,29 +363,41 @@ TEST_CASE(TempifyApp_reapply_report_outputs_report_only_json_without_mutation) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-report-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply Report App", - "--set", "name_slug=old-report-slug", - "--set", "namespace=reapply_report_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Report Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Report App", + "--set", + "name_slug=old-report-slug", + "--set", + "namespace=reapply_report_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Report Tester", + }), + 0); const std::string original_cmake = read_text_file(target.path() / "CMakeLists.txt"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply Report App", - "--set", "name_slug=new-report-slug", - "--set", "namespace=reapply_report_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Report Tester", - "--reapply", - "--report", - "--json", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Report App", + "--set", + "name_slug=new-report-slug", + "--set", + "namespace=reapply_report_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Report Tester", + "--reapply", + "--report", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"reapply\": {") != std::string::npos); @@ -328,33 +409,47 @@ TEST_CASE(TempifyApp_reapply_report_outputs_report_only_json_without_mutation) { } TEST_CASE(TempifyApp_reapply_subcommand_report_outputs_report_only_json_without_mutation) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-subcommand-report-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-subcommand-report-data-home"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-subcommand-report-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-subcommand-report-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply Subcommand Report App", - "--set", "name_slug=old-subcommand-report-slug", - "--set", "namespace=reapply_subcommand_report_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Report Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Subcommand Report App", + "--set", + "name_slug=old-subcommand-report-slug", + "--set", + "namespace=reapply_subcommand_report_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Report Tester", + }), + 0); const std::string original_cmake = read_text_file(target.path() / "CMakeLists.txt"); ScopedStdoutCapture capture; REQUIRE_EQ(app.run({ - "reapply", - "basic_cpp", - target.path().string(), - "--set", "project_name=Reapply Subcommand Report App", - "--set", "name_slug=new-subcommand-report-slug", - "--set", "namespace=reapply_subcommand_report_ns", - "--set", "include_ci=false", - "--set", "author=Reapply Report Tester", - "--report", - "--json", - }), 0); + "reapply", + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Subcommand Report App", + "--set", + "name_slug=new-subcommand-report-slug", + "--set", + "namespace=reapply_subcommand_report_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Report Tester", + "--report", + "--json", + }), + 0); const std::string output = capture.str(); REQUIRE(output.find("\"reapply\": {") != std::string::npos); @@ -368,14 +463,20 @@ TEST_CASE(TempifyApp_reapply_blocks_conflicts_without_mutation) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-conflict-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Conflict Reapply App", - "--set", "name_slug=conflict-reapply-app", - "--set", "namespace=conflict_reapply_ns", - "--set", "include_ci=false", - "--set", "author=Old Author", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Conflict Reapply App", + "--set", + "name_slug=conflict-reapply-app", + "--set", + "namespace=conflict_reapply_ns", + "--set", + "include_ci=false", + "--set", + "author=Old Author", + }), + 0); write_text_file(target.path() / "README.md", "# local conflict\n"); const std::string original_readme = read_text_file(target.path() / "README.md"); @@ -385,15 +486,20 @@ TEST_CASE(TempifyApp_reapply_blocks_conflicts_without_mutation) { static_cast(app.run({ "basic_cpp", target.path().string(), - "--set", "project_name=Conflict Reapply App", - "--set", "name_slug=conflict-reapply-app", - "--set", "namespace=conflict_reapply_ns", - "--set", "include_ci=false", - "--set", "author=New Author", + "--set", + "project_name=Conflict Reapply App", + "--set", + "name_slug=conflict-reapply-app", + "--set", + "namespace=conflict_reapply_ns", + "--set", + "include_ci=false", + "--set", + "author=New Author", "--reapply", })); REQUIRE(false); - } catch (const tempify::TempifyError& error) { + } catch (const tempify::TempifyError &error) { REQUIRE(std::string(error.what()).find("Reapply blocked") != std::string::npos); REQUIRE(std::string(error.what()).find("conflict item") != std::string::npos); REQUIRE(std::string(error.what()).find("--diff") != std::string::npos); @@ -405,33 +511,48 @@ TEST_CASE(TempifyApp_reapply_blocks_conflicts_without_mutation) { TEST_CASE(Tempify_json_error_wraps_reapply_block_and_includes_blocked_paths) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-json-error-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-json-error-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-json-error-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Json Conflict App", - "--set", "name_slug=json-conflict-app", - "--set", "namespace=json_conflict_ns", - "--set", "include_ci=false", - "--set", "author=Old Author", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Json Conflict App", + "--set", + "name_slug=json-conflict-app", + "--set", + "namespace=json_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Old Author", + }), + 0); write_text_file(target.path() / "README.md", "# local conflict\n"); - const std::filesystem::path stderr_file = std::filesystem::temp_directory_path() / "tempify-app-reapply-json-error-stderr.txt"; + const std::filesystem::path stderr_file = + std::filesystem::temp_directory_path() / "tempify-app-reapply-json-error-stderr.txt"; std::filesystem::remove(stderr_file); - const int exit_code = run_tempify_and_capture_stderr({ - "basic_cpp", - target.path().string(), - "--set", "project_name=JsonConflictApp", - "--set", "name_slug=json-conflict-app", - "--set", "namespace=json_conflict_ns", - "--set", "include_ci=false", - "--set", "author=NewAuthor", - "--reapply", - "--json", - }, stderr_file); + const int exit_code = run_tempify_and_capture_stderr( + { + "basic_cpp", + target.path().string(), + "--set", + "project_name=JsonConflictApp", + "--set", + "name_slug=json-conflict-app", + "--set", + "namespace=json_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=NewAuthor", + "--reapply", + "--json", + }, + stderr_file); REQUIRE(exit_code != 0); const std::string error_output = read_text_file(stderr_file); @@ -457,32 +578,45 @@ TEST_CASE(TempifyApp_reapply_requires_existing_generation_lock) { static_cast(app.run({ "basic_cpp", target.path().string(), - "--set", "project_name=No Lock App", - "--set", "name_slug=no-lock-app", - "--set", "namespace=no_lock_ns", - "--set", "include_ci=false", - "--set", "author=No Lock Tester", + "--set", + "project_name=No Lock App", + "--set", + "name_slug=no-lock-app", + "--set", + "namespace=no_lock_ns", + "--set", + "include_ci=false", + "--set", + "author=No Lock Tester", "--reapply", })); REQUIRE(false); - } catch (const tempify::TempifyError& error) { + } catch (const tempify::TempifyError &error) { REQUIRE(std::string(error.what()).find("requires existing .tempify-lock.json") != std::string::npos); } } TEST_CASE(TempifyApp_reapply_blocks_origin_template_mismatch) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-origin-mismatch-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-origin-mismatch-data-home"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-origin-mismatch-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-origin-mismatch-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "layered_cpp_product", - target.path().string(), - "--set", "project_name=Origin Mismatch Reapply App", - "--set", "project_slug=origin-mismatch-reapply-app", - "--set", "language_standard=c++23", - "--set", "include_ci=true", - "--set", "ci_provider=github", - }), 0); + "layered_cpp_product", + target.path().string(), + "--set", + "project_name=Origin Mismatch Reapply App", + "--set", + "project_slug=origin-mismatch-reapply-app", + "--set", + "language_standard=c++23", + "--set", + "include_ci=true", + "--set", + "ci_provider=github", + }), + 0); const std::string original_lock = read_text_file(target.path() / ".tempify-lock.json"); @@ -490,15 +624,20 @@ TEST_CASE(TempifyApp_reapply_blocks_origin_template_mismatch) { static_cast(app.run({ "basic_cpp", target.path().string(), - "--set", "project_name=Origin Mismatch Reapply App", - "--set", "name_slug=origin-mismatch-reapply-app", - "--set", "namespace=origin_mismatch_reapply_ns", - "--set", "include_ci=false", - "--set", "author=Origin Tester", + "--set", + "project_name=Origin Mismatch Reapply App", + "--set", + "name_slug=origin-mismatch-reapply-app", + "--set", + "namespace=origin_mismatch_reapply_ns", + "--set", + "include_ci=false", + "--set", + "author=Origin Tester", "--reapply", })); REQUIRE(false); - } catch (const tempify::ReapplyBlockedError& error) { + } catch (const tempify::ReapplyBlockedError &error) { REQUIRE(std::string(error.what()).find("origin template mismatch") != std::string::npos); REQUIRE(std::string(error.what()).find("layered_cpp_product") != std::string::npos); REQUIRE(error.origin_mismatch().has_value()); @@ -506,39 +645,56 @@ TEST_CASE(TempifyApp_reapply_blocks_origin_template_mismatch) { REQUIRE_EQ(error.origin_mismatch()->origin_template_id, std::string("layered_cpp_product")); REQUIRE_EQ(error.origin_mismatch()->requested_template_id, std::string("basic_cpp")); REQUIRE(!error.version_transition().has_value()); - REQUIRE(std::find(error.review_paths().begin(), error.review_paths().end(), ".tempify-lock.json") != error.review_paths().end()); + REQUIRE(std::find(error.review_paths().begin(), error.review_paths().end(), ".tempify-lock.json") != + error.review_paths().end()); } REQUIRE_EQ(read_text_file(target.path() / ".tempify-lock.json"), original_lock); } TEST_CASE(Tempify_json_error_wraps_origin_template_mismatch_with_structured_blocked_details) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-origin-mismatch-json-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-origin-mismatch-json-data-home"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-origin-mismatch-json-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-origin-mismatch-json-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "layered_cpp_product", - target.path().string(), - "--set", "project_name=Origin Json App", - "--set", "project_slug=origin-json-app", - "--set", "language_standard=c++23", - "--set", "include_ci=true", - "--set", "ci_provider=github", - }), 0); - - const std::filesystem::path stderr_file = std::filesystem::temp_directory_path() / "tempify-app-reapply-origin-mismatch-json-stderr.txt"; + "layered_cpp_product", + target.path().string(), + "--set", + "project_name=Origin Json App", + "--set", + "project_slug=origin-json-app", + "--set", + "language_standard=c++23", + "--set", + "include_ci=true", + "--set", + "ci_provider=github", + }), + 0); + + const std::filesystem::path stderr_file = + std::filesystem::temp_directory_path() / "tempify-app-reapply-origin-mismatch-json-stderr.txt"; std::filesystem::remove(stderr_file); - const int exit_code = run_tempify_and_capture_stderr({ - "basic_cpp", - target.path().string(), - "--set", "project_name=OriginJsonApp", - "--set", "name_slug=origin-json-app", - "--set", "namespace=origin_json_ns", - "--set", "include_ci=false", - "--set", "author=OriginJsonTester", - "--reapply", - "--json", - }, stderr_file); + const int exit_code = run_tempify_and_capture_stderr( + { + "basic_cpp", + target.path().string(), + "--set", + "project_name=OriginJsonApp", + "--set", + "name_slug=origin-json-app", + "--set", + "namespace=origin_json_ns", + "--set", + "include_ci=false", + "--set", + "author=OriginJsonTester", + "--reapply", + "--json", + }, + stderr_file); REQUIRE(exit_code != 0); const std::string error_output = read_text_file(stderr_file); @@ -555,17 +711,24 @@ TEST_CASE(Tempify_json_error_wraps_origin_template_mismatch_with_structured_bloc TEST_CASE(Tempify_json_error_wraps_pre_1_0_minor_upgrade_with_structured_blocked_details) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-pre-1-0-json-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-pre-1-0-json-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-pre-1-0-json-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Pre1 Json App", - "--set", "name_slug=pre1-json-app", - "--set", "namespace=pre1_json_ns", - "--set", "include_ci=false", - "--set", "author=Upgrade Tester", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Pre1 Json App", + "--set", + "name_slug=pre1-json-app", + "--set", + "namespace=pre1_json_ns", + "--set", + "include_ci=false", + "--set", + "author=Upgrade Tester", + }), + 0); std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); const std::string current_version = "\"version\": \"0.1.0\""; @@ -574,19 +737,27 @@ TEST_CASE(Tempify_json_error_wraps_pre_1_0_minor_upgrade_with_structured_blocked lock_text.replace(version_pos, current_version.size(), "\"version\": \"0.0.9\""); write_text_file(target.path() / ".tempify-lock.json", lock_text); - const std::filesystem::path stderr_file = std::filesystem::temp_directory_path() / "tempify-app-reapply-pre-1-0-json-stderr.txt"; + const std::filesystem::path stderr_file = + std::filesystem::temp_directory_path() / "tempify-app-reapply-pre-1-0-json-stderr.txt"; std::filesystem::remove(stderr_file); - const int exit_code = run_tempify_and_capture_stderr({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Pre1JsonApp", - "--set", "name_slug=pre1-json-app", - "--set", "namespace=pre1_json_ns", - "--set", "include_ci=false", - "--set", "author=UpgradeTester", - "--reapply", - "--json", - }, stderr_file); + const int exit_code = run_tempify_and_capture_stderr( + { + "basic_cpp", + target.path().string(), + "--set", + "project_name=Pre1JsonApp", + "--set", + "name_slug=pre1-json-app", + "--set", + "namespace=pre1_json_ns", + "--set", + "include_ci=false", + "--set", + "author=UpgradeTester", + "--reapply", + "--json", + }, + stderr_file); REQUIRE(exit_code != 0); const std::string error_output = read_text_file(stderr_file); @@ -600,34 +771,50 @@ TEST_CASE(Tempify_json_error_wraps_pre_1_0_minor_upgrade_with_structured_blocked } TEST_CASE(Tempify_json_error_wraps_reapply_subcommand_block_with_structured_details) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-subcommand-json-error-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-subcommand-json-error-data-home"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-subcommand-json-error-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-subcommand-json-error-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Json Subcommand Conflict App", - "--set", "name_slug=json-subcommand-conflict-app", - "--set", "namespace=json_subcommand_conflict_ns", - "--set", "include_ci=false", - "--set", "author=Old Author", - }), 0); + "basic_cpp", + target.path().string(), + "--set", + "project_name=Json Subcommand Conflict App", + "--set", + "name_slug=json-subcommand-conflict-app", + "--set", + "namespace=json_subcommand_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Old Author", + }), + 0); write_text_file(target.path() / "README.md", "# local conflict\n"); - const std::filesystem::path stderr_file = std::filesystem::temp_directory_path() / "tempify-app-reapply-subcommand-json-error-stderr.txt"; + const std::filesystem::path stderr_file = + std::filesystem::temp_directory_path() / "tempify-app-reapply-subcommand-json-error-stderr.txt"; std::filesystem::remove(stderr_file); - const int exit_code = run_tempify_and_capture_stderr({ - "reapply", - "basic_cpp", - target.path().string(), - "--set", "project_name=JsonSubcommandConflictApp", - "--set", "name_slug=json-subcommand-conflict-app", - "--set", "namespace=json_subcommand_conflict_ns", - "--set", "include_ci=false", - "--set", "author=NewAuthor", - "--json", - }, stderr_file); + const int exit_code = run_tempify_and_capture_stderr( + { + "reapply", + "basic_cpp", + target.path().string(), + "--set", + "project_name=JsonSubcommandConflictApp", + "--set", + "name_slug=json-subcommand-conflict-app", + "--set", + "namespace=json_subcommand_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=NewAuthor", + "--json", + }, + stderr_file); REQUIRE(exit_code != 0); const std::string error_output = read_text_file(stderr_file); diff --git a/tests/tempify/TempifyAppRenderIntegrationTests.cpp b/tests/tempify/TempifyAppRenderIntegrationTests.cpp index e1463e7..388bba5 100644 --- a/tests/tempify/TempifyAppRenderIntegrationTests.cpp +++ b/tests/tempify/TempifyAppRenderIntegrationTests.cpp @@ -5,12 +5,12 @@ namespace { +using tempify::test_support::read_text_file; using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::ScopedTempifyDataHome; -using tempify::test_support::read_text_file; using tempify::test_support::test_template_path; -} +} // namespace TEST_CASE(TempifyApp_render_with_cli_values_generates_expected_output) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-cli-values-test"); @@ -20,13 +20,20 @@ TEST_CASE(TempifyApp_render_with_cli_values_generates_expected_output) { const int result = app.run({ "basic_cpp", target.path().string(), - "--set", "project_name=CLI App", - "--set", "name_slug=alias-slug", - "--set", "namespace=cli_ns", - "--set", "include_ci=false", - "--set", "ci_provider=gitlab", - "--set", "docs_url=https://docs.example", - "--set", "author=Leodoras", + "--set", + "project_name=CLI App", + "--set", + "name_slug=alias-slug", + "--set", + "namespace=cli_ns", + "--set", + "include_ci=false", + "--set", + "ci_provider=gitlab", + "--set", + "docs_url=https://docs.example", + "--set", + "author=Leodoras", }); REQUIRE_EQ(result, 0); @@ -53,11 +60,16 @@ TEST_CASE(TempifyApp_render_inherited_template_merges_layers_and_drop_paths) { const int result = app.run({ "layered_cpp_product", target.path().string(), - "--set", "project_name=Layered Product", - "--set", "project_slug=layered-product", - "--set", "language_standard=c++23", - "--set", "include_ci=true", - "--set", "ci_provider=github", + "--set", + "project_name=Layered Product", + "--set", + "project_slug=layered-product", + "--set", + "language_standard=c++23", + "--set", + "include_ci=true", + "--set", + "ci_provider=github", }); REQUIRE_EQ(result, 0); @@ -81,11 +93,16 @@ TEST_CASE(TempifyApp_render_with_direct_template_path_generates_expected_output) const int result = app.run({ test_template_path("basic_cpp").string(), target.path().string(), - "--set", "project_name=Path App", - "--set", "name_slug=path-app", - "--set", "namespace=path_ns", - "--set", "include_ci=false", - "--set", "author=Path Tester", + "--set", + "project_name=Path App", + "--set", + "name_slug=path-app", + "--set", + "namespace=path_ns", + "--set", + "include_ci=false", + "--set", + "author=Path Tester", }); REQUIRE_EQ(result, 0); @@ -95,22 +112,29 @@ TEST_CASE(TempifyApp_render_with_direct_template_path_generates_expected_output) TEST_CASE(TempifyApp_overwrite_if_exists_replaces_conflicting_files) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-overwrite-if-exists-test"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-overwrite-if-exists-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-overwrite-if-exists-data-home"); std::filesystem::create_directories(target.path()); tempify::test_support::write_text_file(target.path() / "README.md", "old readme\n"); tempify::test_support::write_text_file(target.path() / "keep.txt", "keep\n"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "-f", - "--set", "project_name=Overwrite App", - "--set", "name_slug=overwrite-app", - "--set", "namespace=overwrite_ns", - "--set", "include_ci=false", - "--set", "author=Overwrite Tester", - }), 0); + "basic_cpp", + target.path().string(), + "-f", + "--set", + "project_name=Overwrite App", + "--set", + "name_slug=overwrite-app", + "--set", + "namespace=overwrite_ns", + "--set", + "include_ci=false", + "--set", + "author=Overwrite Tester", + }), + 0); REQUIRE(read_text_file(target.path() / "README.md").find("# Overwrite App") != std::string::npos); REQUIRE(read_text_file(target.path() / "keep.txt") == std::string("keep\n")); @@ -118,21 +142,28 @@ TEST_CASE(TempifyApp_overwrite_if_exists_replaces_conflicting_files) { TEST_CASE(TempifyApp_skip_if_file_exists_preserves_conflicting_files_and_generates_missing_files) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-skip-if-file-exists-test"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-skip-if-file-exists-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-skip-if-file-exists-data-home"); std::filesystem::create_directories(target.path()); tempify::test_support::write_text_file(target.path() / "README.md", "custom readme\n"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ - "basic_cpp", - target.path().string(), - "-s", - "--set", "project_name=Skip App", - "--set", "name_slug=skip-app", - "--set", "namespace=skip_ns", - "--set", "include_ci=false", - "--set", "author=Skip Tester", - }), 0); + "basic_cpp", + target.path().string(), + "-s", + "--set", + "project_name=Skip App", + "--set", + "name_slug=skip-app", + "--set", + "namespace=skip_ns", + "--set", + "include_ci=false", + "--set", + "author=Skip Tester", + }), + 0); REQUIRE(read_text_file(target.path() / "README.md") == std::string("custom readme\n")); REQUIRE(std::filesystem::exists(target.path() / "src" / "main.cpp")); @@ -142,9 +173,7 @@ TEST_CASE(TempifyApp_render_unknown_template_throws) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-render-missing-data-home"); tempify::TempifyApp app; - REQUIRE_THROWS_AS( - app.run({"definitely_missing_template_67890", "out-dir"}), - tempify::TempifyError); + REQUIRE_THROWS_AS(app.run({"definitely_missing_template_67890", "out-dir"}), tempify::TempifyError); } TEST_CASE(TempifyApp_render_existing_target_without_overwrite_throws) { @@ -155,15 +184,19 @@ TEST_CASE(TempifyApp_render_existing_target_without_overwrite_throws) { tempify::TempifyApp app; - REQUIRE_THROWS_AS( - app.run({ - "basic_cpp", - target.path().string(), - "--set", "project_name=Existing Target", - "--set", "name_slug=existing-target", - "--set", "namespace=existing_ns", - "--set", "include_ci=false", - "--set", "author=Keeper", - }), - tempify::TempifyError); + REQUIRE_THROWS_AS(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Existing Target", + "--set", + "name_slug=existing-target", + "--set", + "namespace=existing_ns", + "--set", + "include_ci=false", + "--set", + "author=Keeper", + }), + tempify::TempifyError); } diff --git a/tests/tempify/TempifyAppTemplateTestCommandIntegrationTests.cpp b/tests/tempify/TempifyAppTemplateTestCommandIntegrationTests.cpp index 9e02afc..5034da7 100644 --- a/tests/tempify/TempifyAppTemplateTestCommandIntegrationTests.cpp +++ b/tests/tempify/TempifyAppTemplateTestCommandIntegrationTests.cpp @@ -5,14 +5,14 @@ namespace { +using tempify::test_support::create_required_only_template; +using tempify::test_support::read_text_file; using tempify::test_support::ScopedDirectoryCleanup; using tempify::test_support::ScopedStdoutCapture; using tempify::test_support::ScopedTempifyDataHome; -using tempify::test_support::create_required_only_template; -using tempify::test_support::read_text_file; using tempify::test_support::write_text_file; -} +} // namespace TEST_CASE(TempifyApp_test_runs_template_fixtures) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-data-home"); @@ -44,7 +44,8 @@ TEST_CASE(TempifyApp_test_fixture_flag_runs_single_named_fixture) { } TEST_CASE(TempifyApp_test_list_fixtures_outputs_names_only) { - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-list-fixtures-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-test-list-fixtures-data-home"); tempify::TempifyApp app; ScopedStdoutCapture capture; @@ -101,10 +102,12 @@ TEST_CASE(TempifyApp_test_json_outputs_machine_readable_report) { } TEST_CASE(TempifyApp_test_json_reports_failures_machine_readably) { - ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-test-json-fail-template"); + ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / + "tempify-app-test-json-fail-template"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-json-fail-data-home"); const std::filesystem::path template_path = create_required_only_template(template_root.path()); - write_text_file(template_path / "tests" / "broken_case" / "answers.json", "{\n \"required_name\": \"Mismatch App\"\n}\n"); + write_text_file(template_path / "tests" / "broken_case" / "answers.json", + "{\n \"required_name\": \"Mismatch App\"\n}\n"); write_text_file(template_path / "tests" / "broken_case" / "snapshot" / "README.md", "# Wrong App\n"); tempify::TempifyApp app; @@ -125,7 +128,8 @@ TEST_CASE(TempifyApp_test_update_snapshots_rewrites_fixture_outputs) { ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-test-update-template"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-update-data-home"); const std::filesystem::path template_path = create_required_only_template(template_root.path()); - write_text_file(template_path / "tests" / "refresh_case" / "answers.json", "{\n \"required_name\": \"Updated App\"\n}\n"); + write_text_file(template_path / "tests" / "refresh_case" / "answers.json", + "{\n \"required_name\": \"Updated App\"\n}\n"); write_text_file(template_path / "tests" / "refresh_case" / "snapshot" / "README.md", "# Old App\n"); tempify::TempifyApp app; @@ -134,14 +138,17 @@ TEST_CASE(TempifyApp_test_update_snapshots_rewrites_fixture_outputs) { REQUIRE_EQ(app.run({"test", template_path.string(), "--update-snapshots"}), 0); const std::string output = capture.str(); REQUIRE(output.find("PASS refresh_case (1 files)") != std::string::npos); - REQUIRE(read_text_file(template_path / "tests" / "refresh_case" / "snapshot" / "README.md") == std::string("# Updated App\n")); + REQUIRE(read_text_file(template_path / "tests" / "refresh_case" / "snapshot" / "README.md") == + std::string("# Updated App\n")); } TEST_CASE(TempifyApp_test_update_snapshots_rewrites_lock_snapshot_when_present) { - ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-test-update-lock-template"); + ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / + "tempify-app-test-update-lock-template"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-update-lock-data-home"); const std::filesystem::path template_path = create_required_only_template(template_root.path()); - write_text_file(template_path / "tests" / "refresh_lock" / "answers.json", "{\n \"required_name\": \"Lock App\"\n}\n"); + write_text_file(template_path / "tests" / "refresh_lock" / "answers.json", + "{\n \"required_name\": \"Lock App\"\n}\n"); write_text_file(template_path / "tests" / "refresh_lock" / "snapshot" / "README.md", "# stale\n"); write_text_file(template_path / "tests" / "refresh_lock" / "lock.json", "{\n \"generated_at\": \"stale\"\n}\n"); @@ -154,7 +161,8 @@ TEST_CASE(TempifyApp_test_update_snapshots_rewrites_lock_snapshot_when_present) } TEST_CASE(TempifyApp_test_fixture_flag_rejects_unknown_fixture) { - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-filtered-missing-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-test-filtered-missing-data-home"); tempify::TempifyApp app; ScopedStdoutCapture capture; @@ -195,9 +203,11 @@ TEST_CASE(TempifyApp_test_reports_snapshot_mismatch_with_diff_hint) { ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-test-mismatch-template"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-mismatch-data-home"); const std::filesystem::path template_path = create_required_only_template(template_root.path()); - write_text_file(template_path / "tests" / "broken_case" / "answers.json", "{\n \"required_name\": \"Mismatch App\"\n}\n"); + write_text_file(template_path / "tests" / "broken_case" / "answers.json", + "{\n \"required_name\": \"Mismatch App\"\n}\n"); write_text_file(template_path / "tests" / "broken_case" / "snapshot" / "README.md", "# Wrong App\n"); - write_text_file(template_path / "tests" / "passing_case" / "answers.json", "{\n \"required_name\": \"Passing App\"\n}\n"); + write_text_file(template_path / "tests" / "passing_case" / "answers.json", + "{\n \"required_name\": \"Passing App\"\n}\n"); write_text_file(template_path / "tests" / "passing_case" / "snapshot" / "README.md", "# Passing App\n"); tempify::TempifyApp app; @@ -216,7 +226,8 @@ TEST_CASE(TempifyApp_test_reports_missing_and_unexpected_snapshot_files) { ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-test-file-set-template"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-file-set-data-home"); const std::filesystem::path template_path = create_required_only_template(template_root.path()); - write_text_file(template_path / "tests" / "wrong_files" / "answers.json", "{\n \"required_name\": \"Wrong Files App\"\n}\n"); + write_text_file(template_path / "tests" / "wrong_files" / "answers.json", + "{\n \"required_name\": \"Wrong Files App\"\n}\n"); write_text_file(template_path / "tests" / "wrong_files" / "snapshot" / "expected-only.txt", "only in snapshot\n"); tempify::TempifyApp app; @@ -231,18 +242,23 @@ TEST_CASE(TempifyApp_test_reports_missing_and_unexpected_snapshot_files) { } TEST_CASE(TempifyApp_test_rejects_fixture_missing_snapshot_directory) { - ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-test-missing-snapshot-template"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-missing-snapshot-data-home"); + ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / + "tempify-app-test-missing-snapshot-template"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-test-missing-snapshot-data-home"); const std::filesystem::path template_path = create_required_only_template(template_root.path()); - write_text_file(template_path / "tests" / "missing_snapshot" / "answers.json", "{\n \"required_name\": \"App\"\n}\n"); + write_text_file(template_path / "tests" / "missing_snapshot" / "answers.json", + "{\n \"required_name\": \"App\"\n}\n"); tempify::TempifyApp app; REQUIRE_THROWS_AS(app.run({"test", template_path.string()}), tempify::TempifyError); } TEST_CASE(TempifyApp_test_rejects_fixture_bad_lock_snapshot_path) { - ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-test-bad-lock-path-template"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-test-bad-lock-path-data-home"); + ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / + "tempify-app-test-bad-lock-path-template"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-test-bad-lock-path-data-home"); const std::filesystem::path template_path = create_required_only_template(template_root.path()); write_text_file(template_path / "tests" / "bad_lock" / "answers.json", "{\n \"required_name\": \"App\"\n}\n"); write_text_file(template_path / "tests" / "bad_lock" / "snapshot" / "README.md", "# App\n"); diff --git a/tests/tempify/TempifyAppValidationInspectLintIntegrationTests.cpp b/tests/tempify/TempifyAppValidationInspectLintIntegrationTests.cpp index 2c161a5..0e12270 100644 --- a/tests/tempify/TempifyAppValidationInspectLintIntegrationTests.cpp +++ b/tests/tempify/TempifyAppValidationInspectLintIntegrationTests.cpp @@ -5,14 +5,14 @@ namespace { -using tempify::test_support::ScopedDirectoryCleanup; -using tempify::test_support::ScopedStdoutCapture; -using tempify::test_support::ScopedTempifyDataHome; using tempify::test_support::create_bad_layout_template; using tempify::test_support::create_duplicate_alias_template; using tempify::test_support::create_sensitive_template; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedStdoutCapture; +using tempify::test_support::ScopedTempifyDataHome; -} +} // namespace TEST_CASE(TempifyApp_validate_accepts_valid_template) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-validate-valid-data-home"); @@ -35,7 +35,8 @@ TEST_CASE(TempifyApp_validate_json_outputs_machine_readable_result) { } TEST_CASE(TempifyApp_validate_rejects_duplicate_alias_conflict) { - ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-validate-alias-template"); + ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / + "tempify-app-validate-alias-template"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-validate-alias-data-home"); const std::filesystem::path template_path = create_duplicate_alias_template(template_root.path()); tempify::TempifyApp app; @@ -44,7 +45,8 @@ TEST_CASE(TempifyApp_validate_rejects_duplicate_alias_conflict) { } TEST_CASE(TempifyApp_validate_rejects_missing_layout_source) { - ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / "tempify-app-validate-layout-template"); + ScopedDirectoryCleanup template_root(std::filesystem::temp_directory_path() / + "tempify-app-validate-layout-template"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-validate-layout-data-home"); const std::filesystem::path template_path = create_bad_layout_template(template_root.path()); tempify::TempifyApp app; @@ -85,7 +87,8 @@ TEST_CASE(TempifyApp_inspect_json_outputs_machine_readable_report) { } TEST_CASE(TempifyApp_inspect_json_includes_sensitive_question_metadata) { - ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-app-sensitive-inspect-workspace"); + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-app-sensitive-inspect-workspace"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-sensitive-inspect-data-home"); const std::filesystem::path template_root = create_sensitive_template(workspace.path()); tempify::TempifyApp app; diff --git a/tests/tempify/TemplateLoaderTests.cpp b/tests/tempify/TemplateLoaderTests.cpp index 045f819..f058386 100644 --- a/tests/tempify/TemplateLoaderTests.cpp +++ b/tests/tempify/TemplateLoaderTests.cpp @@ -1,9 +1,8 @@ -#include "TestHarness.h" #include "TempifyTestSupport.h" - +#include "TestHarness.h" #include "tempify/lua/LuaEngine.h" -#include "tempify/template/TemplateLoader.h" #include "tempify/support/Errors.h" +#include "tempify/template/TemplateLoader.h" #include #include @@ -16,9 +15,8 @@ namespace { class ScopedDirectoryCleanup { -public: - explicit ScopedDirectoryCleanup(std::filesystem::path path) - : path_(std::move(path)) { + public: + explicit ScopedDirectoryCleanup(std::filesystem::path path) : path_(std::move(path)) { std::filesystem::remove_all(path_); } @@ -26,28 +24,26 @@ class ScopedDirectoryCleanup { std::filesystem::remove_all(path_); } - const std::filesystem::path& path() const noexcept { + const std::filesystem::path &path() const noexcept { return path_; } -private: + private: std::filesystem::path path_; }; -void write_text_file(const std::filesystem::path& path, const std::string& text) { +void write_text_file(const std::filesystem::path &path, const std::string &text) { std::filesystem::create_directories(path.parent_path()); std::ofstream output(path, std::ios::binary); output << text; } -void create_template(const std::filesystem::path& root, - const std::string& id, - const std::vector& includes = {}, - const std::string& merge_body = {}, - const std::vector>& files = {}, +void create_template(const std::filesystem::path &root, const std::string &id, + const std::vector &includes = {}, const std::string &merge_body = {}, + const std::vector> &files = {}, const bool with_pre_hook = false) { std::filesystem::create_directories(root / "files"); - for (const auto& [relative_path, content] : files) { + for (const auto &[relative_path, content] : files) { write_text_file(root / "files" / relative_path, content); } if (with_pre_hook) { @@ -87,13 +83,14 @@ std::map test_index() { }; } -} +} // namespace TEST_CASE(TemplateLoader_merge_layers_replace_drop_and_append) { tempify::LuaEngine lua_engine; tempify::TemplateLoader loader(lua_engine); - const tempify::TemplateManifest manifest = loader.load(tempify::test_support::test_template_path("layered_cpp_product"), test_index()); + const tempify::TemplateManifest manifest = + loader.load(tempify::test_support::test_template_path("layered_cpp_product"), test_index()); REQUIRE_EQ(manifest.info.id, std::string("layered_cpp_product")); REQUIRE(manifest.files.size() >= static_cast(3)); @@ -102,7 +99,7 @@ TEST_CASE(TemplateLoader_merge_layers_replace_drop_and_append) { bool has_ci = false; bool has_base_only = false; bool main_from_product = false; - for (const auto& file : manifest.files) { + for (const auto &file : manifest.files) { if (file.relative_path == "README.md.pbt") { has_readme = true; REQUIRE_EQ(file.source_template_id, std::string("layered_ci_overlay")); @@ -128,9 +125,8 @@ TEST_CASE(TemplateLoader_file_conflict_error_strategy_throws) { tempify::LuaEngine lua_engine; tempify::TemplateLoader loader(lua_engine); - REQUIRE_THROWS_AS( - loader.load(tempify::test_support::test_template_path("conflict_child_error"), test_index()), - tempify::TempifyError); + REQUIRE_THROWS_AS(loader.load(tempify::test_support::test_template_path("conflict_child_error"), test_index()), + tempify::TempifyError); } TEST_CASE(TemplateLoader_detects_include_cycle_in_dynamic_templates) { @@ -157,12 +153,8 @@ TEST_CASE(TemplateLoader_wildcard_file_conflict_keep_preserves_base_file) { const std::filesystem::path child = workspace.path() / "wild_child"; create_template(base, "wild_base", {}, {}, {{"README.md.pbt", "base readme\n"}}); - create_template( - child, - "wild_child", - {"wild_base"}, - " file_conflicts = { [\"*.pbt\"] = \"keep\" },\n", - {{"README.md.pbt", "child readme\n"}}); + create_template(child, "wild_child", {"wild_base"}, " file_conflicts = { [\"*.pbt\"] = \"keep\" },\n", + {{"README.md.pbt", "child readme\n"}}); tempify::LuaEngine lua_engine; tempify::TemplateLoader loader(lua_engine); @@ -174,7 +166,7 @@ TEST_CASE(TemplateLoader_wildcard_file_conflict_keep_preserves_base_file) { const tempify::TemplateManifest manifest = loader.load(child, index); bool found = false; - for (const auto& file : manifest.files) { + for (const auto &file : manifest.files) { if (file.relative_path == "README.md.pbt") { found = true; REQUIRE_EQ(file.source_template_id, std::string("wild_base")); @@ -191,11 +183,7 @@ TEST_CASE(TemplateLoader_pre_hook_conflict_error_for_included_templates_throws) create_template(first, "hook_first", {}, {}, {}, true); create_template(second, "hook_second", {}, {}, {}, true); - create_template( - root, - "hook_root", - {"hook_first", "hook_second"}, - " pre_hook_conflict = \"error\",\n"); + create_template(root, "hook_root", {"hook_first", "hook_second"}, " pre_hook_conflict = \"error\",\n"); tempify::LuaEngine lua_engine; tempify::TemplateLoader loader(lua_engine); diff --git a/tests/tempify/TemplateTestRunnerTests.cpp b/tests/tempify/TemplateTestRunnerTests.cpp index 18c9e3d..ea4a1b2 100644 --- a/tests/tempify/TemplateTestRunnerTests.cpp +++ b/tests/tempify/TemplateTestRunnerTests.cpp @@ -1,5 +1,4 @@ #include "TestHarness.h" - #include "tempify/build/BuildPlanReport.h" #include "tempify/domain/BuildPlan.h" #include "tempify/domain/TemplateManifest.h" @@ -11,10 +10,12 @@ TEST_CASE(TemplateTestRunner_format_report_includes_failures_without_aborting_summary) { const tempify::TemplateTestReport report{ .template_id = "basic_cpp", - .fixtures = { - tempify::TemplateFixtureResult{.name = "default_no_ci", .snapshot_file_count = 5, .includes_lockfile_snapshot = false}, - tempify::TemplateFixtureResult{.name = "broken", .failure_message = "snapshot mismatch: line 1"}, - }, + .fixtures = + { + tempify::TemplateFixtureResult{ + .name = "default_no_ci", .snapshot_file_count = 5, .includes_lockfile_snapshot = false}, + tempify::TemplateFixtureResult{.name = "broken", .failure_message = "snapshot mismatch: line 1"}, + }, .total_snapshot_artifact_count = 5, .elapsed_ms = 2, }; @@ -43,10 +44,13 @@ TEST_CASE(TemplateTestRunner_canonicalize_lockfile_json_replaces_volatile_fields TEST_CASE(TemplateTestRunner_format_report_includes_lock_and_totals) { const tempify::TemplateTestReport report{ .template_id = "basic_cpp", - .fixtures = { - tempify::TemplateFixtureResult{.name = "default_no_ci", .snapshot_file_count = 5, .includes_lockfile_snapshot = false}, - tempify::TemplateFixtureResult{.name = "hooks_and_lock", .snapshot_file_count = 7, .includes_lockfile_snapshot = true}, - }, + .fixtures = + { + tempify::TemplateFixtureResult{ + .name = "default_no_ci", .snapshot_file_count = 5, .includes_lockfile_snapshot = false}, + tempify::TemplateFixtureResult{ + .name = "hooks_and_lock", .snapshot_file_count = 7, .includes_lockfile_snapshot = true}, + }, .total_snapshot_artifact_count = 13, .elapsed_ms = 4, }; @@ -61,10 +65,15 @@ TEST_CASE(TemplateTestRunner_format_report_includes_lock_and_totals) { TEST_CASE(TemplateTestRunner_format_report_json_includes_timing_and_failure_fields) { const tempify::TemplateTestReport report{ .template_id = "basic_cpp", - .fixtures = { - tempify::TemplateFixtureResult{.name = "default_no_ci", .snapshot_file_count = 5, .includes_lockfile_snapshot = false, .elapsed_ms = 1}, - tempify::TemplateFixtureResult{.name = "broken", .elapsed_ms = 2, .failure_message = "snapshot mismatch"}, - }, + .fixtures = + { + tempify::TemplateFixtureResult{.name = "default_no_ci", + .snapshot_file_count = 5, + .includes_lockfile_snapshot = false, + .elapsed_ms = 1}, + tempify::TemplateFixtureResult{ + .name = "broken", .elapsed_ms = 2, .failure_message = "snapshot mismatch"}, + }, .total_snapshot_artifact_count = 5, .elapsed_ms = 3, }; @@ -130,15 +139,13 @@ TEST_CASE(BuildPlanReport_redacts_sensitive_values_in_generation_lock) { tempify::BuildPlan plan; plan.build_root = "/tmp/output"; - const std::string output = tempify::format_generation_lock_json(manifest, - plan, + const std::string output = tempify::format_generation_lock_json(manifest, plan, { {"api_token", "secret-value"}, {"project_name", "Stone App"}, {"token", "secret-value"}, }, - tempify::HookAcceptance::Yes, - false); + tempify::HookAcceptance::Yes, false); REQUIRE(output.find("\"managed_files\": [") != std::string::npos); REQUIRE(output.find("\"managed_file_hashes\": {") != std::string::npos); From eede0de75c05e87d03d27fbfcf76fe7d5e5b6511 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 20:25:43 +0200 Subject: [PATCH 03/29] Adding Coverage --- CMakeLists.txt | 20 +++++++++++++++++ CMakePresets.json | 31 ++++++++++++++++++++++++++ Makefile | 11 ++++++++- scripts/ci/generate_coverage_report.sh | 17 ++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100755 scripts/ci/generate_coverage_report.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index 1203a5f..c0da2cd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,6 +10,8 @@ set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +option(TEMPIFY_ENABLE_COVERAGE "Enable GCC/GCov coverage instrumentation" OFF) + if(MSVC AND NOT DEFINED CMAKE_MSVC_RUNTIME_LIBRARY) if(DEFINED VCPKG_TARGET_TRIPLET AND VCPKG_TARGET_TRIPLET MATCHES "windows-static") set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") @@ -75,6 +77,17 @@ target_link_libraries(tempify_core PUBLIC prebyte_core PRIVATE CLI11::CLI11) add_executable(tempify src/main/cpp/main.cpp) target_link_libraries(tempify PRIVATE tempify_core) +if(TEMPIFY_ENABLE_COVERAGE) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(tempify_core PUBLIC --coverage -O0 -g) + target_link_options(tempify_core PUBLIC --coverage) + target_compile_options(tempify PRIVATE --coverage -O0 -g) + target_link_options(tempify PRIVATE --coverage) + else() + message(FATAL_ERROR "TEMPIFY_ENABLE_COVERAGE requires GCC-compatible coverage flags") + endif() +endif() + install(TARGETS tempify RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) install(FILES README.md LICENSE DESTINATION ${CMAKE_INSTALL_DATADIR}/tempify) @@ -93,6 +106,13 @@ if(BUILD_TESTING) target_link_libraries(tempify_tests PRIVATE tempify_core) add_dependencies(tempify_tests tempify) + if(TEMPIFY_ENABLE_COVERAGE) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(tempify_tests PRIVATE --coverage -O0 -g) + target_link_options(tempify_tests PRIVATE --coverage) + endif() + endif() + if(TEMPIFY_TEST_SOURCES) set(TEMPIFY_DISCOVERED_TEST_NAMES) foreach(TEMPIFY_TEST_SOURCE IN LISTS TEMPIFY_TEST_SOURCES) diff --git a/CMakePresets.json b/CMakePresets.json index 234d083..fa05470 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -15,6 +15,19 @@ "CMAKE_BUILD_TYPE": "RelWithDebInfo" } }, + { + "name": "coverage", + "displayName": "Coverage Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/coverage", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_C_COMPILER": "gcc", + "CMAKE_CXX_COMPILER": "g++", + "BUILD_TESTING": "ON", + "TEMPIFY_ENABLE_COVERAGE": "ON" + } + }, { "name": "tidy", "displayName": "Clang-Tidy Ninja", @@ -34,10 +47,28 @@ "name": "dev", "configurePreset": "dev" }, + { + "name": "coverage", + "configurePreset": "coverage" + }, + { + "name": "coverage-tests", + "configurePreset": "coverage", + "targets": ["tempify_tests", "tempify"] + }, { "name": "tidy-core", "configurePreset": "tidy", "targets": ["tempify_core", "tempify", "tempify_tests"] } + ], + "testPresets": [ + { + "name": "coverage", + "configurePreset": "coverage", + "output": { + "outputOnFailure": true + } + } ] } diff --git a/Makefile b/Makefile index 3fb4444..335d6f0 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ -.PHONY: configure build start run test clean analyze lint security static-analysis format format-check +.PHONY: configure build start run test clean analyze lint security static-analysis format format-check coverage BUILD_DIR ?= build CLANG_TIDY_BUILD_DIR := build-cmake/tidy +COVERAGE_BUILD_DIR := build-cmake/coverage +COVERAGE_MIN_LINE ?= 85 ifdef VCPKG_ROOT CMAKE_VCPKG_ARGS := -DCMAKE_TOOLCHAIN_FILE=$(VCPKG_ROOT)/scripts/buildsystems/vcpkg.cmake @@ -48,5 +50,12 @@ format-check: chmod +x scripts/ci/run_clang_format.sh ./scripts/ci/run_clang_format.sh check +coverage: + cmake --preset coverage $(CMAKE_VCPKG_ARGS) + cmake --build --preset coverage-tests --parallel + ctest --preset coverage + chmod +x scripts/ci/generate_coverage_report.sh + COVERAGE_MIN_LINE=$(COVERAGE_MIN_LINE) ./scripts/ci/generate_coverage_report.sh + clean: rm -rf $(BUILD_DIR) build-cmake diff --git a/scripts/ci/generate_coverage_report.sh b/scripts/ci/generate_coverage_report.sh new file mode 100755 index 0000000..a25a744 --- /dev/null +++ b/scripts/ci/generate_coverage_report.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${COVERAGE_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +OBJECT_DIR="${COVERAGE_OBJECT_DIR:-$ROOT/build-cmake/coverage}" +OUTPUT="${COVERAGE_OUTPUT:-$OBJECT_DIR/coverage.xml}" +FILTER="${COVERAGE_FILTER:-$ROOT/src/main/cpp}" +MIN_LINE="${COVERAGE_MIN_LINE:-85}" + +gcovr \ + --root "$ROOT" \ + --object-directory "$OBJECT_DIR" \ + --filter "$FILTER" \ + --gcov-ignore-parse-errors=negative_hits.warn \ + --fail-under-line "$MIN_LINE" \ + --xml-pretty \ + --output "$OUTPUT" From b1e6fd48b02d651c8b9e67fdd2b1d3c342f7f290 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 20:27:28 +0200 Subject: [PATCH 04/29] Update to Prebyte 1.1.1 --- CMakeLists.txt | 2 +- src/main/cpp/lua/LuaEngineInternal.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c0da2cd..1a1cebe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,7 +20,7 @@ endif() find_package(CLI11 CONFIG REQUIRED) -set(TEMPIFY_PREBYTE_VERSION "v1.0.0" CACHE STRING "Pinned Prebyte git tag") +set(TEMPIFY_PREBYTE_VERSION "v1.1.1" CACHE STRING "Pinned Prebyte git tag") set(TEMPIFY_PREBYTE_SOURCE_DIR "" CACHE PATH "Optional local Prebyte source root") set(PREBYTE_BUILD_TESTS OFF CACHE BOOL "Build Prebyte tests" FORCE) diff --git a/src/main/cpp/lua/LuaEngineInternal.h b/src/main/cpp/lua/LuaEngineInternal.h index 82a1792..9135ebc 100644 --- a/src/main/cpp/lua/LuaEngineInternal.h +++ b/src/main/cpp/lua/LuaEngineInternal.h @@ -3,7 +3,7 @@ #include "tempify/domain/BuildPlan.h" #include "tempify/domain/TemplateManifest.h" -#include "runtime/LuaHeaders.h" +#include "runtime/lua/LuaHeaders.h" #include #include From 53a3fe3294a5af0a7cebcd7381a572303c8c220b Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 20:32:48 +0200 Subject: [PATCH 05/29] Adding fuzzer and sanitizer --- .gitignore | 1 + CMakeLists.txt | 68 +++++++++++++ CMakePresets.json | 98 +++++++++++++++++++ Makefile | 23 ++++- scripts/ci/bootstrap_msan_libcxx.sh | 67 +++++++++++++ scripts/ci/run_fuzz_regression.sh | 47 +++++++++ scripts/ci/run_fuzzers.sh | 71 ++++++++++++++ scripts/ci/run_sanitize_tests.sh | 66 +++++++++++++ tests/fault_tolerance/fuzz/AnswerJsonFuzz.cpp | 17 ++++ tests/fault_tolerance/fuzz/CliParserFuzz.cpp | 51 ++++++++++ tests/fault_tolerance/fuzz/SlugFuzz.cpp | 11 +++ .../fuzz/seeds/answer_json/valid.json | 1 + .../fuzz/seeds/cli_parser/list.txt | 1 + .../fuzz/seeds/slug/hello_world.txt | 1 + 14 files changed, 522 insertions(+), 1 deletion(-) create mode 100755 scripts/ci/bootstrap_msan_libcxx.sh create mode 100755 scripts/ci/run_fuzz_regression.sh create mode 100755 scripts/ci/run_fuzzers.sh create mode 100755 scripts/ci/run_sanitize_tests.sh create mode 100644 tests/fault_tolerance/fuzz/AnswerJsonFuzz.cpp create mode 100644 tests/fault_tolerance/fuzz/CliParserFuzz.cpp create mode 100644 tests/fault_tolerance/fuzz/SlugFuzz.cpp create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_json/valid.json create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/list.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/slug/hello_world.txt diff --git a/.gitignore b/.gitignore index 86736a5..bf752ba 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ /compile_commands.json /scripts/ci/__pycache__/ /tests/test_workspace/ +/tests/fault_tolerance/fuzz/corpus/ /build-cmake/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a1cebe..2257983 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,13 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) option(TEMPIFY_ENABLE_COVERAGE "Enable GCC/GCov coverage instrumentation" OFF) +option(TEMPIFY_BUILD_FUZZERS "Build Tempify libFuzzer targets" OFF) +set(TEMPIFY_MSAN_LIBCXX_PREFIX "" + CACHE PATH "Install prefix of a MemorySanitizer-instrumented libc++ (required for reliable MSan runs)") + +if(TEMPIFY_MSAN_LIBCXX_PREFIX) + set(PREBYTE_MSAN_LIBCXX_PREFIX "${TEMPIFY_MSAN_LIBCXX_PREFIX}" CACHE PATH "MSan libc++ prefix" FORCE) +endif() if(MSVC AND NOT DEFINED CMAKE_MSVC_RUNTIME_LIBRARY) if(DEFINED VCPKG_TARGET_TRIPLET AND VCPKG_TARGET_TRIPLET MATCHES "windows-static") @@ -161,3 +168,64 @@ if(BUILD_TESTING) ENVIRONMENT "${TEMPIFY_TEST_ENVIRONMENT}" ) endif() + +if(TEMPIFY_BUILD_FUZZERS) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang|AppleClang") + message(FATAL_ERROR "TEMPIFY_BUILD_FUZZERS requires Clang with libFuzzer support") + endif() + + if(NOT PREBYTE_ENABLE_SANITIZERS) + message(FATAL_ERROR "TEMPIFY_BUILD_FUZZERS requires PREBYTE_ENABLE_SANITIZERS") + endif() + + if(NOT TARGET tempify) + message(FATAL_ERROR "TEMPIFY_BUILD_FUZZERS requires the tempify executable target") + endif() + + set(TEMPIFY_FUZZ_TEST_ENVIRONMENT + "TEMPIFY_TEST_BINARY=$;XDG_DATA_HOME=${CMAKE_SOURCE_DIR}/tests/test_workspace/.xdg-data;XDG_CONFIG_HOME=${CMAKE_SOURCE_DIR}/tests/test_workspace/.xdg-config") + + set(TEMPIFY_FUZZ_TARGETS + fuzz_slugify + fuzz_answer_json + fuzz_cli_parser + ) + + set(TEMPIFY_FUZZ_SOURCES + tests/fault_tolerance/fuzz/SlugFuzz.cpp + tests/fault_tolerance/fuzz/AnswerJsonFuzz.cpp + tests/fault_tolerance/fuzz/CliParserFuzz.cpp + ) + + list(LENGTH TEMPIFY_FUZZ_TARGETS TEMPIFY_FUZZ_TARGET_COUNT) + math(EXPR TEMPIFY_FUZZ_LAST_INDEX "${TEMPIFY_FUZZ_TARGET_COUNT} - 1") + + if(DEFINED ENV{TEMPIFY_FUZZ_MAX_TOTAL_TIME}) + set(TEMPIFY_FUZZ_MAX_TOTAL_TIME "$ENV{TEMPIFY_FUZZ_MAX_TOTAL_TIME}") + else() + set(TEMPIFY_FUZZ_MAX_TOTAL_TIME 60) + endif() + + foreach(TEMPIFY_FUZZ_INDEX RANGE ${TEMPIFY_FUZZ_LAST_INDEX}) + list(GET TEMPIFY_FUZZ_TARGETS ${TEMPIFY_FUZZ_INDEX} TEMPIFY_FUZZ_TARGET) + list(GET TEMPIFY_FUZZ_SOURCES ${TEMPIFY_FUZZ_INDEX} TEMPIFY_FUZZ_SOURCE) + + add_executable(${TEMPIFY_FUZZ_TARGET} ${TEMPIFY_FUZZ_SOURCE}) + target_link_libraries(${TEMPIFY_FUZZ_TARGET} PRIVATE tempify_core) + target_compile_options(${TEMPIFY_FUZZ_TARGET} PRIVATE -fsanitize=fuzzer) + target_link_options(${TEMPIFY_FUZZ_TARGET} PRIVATE -fsanitize=fuzzer) + + set(TEMPIFY_FUZZ_CORPUS "${CMAKE_SOURCE_DIR}/tests/fault_tolerance/fuzz/corpus/${TEMPIFY_FUZZ_TARGET}") + add_test( + NAME ${TEMPIFY_FUZZ_TARGET} + COMMAND ${TEMPIFY_FUZZ_TARGET} + -max_total_time=${TEMPIFY_FUZZ_MAX_TOTAL_TIME} + -close_fd_mask=3 + ${TEMPIFY_FUZZ_CORPUS} + ) + set_tests_properties(${TEMPIFY_FUZZ_TARGET} PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + ENVIRONMENT "${TEMPIFY_FUZZ_TEST_ENVIRONMENT}" + ) + endforeach() +endif() diff --git a/CMakePresets.json b/CMakePresets.json index fa05470..8328b4c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -28,6 +28,64 @@ "TEMPIFY_ENABLE_COVERAGE": "ON" } }, + { + "name": "sanitize", + "displayName": "Sanitizer Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/sanitize", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "BUILD_TESTING": "ON", + "PREBYTE_ENABLE_SANITIZERS": "ON", + "PREBYTE_SANITIZERS": "address,undefined" + } + }, + { + "name": "tsan", + "displayName": "ThreadSanitizer Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/tsan", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "BUILD_TESTING": "ON", + "PREBYTE_ENABLE_SANITIZERS": "ON", + "PREBYTE_SANITIZERS": "thread" + } + }, + { + "name": "msan", + "displayName": "MemorySanitizer Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/msan", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "BUILD_TESTING": "ON", + "PREBYTE_ENABLE_SANITIZERS": "ON", + "PREBYTE_SANITIZERS": "memory" + } + }, + { + "name": "fuzz", + "displayName": "Fuzzer Ninja", + "inherits": "dev", + "binaryDir": "${sourceDir}/build-cmake/fuzz", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", + "BUILD_TESTING": "OFF", + "TEMPIFY_BUILD_FUZZERS": "ON", + "PREBYTE_ENABLE_SANITIZERS": "ON", + "PREBYTE_SANITIZERS": "address,undefined" + } + }, { "name": "tidy", "displayName": "Clang-Tidy Ninja", @@ -56,6 +114,25 @@ "configurePreset": "coverage", "targets": ["tempify_tests", "tempify"] }, + { + "name": "sanitize-tests", + "configurePreset": "sanitize", + "targets": ["tempify_tests", "tempify"] + }, + { + "name": "tsan-tests", + "configurePreset": "tsan", + "targets": ["tempify_tests", "tempify"] + }, + { + "name": "msan-tests", + "configurePreset": "msan", + "targets": ["tempify_tests", "tempify"] + }, + { + "name": "fuzz", + "configurePreset": "fuzz" + }, { "name": "tidy-core", "configurePreset": "tidy", @@ -69,6 +146,27 @@ "output": { "outputOnFailure": true } + }, + { + "name": "sanitize", + "configurePreset": "sanitize", + "output": { + "outputOnFailure": true + } + }, + { + "name": "tsan", + "configurePreset": "tsan", + "output": { + "outputOnFailure": true + } + }, + { + "name": "msan", + "configurePreset": "msan", + "output": { + "outputOnFailure": true + } } ] } diff --git a/Makefile b/Makefile index 335d6f0..ec04e9b 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ -.PHONY: configure build start run test clean analyze lint security static-analysis format format-check coverage +.PHONY: configure build start run test clean analyze lint security static-analysis format format-check coverage sanitize tsan msan fuzz fuzz-regression BUILD_DIR ?= build CLANG_TIDY_BUILD_DIR := build-cmake/tidy COVERAGE_BUILD_DIR := build-cmake/coverage COVERAGE_MIN_LINE ?= 85 +TEMPIFY_FUZZ_MAX_TOTAL_TIME ?= 60 ifdef VCPKG_ROOT CMAKE_VCPKG_ARGS := -DCMAKE_TOOLCHAIN_FILE=$(VCPKG_ROOT)/scripts/buildsystems/vcpkg.cmake @@ -57,5 +58,25 @@ coverage: chmod +x scripts/ci/generate_coverage_report.sh COVERAGE_MIN_LINE=$(COVERAGE_MIN_LINE) ./scripts/ci/generate_coverage_report.sh +sanitize: + chmod +x scripts/ci/run_sanitize_tests.sh + ./scripts/ci/run_sanitize_tests.sh asan $(CMAKE_VCPKG_ARGS) + +tsan: + chmod +x scripts/ci/run_sanitize_tests.sh + ./scripts/ci/run_sanitize_tests.sh tsan $(CMAKE_VCPKG_ARGS) + +msan: + chmod +x scripts/ci/run_sanitize_tests.sh + ./scripts/ci/run_sanitize_tests.sh msan $(CMAKE_VCPKG_ARGS) + +fuzz: + chmod +x scripts/ci/run_fuzzers.sh + TEMPIFY_FUZZ_MAX_TOTAL_TIME=$(TEMPIFY_FUZZ_MAX_TOTAL_TIME) ./scripts/ci/run_fuzzers.sh $(CMAKE_VCPKG_ARGS) + +fuzz-regression: + chmod +x scripts/ci/run_fuzz_regression.sh + ./scripts/ci/run_fuzz_regression.sh + clean: rm -rf $(BUILD_DIR) build-cmake diff --git a/scripts/ci/bootstrap_msan_libcxx.sh b/scripts/ci/bootstrap_msan_libcxx.sh new file mode 100755 index 0000000..a8f86ea --- /dev/null +++ b/scripts/ci/bootstrap_msan_libcxx.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_PREFIX="${TEMPIFY_MSAN_LIBCXX_PREFIX:-${XDG_CACHE_HOME:-$HOME/.cache}/tempify-msan-libcxx}" +LLVM_TAG="${TEMPIFY_MSAN_LIBCXX_LLVM_TAG:-llvmorg-18.1.8}" +MARKER_FILE="$INSTALL_PREFIX/.bootstrap-complete" +WORK_DIR="${TEMPIFY_MSAN_LIBCXX_WORK_DIR:-${TMPDIR:-/tmp}/tempify-msan-libcxx-build}" +CONFIG_STAMP="tag=$LLVM_TAG runtimes=libcxx,libcxxabi unwinder=libgcc_s v2" + +usage() { + cat </dev/null 2>&1; then + printf 'Missing required tool for MSan libc++ bootstrap: %s\n' "$tool" >&2 + exit 1 + fi +done + +if [[ -d "$INSTALL_PREFIX" && "$INSTALL_PREFIX" != *tempify-msan-libcxx* && ! -f "$MARKER_FILE" ]]; then + printf 'Refusing to wipe unfamiliar TEMPIFY_MSAN_LIBCXX_PREFIX: %s\n' "$INSTALL_PREFIX" >&2 + exit 1 +fi +rm -rf "$INSTALL_PREFIX" +mkdir -p "$INSTALL_PREFIX" "$WORK_DIR" +LLVM_SRC="$WORK_DIR/llvm-project" + +if [[ ! -d "$LLVM_SRC/.git" ]]; then + git clone --depth 1 --branch "$LLVM_TAG" https://github.com/llvm/llvm-project.git "$LLVM_SRC" +fi + +BUILD_DIR="$WORK_DIR/build" +rm -rf "$BUILD_DIR" +cmake -G Ninja -S "$LLVM_SRC/runtimes" -B "$BUILD_DIR" \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_C_COMPILER=clang \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi" \ + -DLLVM_USE_SANITIZER=Memory \ + -DLIBCXXABI_USE_LLVM_UNWINDER=OFF \ + -DLLVM_ENABLE_PER_TARGET_RUNTIME_DIR=OFF \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_PREFIX" \ + -DLIBCXX_INSTALL_MODULES=OFF + +cmake --build "$BUILD_DIR" --target install-cxx install-cxxabi --parallel +{ date -u +%Y-%m-%dT%H:%M:%SZ; printf '%s\n' "$CONFIG_STAMP"; } >"$MARKER_FILE" +printf 'Installed MSan libc++ to %s\n' "$INSTALL_PREFIX" diff --git a/scripts/ci/run_fuzz_regression.sh b/scripts/ci/run_fuzz_regression.sh new file mode 100755 index 0000000..4339dde --- /dev/null +++ b/scripts/ci/run_fuzz_regression.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${FUZZ_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +BUILD_DIR="${FUZZ_BUILD_DIR:-$ROOT/build-cmake/fuzz}" +REGRESSION_DIR="$ROOT/tests/fault_tolerance/fuzz/regression" + +if [[ ! -d "$REGRESSION_DIR" ]]; then + printf 'No fuzz regression directory at %s; skipping replay.\n' "$REGRESSION_DIR" + exit 0 +fi + +if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then + printf 'Fuzz build not found at %s. Configure with cmake --preset fuzz first.\n' "$BUILD_DIR" >&2 + exit 1 +fi + +replayed=0 +for target_dir in "$REGRESSION_DIR"/*/; do + [[ -d "$target_dir" ]] || continue + target=$(basename "$target_dir") + fuzzer="$BUILD_DIR/$target" + if [[ ! -x "$fuzzer" ]]; then + printf 'Missing fuzzer binary: %s\n' "$fuzzer" >&2 + exit 1 + fi + + shopt -s nullglob + entries=("$target_dir"/*) + if [[ ${#entries[@]} -eq 0 ]]; then + continue + fi + + for input in "${entries[@]}"; do + [[ -f "$input" ]] || continue + printf 'Replaying %s <= %s\n' "$target" "$(basename "$input")" + "$fuzzer" "$input" -runs=1 + replayed=$((replayed + 1)) + done +done + +if [[ "$replayed" -eq 0 ]]; then + printf 'No fuzz regression inputs found under %s\n' "$REGRESSION_DIR" + exit 0 +fi + +printf 'Replayed %s regression input(s) successfully.\n' "$replayed" diff --git a/scripts/ci/run_fuzzers.sh b/scripts/ci/run_fuzzers.sh new file mode 100755 index 0000000..3f6d3c7 --- /dev/null +++ b/scripts/ci/run_fuzzers.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${FUZZ_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +BUILD_DIR="${FUZZ_BUILD_DIR:-$ROOT/build-cmake/fuzz}" +CMAKE_PRESET="${FUZZ_PRESET:-fuzz}" +BUILD_PRESET="${FUZZ_BUILD_PRESET:-fuzz}" +MAX_TOTAL_TIME="${TEMPIFY_FUZZ_MAX_TOTAL_TIME:-60}" + +FUZZ_TARGETS=( + fuzz_slugify + fuzz_answer_json + fuzz_cli_parser +) + +seed_dir_for_target() { + case "$1" in + fuzz_slugify) + printf '%s/tests/fault_tolerance/fuzz/seeds/slug\n' "$ROOT" + ;; + fuzz_answer_json) + printf '%s/tests/fault_tolerance/fuzz/seeds/answer_json\n' "$ROOT" + ;; + fuzz_cli_parser) + printf '%s/tests/fault_tolerance/fuzz/seeds/cli_parser\n' "$ROOT" + ;; + *) + printf 'Unknown fuzz target: %s\n' "$1" >&2 + return 1 + ;; + esac +} + +bootstrap_corpus() { + local target=$1 + local corpus="$ROOT/tests/fault_tolerance/fuzz/corpus/$target" + local seeds_dir + seeds_dir=$(seed_dir_for_target "$target") + + printf 'Bootstrapping corpus for %s\n' "$target" + mkdir -p "$corpus" + + shopt -s nullglob + for seed_file in "$seeds_dir"/*; do + [[ -f "$seed_file" ]] || continue + cp -n "$seed_file" "$corpus/$(basename "$seed_file")" + done +} + +if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then + cmake --preset "$CMAKE_PRESET" "$@" +fi + +cmake --build --preset "$BUILD_PRESET" --parallel + +export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=1:abort_on_error=1:detect_stack_use_after_return=1}" +export UBSAN_OPTIONS="${UBSAN_OPTIONS:-print_stacktrace=1:halt_on_error=1}" + +for target in "${FUZZ_TARGETS[@]}"; do + bootstrap_corpus "$target" + corpus="$ROOT/tests/fault_tolerance/fuzz/corpus/$target" + printf 'Running fuzzer %s for %ss\n' "$target" "$MAX_TOTAL_TIME" + "$BUILD_DIR/$target" \ + -max_total_time="$MAX_TOTAL_TIME" \ + -close_fd_mask=3 \ + "$corpus" +done + +chmod +x "$ROOT/scripts/ci/run_fuzz_regression.sh" +printf 'Running fuzz regression replay\n' +"$ROOT/scripts/ci/run_fuzz_regression.sh" diff --git a/scripts/ci/run_sanitize_tests.sh b/scripts/ci/run_sanitize_tests.sh new file mode 100755 index 0000000..c3aa0f0 --- /dev/null +++ b/scripts/ci/run_sanitize_tests.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${SANITIZE_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +usage() { + cat < [cmake configure args...] + +Runs the test suite with the selected Clang sanitizer preset. +EOF +} + +SANITIZER_KIND="${1:-}" +if [[ -z "$SANITIZER_KIND" ]]; then + usage >&2 + exit 2 +fi +shift + +case "$SANITIZER_KIND" in + asan) + BUILD_DIR="${SANITIZE_BUILD_DIR:-$ROOT/build-cmake/sanitize}" + CMAKE_PRESET="${SANITIZE_PRESET:-sanitize}" + BUILD_PRESET="${SANITIZE_BUILD_PRESET:-sanitize-tests}" + TEST_PRESET="${SANITIZE_TEST_PRESET:-sanitize}" + unset TSAN_OPTIONS MSAN_OPTIONS + export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=1:abort_on_error=1:detect_stack_use_after_return=1}" + export UBSAN_OPTIONS="${UBSAN_OPTIONS:-print_stacktrace=1:halt_on_error=1}" + ;; + tsan) + BUILD_DIR="${SANITIZE_BUILD_DIR:-$ROOT/build-cmake/tsan}" + CMAKE_PRESET="${SANITIZE_PRESET:-tsan}" + BUILD_PRESET="${SANITIZE_BUILD_PRESET:-tsan-tests}" + TEST_PRESET="${SANITIZE_TEST_PRESET:-tsan}" + unset ASAN_OPTIONS UBSAN_OPTIONS MSAN_OPTIONS + export TSAN_OPTIONS="${TSAN_OPTIONS:-halt_on_error=1:history_size=7:second_deadlock_stack=1}" + ;; + msan) + BUILD_DIR="${SANITIZE_BUILD_DIR:-$ROOT/build-cmake/msan}" + CMAKE_PRESET="${SANITIZE_PRESET:-msan}" + BUILD_PRESET="${SANITIZE_BUILD_PRESET:-msan-tests}" + TEST_PRESET="${SANITIZE_TEST_PRESET:-msan}" + unset ASAN_OPTIONS UBSAN_OPTIONS TSAN_OPTIONS + export MSAN_OPTIONS="${MSAN_OPTIONS:-halt_on_error=1:print_stats=1}" + export TEMPIFY_MSAN_LIBCXX_PREFIX="${TEMPIFY_MSAN_LIBCXX_PREFIX:-${XDG_CACHE_HOME:-$HOME/.cache}/tempify-msan-libcxx}" + chmod +x "$ROOT/scripts/ci/bootstrap_msan_libcxx.sh" + "$ROOT/scripts/ci/bootstrap_msan_libcxx.sh" + ;; + *) + usage >&2 + exit 2 + ;; +esac + +CMAKE_EXTRA_ARGS=("$@") +if [[ "$SANITIZER_KIND" == "msan" ]]; then + CMAKE_EXTRA_ARGS=(-DTEMPIFY_MSAN_LIBCXX_PREFIX="$TEMPIFY_MSAN_LIBCXX_PREFIX" "$@") +fi + +if [[ "$SANITIZER_KIND" == "msan" ]] || [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then + cmake --preset "$CMAKE_PRESET" "${CMAKE_EXTRA_ARGS[@]}" +fi + +cmake --build --preset "$BUILD_PRESET" --parallel +ctest --preset "$TEST_PRESET" --output-on-failure diff --git a/tests/fault_tolerance/fuzz/AnswerJsonFuzz.cpp b/tests/fault_tolerance/fuzz/AnswerJsonFuzz.cpp new file mode 100644 index 0000000..ba39395 --- /dev/null +++ b/tests/fault_tolerance/fuzz/AnswerJsonFuzz.cpp @@ -0,0 +1,17 @@ +#include "parser/JsonParser.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + + try { + prebyte::JsonParser parser; + (void)parser.parse_string(input); + } catch (const std::exception &) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/CliParserFuzz.cpp b/tests/fault_tolerance/fuzz/CliParserFuzz.cpp new file mode 100644 index 0000000..608f529 --- /dev/null +++ b/tests/fault_tolerance/fuzz/CliParserFuzz.cpp @@ -0,0 +1,51 @@ +#include "tempify/cli/CliParser.h" +#include "tempify/support/Errors.h" + +#include +#include +#include +#include + +namespace { + +std::vector tokenize_args(const std::uint8_t *data, std::size_t size) { + std::vector args; + std::string current; + + for (std::size_t index = 0; index < size; ++index) { + const char ch = static_cast(data[index]); + if (ch == '\0' || ch == '\n' || ch == '\r' || ch == '\t' || ch == ' ') { + if (!current.empty()) { + args.push_back(std::move(current)); + current.clear(); + } + continue; + } + + current.push_back(ch); + } + + if (!current.empty()) { + args.push_back(std::move(current)); + } + + if (args.empty()) { + args.emplace_back("list"); + } + + return args; +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size) { + const tempify::CliParser parser; + + try { + (void)parser.parse(tokenize_args(data, size)); + } catch (const tempify::TempifyError &) { + } catch (const std::exception &) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/SlugFuzz.cpp b/tests/fault_tolerance/fuzz/SlugFuzz.cpp new file mode 100644 index 0000000..610df55 --- /dev/null +++ b/tests/fault_tolerance/fuzz/SlugFuzz.cpp @@ -0,0 +1,11 @@ +#include "tempify/support/Slug.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + (void)tempify::slugify(input); + return 0; +} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_json/valid.json b/tests/fault_tolerance/fuzz/seeds/answer_json/valid.json new file mode 100644 index 0000000..6d18c8f --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_json/valid.json @@ -0,0 +1 @@ +{"project_name":"demo","enable_ci":true} diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/list.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/list.txt new file mode 100644 index 0000000..b92b8b7 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/list.txt @@ -0,0 +1 @@ +list diff --git a/tests/fault_tolerance/fuzz/seeds/slug/hello_world.txt b/tests/fault_tolerance/fuzz/seeds/slug/hello_world.txt new file mode 100644 index 0000000..17b06c2 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/slug/hello_world.txt @@ -0,0 +1 @@ +Hello World Template From 44be6c703d77a881b74e450ac54274768545aeed Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 20:43:58 +0200 Subject: [PATCH 06/29] Fixing msan --- CMakeLists.txt | 23 +++++++- Makefile | 2 +- cmake/vcpkg/triplets/x64-linux-msan.cmake | 8 +++ scripts/ci/bootstrap_vcpkg_deps.sh | 53 ++++++++++++++++++ scripts/ci/run_sanitize_tests.sh | 14 ++++- .../files/docs/readme.txt.pbc | Bin 580 -> 554 bytes .../basic_cpp/files/CMakeLists.txt.pbc | Bin 744 -> 718 bytes .../basic_cpp/files/README.md.pbc | Bin 1331 -> 1305 bytes .../basic_cpp/files/src/main.cpp.pbc | Bin 602 -> 576 bytes .../files/.github/workflows/ci.yml.pbc | Bin 690 -> 664 bytes .../layered_ci_overlay/files/README.md.pbc | Bin 887 -> 861 bytes .../files/src/main.cpp.pbc | Bin 707 -> 681 bytes 12 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 cmake/vcpkg/triplets/x64-linux-msan.cmake create mode 100755 scripts/ci/bootstrap_vcpkg_deps.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index 2257983..1200892 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,7 +79,7 @@ list(FILTER TEMPIFY_SOURCES EXCLUDE REGEX ".*/main\\.cpp$") add_library(tempify_core STATIC ${TEMPIFY_SOURCES}) target_include_directories(tempify_core PUBLIC src/main/include) -target_link_libraries(tempify_core PUBLIC prebyte_core PRIVATE CLI11::CLI11) +target_link_libraries(tempify_core PUBLIC prebyte_core CLI11::CLI11) add_executable(tempify src/main/cpp/main.cpp) target_link_libraries(tempify PRIVATE tempify_core) @@ -98,6 +98,23 @@ endif() install(TARGETS tempify RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) install(FILES README.md LICENSE DESTINATION ${CMAKE_INSTALL_DATADIR}/tempify) +if(TEMPIFY_MSAN_LIBCXX_PREFIX AND EXISTS "${TEMPIFY_MSAN_LIBCXX_PREFIX}/lib/libc++.so") + function(tempify_apply_msan_libcxx target) + if(NOT TARGET ${target}) + return() + endif() + target_link_directories(${target} PRIVATE "${TEMPIFY_MSAN_LIBCXX_PREFIX}/lib") + target_link_options(${target} PRIVATE + "LINKER:-rpath,${TEMPIFY_MSAN_LIBCXX_PREFIX}/lib" + -lc++ + -lc++abi + ) + endfunction() + + tempify_apply_msan_libcxx(tempify_core) + tempify_apply_msan_libcxx(tempify) +endif() + if(BUILD_TESTING) file(GLOB TEMPIFY_TEST_SOURCES CONFIGURE_DEPENDS tests/tempify/*.cpp) set(TEMPIFY_TEST_ENVIRONMENT @@ -113,6 +130,10 @@ if(BUILD_TESTING) target_link_libraries(tempify_tests PRIVATE tempify_core) add_dependencies(tempify_tests tempify) + if(TEMPIFY_MSAN_LIBCXX_PREFIX AND EXISTS "${TEMPIFY_MSAN_LIBCXX_PREFIX}/lib/libc++.so") + tempify_apply_msan_libcxx(tempify_tests) + endif() + if(TEMPIFY_ENABLE_COVERAGE) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") target_compile_options(tempify_tests PRIVATE --coverage -O0 -g) diff --git a/Makefile b/Makefile index ec04e9b..adbe052 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,7 @@ tsan: msan: chmod +x scripts/ci/run_sanitize_tests.sh - ./scripts/ci/run_sanitize_tests.sh msan $(CMAKE_VCPKG_ARGS) + ./scripts/ci/run_sanitize_tests.sh msan fuzz: chmod +x scripts/ci/run_fuzzers.sh diff --git a/cmake/vcpkg/triplets/x64-linux-msan.cmake b/cmake/vcpkg/triplets/x64-linux-msan.cmake new file mode 100644 index 0000000..ef33015 --- /dev/null +++ b/cmake/vcpkg/triplets/x64-linux-msan.cmake @@ -0,0 +1,8 @@ +set(VCPKG_TARGET_ARCHITECTURE x64) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE dynamic) +set(VCPKG_CMAKE_SYSTEM_NAME Linux) + +set(VCPKG_CXX_FLAGS "-fsanitize=memory -fno-omit-frame-pointer -g -fsanitize-memory-track-origins=1") +set(VCPKG_C_FLAGS "-fsanitize=memory -fno-omit-frame-pointer -g -fsanitize-memory-track-origins=1") +set(VCPKG_LINKER_FLAGS "-fsanitize=memory") diff --git a/scripts/ci/bootstrap_vcpkg_deps.sh b/scripts/ci/bootstrap_vcpkg_deps.sh new file mode 100755 index 0000000..e73d57a --- /dev/null +++ b/scripts/ci/bootstrap_vcpkg_deps.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${TEMPIFY_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +SANITIZER_KIND="${1:-asan}" +shift || true + +OVERLAY_TRIPLETS="${VCPKG_OVERLAY_TRIPLETS:-$ROOT/cmake/vcpkg/triplets}" +VCPKG_TRIPLET="${VCPKG_TARGET_TRIPLET:-x64-linux}" + +case "$SANITIZER_KIND" in + msan) + export CC=clang + export CXX=clang++ + VCPKG_TRIPLET=x64-linux-msan + VCPKG_ROOT="$ROOT/build-cmake/.vcpkg-msan" + MSAN_LIBCXX_PREFIX="${TEMPIFY_MSAN_LIBCXX_PREFIX:-${XDG_CACHE_HOME:-$HOME/.cache}/tempify-msan-libcxx}" + if [[ ! -f "$MSAN_LIBCXX_PREFIX/lib/libc++.so" ]]; then + chmod +x "$ROOT/scripts/ci/bootstrap_msan_libcxx.sh" + TEMPIFY_MSAN_LIBCXX_PREFIX="$MSAN_LIBCXX_PREFIX" "$ROOT/scripts/ci/bootstrap_msan_libcxx.sh" + fi + export CXXFLAGS="-stdlib=libc++ -nostdinc++ -isystem ${MSAN_LIBCXX_PREFIX}/include/c++/v1 -fsanitize=memory -fno-omit-frame-pointer -g -fsanitize-memory-track-origins=1" + export CFLAGS="-fsanitize=memory -fno-omit-frame-pointer -g -fsanitize-memory-track-origins=1" + export LDFLAGS="-stdlib=libc++ -L${MSAN_LIBCXX_PREFIX}/lib -lc++ -lc++abi -fsanitize=memory -Wl,-rpath,${MSAN_LIBCXX_PREFIX}/lib" + ;; + asan|tsan|fuzz|coverage) + VCPKG_TRIPLET="${VCPKG_TARGET_TRIPLET:-x64-linux}" + VCPKG_ROOT="${VCPKG_ROOT:-$ROOT/build-cmake/.vcpkg-ci}" + ;; + *) + printf 'Unsupported sanitizer kind for vcpkg bootstrap: %s\n' "$SANITIZER_KIND" >&2 + exit 1 + ;; +esac + +if [[ ! -x "$VCPKG_ROOT/vcpkg" ]]; then + git clone --depth 1 https://github.com/microsoft/vcpkg.git "$VCPKG_ROOT" >&2 + "$VCPKG_ROOT/bootstrap-vcpkg.sh" -disableMetrics >&2 +fi + +"$VCPKG_ROOT/vcpkg" install \ + "lua:${VCPKG_TRIPLET}" \ + "cli11:${VCPKG_TRIPLET}" \ + --overlay-triplets="$OVERLAY_TRIPLETS" \ + --binarysource=clear \ + "$@" >&2 + +ENV_FILE="$ROOT/build-cmake/.vcpkg-${SANITIZER_KIND}.env" +mkdir -p "$(dirname "$ENV_FILE")" +{ + printf 'export VCPKG_ROOT=%q\n' "$VCPKG_ROOT" + printf 'export VCPKG_TARGET_TRIPLET=%q\n' "$VCPKG_TRIPLET" +} >"$ENV_FILE" diff --git a/scripts/ci/run_sanitize_tests.sh b/scripts/ci/run_sanitize_tests.sh index c3aa0f0..d1b9e39 100755 --- a/scripts/ci/run_sanitize_tests.sh +++ b/scripts/ci/run_sanitize_tests.sh @@ -44,8 +44,12 @@ case "$SANITIZER_KIND" in unset ASAN_OPTIONS UBSAN_OPTIONS TSAN_OPTIONS export MSAN_OPTIONS="${MSAN_OPTIONS:-halt_on_error=1:print_stats=1}" export TEMPIFY_MSAN_LIBCXX_PREFIX="${TEMPIFY_MSAN_LIBCXX_PREFIX:-${XDG_CACHE_HOME:-$HOME/.cache}/tempify-msan-libcxx}" - chmod +x "$ROOT/scripts/ci/bootstrap_msan_libcxx.sh" + chmod +x "$ROOT/scripts/ci/bootstrap_msan_libcxx.sh" "$ROOT/scripts/ci/bootstrap_vcpkg_deps.sh" "$ROOT/scripts/ci/bootstrap_msan_libcxx.sh" + rm -rf "$ROOT/build-cmake/.vcpkg-msan" + "$ROOT/scripts/ci/bootstrap_vcpkg_deps.sh" msan + # shellcheck disable=SC1091 + source "$ROOT/build-cmake/.vcpkg-msan.env" ;; *) usage >&2 @@ -55,7 +59,13 @@ esac CMAKE_EXTRA_ARGS=("$@") if [[ "$SANITIZER_KIND" == "msan" ]]; then - CMAKE_EXTRA_ARGS=(-DTEMPIFY_MSAN_LIBCXX_PREFIX="$TEMPIFY_MSAN_LIBCXX_PREFIX" "$@") + CMAKE_EXTRA_ARGS=( + -DTEMPIFY_MSAN_LIBCXX_PREFIX="$TEMPIFY_MSAN_LIBCXX_PREFIX" + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" + -DVCPKG_TARGET_TRIPLET=x64-linux-msan + -DVCPKG_OVERLAY_TRIPLETS="$ROOT/cmake/vcpkg/triplets" + "$@" + ) fi if [[ "$SANITIZER_KIND" == "msan" ]] || [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then diff --git a/tests/test_templates/advanced_hooks_layout/files/docs/readme.txt.pbc b/tests/test_templates/advanced_hooks_layout/files/docs/readme.txt.pbc index 4bfe8e60a3187dc52959f18109126a05fcb4451d..4c99123992fef61a16954d887408df2aff1c7ad8 100644 GIT binary patch delta 88 zcmX@YvWi7Az{%N=gMope2#EDF@^e%5eNyvN@{1BDUuR4e#40+OgHeQ0XR;*YVyyDH E075ewA^-pY delta 118 zcmZ3*a)d=Wz{%N=je&uo28i`D@^e%5eNyvN@{1CS^HToXo?x R0Kbw{j@v3sK^&4BBmjruCV>C| diff --git a/tests/test_templates/basic_cpp/files/CMakeLists.txt.pbc b/tests/test_templates/basic_cpp/files/CMakeLists.txt.pbc index e2c86c1c7ef1a2f6e94584c33141e62586655084..afd73a48464985405718fec504f5b4c76fde0241 100644 GIT binary patch delta 87 zcmaFCdX7~xz{%N=gMon|6^QjS@^e%5eNyvN@{1BDcQ8c=VileEPK5FF#{YSYSfvvH DVe=b> delta 115 zcmX@d`hrzCz{%N=je&t7ABgoc@^e%5eNyvN@{1CS^@xKkM;V@^e%5eNyvN@{1CS^sMngszKbAx2U`eau)AbSnqHGXn delta 74 zcmZ3Mv>$z(^S1V#*ziLXR9GcoEhV#s6w00i6> Aq5uE@ From dddc02559bf9844a3a68161841ca236f089dffd0 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 20:53:55 +0200 Subject: [PATCH 07/29] Adding more tests --- CMakeLists.txt | 19 +- tests/README.md | 35 ++ .../e2e/TempifyParallelCliE2ETests.cpp | 102 ++++++ .../e2e/TempifyWorkflowE2ETests.cpp | 125 +++++++ .../cli/TempifyCliBinaryE2ETests.cpp | 78 ++++ .../security/e2e/TempifySecurityE2ETests.cpp | 113 ++++++ tests/support/CliProcess.cpp | 346 ++++++++++++++++++ tests/support/CliProcess.h | 25 ++ tests/support/E2ETestSupport.h | 33 ++ 9 files changed, 874 insertions(+), 2 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp create mode 100644 tests/correctness/e2e/TempifyWorkflowE2ETests.cpp create mode 100644 tests/portability/cli/TempifyCliBinaryE2ETests.cpp create mode 100644 tests/security/e2e/TempifySecurityE2ETests.cpp create mode 100644 tests/support/CliProcess.cpp create mode 100644 tests/support/CliProcess.h create mode 100644 tests/support/E2ETestSupport.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1200892..60e2b11 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -116,17 +116,32 @@ if(TEMPIFY_MSAN_LIBCXX_PREFIX AND EXISTS "${TEMPIFY_MSAN_LIBCXX_PREFIX}/lib/libc endif() if(BUILD_TESTING) - file(GLOB TEMPIFY_TEST_SOURCES CONFIGURE_DEPENDS tests/tempify/*.cpp) + set(TEMPIFY_TEST_GLOBS + tests/tempify/*.cpp + tests/correctness/e2e/*.cpp + tests/security/e2e/*.cpp + tests/portability/cli/*.cpp + tests/concurrency/e2e/*.cpp + ) + set(TEMPIFY_TEST_SOURCES) + foreach(TEMPIFY_TEST_GLOB IN LISTS TEMPIFY_TEST_GLOBS) + file(GLOB TEMPIFY_TEST_GLOB_SOURCES CONFIGURE_DEPENDS ${TEMPIFY_TEST_GLOB}) + list(APPEND TEMPIFY_TEST_SOURCES ${TEMPIFY_TEST_GLOB_SOURCES}) + endforeach() set(TEMPIFY_TEST_ENVIRONMENT "TEMPIFY_TEST_BINARY=$;XDG_DATA_HOME=${CMAKE_SOURCE_DIR}/tests/test_workspace/.xdg-data;XDG_CONFIG_HOME=${CMAKE_SOURCE_DIR}/tests/test_workspace/.xdg-config") add_executable(tempify_tests tests/support/TestHarness.cpp tests/support/TestMain.cpp + tests/support/CliProcess.cpp ${TEMPIFY_TEST_SOURCES} ) target_include_directories(tempify_tests PRIVATE tests/support src/main/include) - target_compile_definitions(tempify_tests PRIVATE TEMPIFY_SOURCE_DIR="${CMAKE_SOURCE_DIR}") + target_compile_definitions(tempify_tests PRIVATE + TEMPIFY_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + TEMPIFY_CLI_BINARY="$" + ) target_link_libraries(tempify_tests PRIVATE tempify_core) add_dependencies(tempify_tests tempify) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..a842c27 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,35 @@ +# Tempify Test Layout + +Tests are grouped by non-functional quality attributes (NFR). Each folder targets a specific concern while sharing the same harness in `tests/support/`. + +## Structure + +| Folder | Purpose | +|--------|---------| +| `tests/tempify/` | Core unit and in-process integration tests | +| `tests/correctness/e2e/` | End-to-end workflows (render, validate, diff, reapply) | +| `tests/portability/cli/` | Real CLI subprocess tests (binary invocation) | +| `tests/security/e2e/` | Hook sandboxing, sensitive data, path safety | +| `tests/concurrency/e2e/` | Parallel CLI execution | +| `tests/fault_tolerance/fuzz/` | LibFuzzer targets and regression seeds | + +## Running + +```bash +make test +``` + +Individual tests are discovered from `TEST_CASE(...)` declarations and can be run via CTest: + +```bash +ctest --test-dir build-cmake/dev -R TempifyCliBinary_help +``` + +## E2E subprocess tests + +Subprocess tests use `tests/support/CliProcess.{h,cpp}` and resolve the binary from: + +- `TEMPIFY_TEST_BINARY` (set automatically by CMake) +- `TEMPIFY_CLI_BINARY` compile-time fallback + +Working directory and isolated `XDG_DATA_HOME` / `XDG_CONFIG_HOME` are configured per test via `tests/support/E2ETestSupport.h`. diff --git a/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp b/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp new file mode 100644 index 0000000..0776fc4 --- /dev/null +++ b/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp @@ -0,0 +1,102 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include +#include +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::e2e::slug_to_namespace; +using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; + +std::vector parallel_render_args(const std::filesystem::path &target, const std::string &slug) { + return { + "basic_cpp", + target.string(), + "--set", + "project_name=Parallel " + slug, + "--set", + "name_slug=" + slug, + "--set", + "namespace=" + slug_to_namespace(slug), + "--set", + "include_ci=false", + "--set", + "author=Parallel Tester", + }; +} + +} // namespace + +TEST_CASE(TempifyConcurrencyE2E_parallel_renders_succeed_in_subprocesses) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + constexpr int parallel_count = 4; + std::array target_paths; + std::array targets{{ + ScopedDirectoryCleanup(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-0"), + ScopedDirectoryCleanup(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-1"), + ScopedDirectoryCleanup(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-2"), + ScopedDirectoryCleanup(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-3"), + }}; + for (int index = 0; index < parallel_count; ++index) { + target_paths[static_cast(index)] = targets[static_cast(index)].path(); + } + + std::vector> futures; + futures.reserve(parallel_count); + for (int index = 0; index < parallel_count; ++index) { + const std::string slug = "parallel-" + std::to_string(index); + futures.push_back(std::async(std::launch::async, [workspace_path = workspace.path(), env, args = parallel_render_args(target_paths[static_cast(index)], slug)]() { + return run_cli(args, workspace_path, env); + })); + } + + for (int index = 0; index < parallel_count; ++index) { + const ProcessResult result = futures[static_cast(index)].get(); + REQUIRE_EQ(result.exit_code, 0); + const std::filesystem::path target_path = target_paths[static_cast(index)]; + REQUIRE(std::filesystem::is_regular_file(target_path / "README.md")); + REQUIRE(read_text_file(target_path / "README.md").find("# Parallel parallel-") != std::string::npos); + } +} + +TEST_CASE(TempifyConcurrencyE2E_parallel_list_and_inspect_do_not_interfere) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-concurrency-catalog-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-concurrency-catalog-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + + auto list_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"list"}, workspace_path, env); + }); + auto inspect_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"inspect", "basic_cpp", "--json"}, workspace_path, env); + }); + auto doctor_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"doctor"}, workspace_path, env); + }); + + const ProcessResult list = list_future.get(); + const ProcessResult inspect = inspect_future.get(); + const ProcessResult doctor = doctor_future.get(); + + REQUIRE_EQ(list.exit_code, 0); + REQUIRE(list.stdout_text.find("basic_cpp") != std::string::npos); + REQUIRE_EQ(inspect.exit_code, 0); + REQUIRE(inspect.stdout_text.find("\"template_id\": \"basic_cpp\"") != std::string::npos); + REQUIRE_EQ(doctor.exit_code, 0); +} diff --git a/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp b/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp new file mode 100644 index 0000000..6a67a8b --- /dev/null +++ b/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp @@ -0,0 +1,125 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::e2e::slug_to_namespace; +using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; +using tempify::test_support::write_text_file; + +std::vector basic_cpp_render_args(const std::filesystem::path &target, const std::string &slug) { + return { + "basic_cpp", + target.string(), + "--set", + "project_name=Workflow " + slug, + "--set", + "name_slug=" + slug, + "--set", + "namespace=" + slug_to_namespace(slug), + "--set", + "include_ci=false", + "--set", + "author=Workflow Tester", + }; +} + +} // namespace + +TEST_CASE(TempifyWorkflowE2E_render_validate_and_inspect_round_trip) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-workflow-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-workflow-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-workflow-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + const ProcessResult render = run_cli(basic_cpp_render_args(target.path(), "workflow-e2e"), workspace.path(), env); + REQUIRE_EQ(render.exit_code, 0); + REQUIRE(std::filesystem::is_regular_file(target.path() / ".tempify-lock.json")); + + const ProcessResult validate = run_cli({"validate", "basic_cpp"}, workspace.path(), env); + REQUIRE_EQ(validate.exit_code, 0); + + const ProcessResult inspect = run_cli({"inspect", "basic_cpp", "--json"}, workspace.path(), env); + REQUIRE_EQ(inspect.exit_code, 0); + REQUIRE(inspect.stdout_text.find("\"template_id\": \"basic_cpp\"") != std::string::npos); +} + +TEST_CASE(TempifyWorkflowE2E_answers_file_enables_second_render) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-workflow-answers-workspace"); + ScopedDirectoryCleanup answers_root(std::filesystem::temp_directory_path() / "tempify-workflow-answers-root"); + ScopedDirectoryCleanup first_target(std::filesystem::temp_directory_path() / "tempify-workflow-answers-first"); + ScopedDirectoryCleanup second_target(std::filesystem::temp_directory_path() / "tempify-workflow-answers-second"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-workflow-answers-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path answers_file = answers_root.path() / "answers.json"; + + std::vector first_args = basic_cpp_render_args(first_target.path(), "answers-first"); + first_args.push_back("--write-answers"); + first_args.push_back(answers_file.string()); + REQUIRE_EQ(run_cli(first_args, workspace.path(), env).exit_code, 0); + REQUIRE(std::filesystem::is_regular_file(answers_file)); + + std::vector second_args = { + "basic_cpp", + second_target.path().string(), + "--answers", + answers_file.string(), + "--set", + "author=Workflow Tester", + "--non-interactive", + "--strict", + }; + REQUIRE_EQ(run_cli(second_args, workspace.path(), env).exit_code, 0); + REQUIRE(read_text_file(second_target.path() / "README.md").find("# Workflow answers-first") != std::string::npos); +} + +TEST_CASE(TempifyWorkflowE2E_reapply_updates_safe_files_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-workflow-reapply-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-workflow-reapply-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-workflow-reapply-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "reapply-e2e"), workspace.path(), env).exit_code, 0); + + write_text_file(target.path() / "README.md", "# Local edit\n"); + const std::filesystem::path summary_path = target.path() / ".tempify-summary.txt"; + REQUIRE(std::filesystem::is_regular_file(summary_path)); + + std::vector reapply_args = basic_cpp_render_args(target.path(), "reapply-e2e"); + reapply_args.push_back("--reapply"); + const ProcessResult reapply = run_cli(reapply_args, workspace.path(), env); + REQUIRE_EQ(reapply.exit_code, 0); + REQUIRE(read_text_file(target.path() / "README.md").find("# Local edit") != std::string::npos); + REQUIRE(reapply.stdout_text.find("Hooks: skipped") != std::string::npos + || reapply.stdout_text.find("reapply") != std::string::npos); +} + +TEST_CASE(TempifyWorkflowE2E_diff_reports_changes_after_manual_edit) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-workflow-diff-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-workflow-diff-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-workflow-diff-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "diff-e2e"), workspace.path(), env).exit_code, 0); + write_text_file(target.path() / "README.md", "# Changed locally\n"); + + std::vector diff_args = basic_cpp_render_args(target.path(), "diff-e2e"); + diff_args.push_back("--diff"); + const ProcessResult diff = run_cli(diff_args, workspace.path(), env); + REQUIRE_EQ(diff.exit_code, 0); + REQUIRE(diff.stdout_text.find("README.md") != std::string::npos || diff.stdout_text.find("Diff ") != std::string::npos); +} diff --git a/tests/portability/cli/TempifyCliBinaryE2ETests.cpp b/tests/portability/cli/TempifyCliBinaryE2ETests.cpp new file mode 100644 index 0000000..f9f7136 --- /dev/null +++ b/tests/portability/cli/TempifyCliBinaryE2ETests.cpp @@ -0,0 +1,78 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyConfigHome; +using tempify::test_support::ScopedTempifyDataHome; + +} // namespace + +TEST_CASE(TempifyCliBinary_help_and_version_exit_zero) { + const ProcessResult help = run_cli({"--help"}); + REQUIRE_EQ(help.exit_code, 0); + REQUIRE(help.stdout_text.find("Usage:") != std::string::npos); + REQUIRE(help.stdout_text.find("Commands:") != std::string::npos); + + const ProcessResult version = run_cli({"--version"}); + REQUIRE_EQ(version.exit_code, 0); + REQUIRE(version.stdout_text.find("v0.") != std::string::npos); +} + +TEST_CASE(TempifyCliBinary_list_reports_fixture_templates) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-cli-binary-list-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-cli-binary-list-data-home"); + prepare_template_workspace(workspace.path()); + + const ProcessResult result = run_cli({"list"}, workspace.path(), isolated_cli_env(data_home.path())); + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("basic_cpp") != std::string::npos); + REQUIRE(result.stdout_text.find("advanced_hooks_layout") != std::string::npos); +} + +TEST_CASE(TempifyCliBinary_render_basic_cpp_creates_expected_files) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-cli-binary-render-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-cli-binary-render-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-cli-binary-render-data-home"); + prepare_template_workspace(workspace.path()); + + const ProcessResult result = run_cli({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Binary E2E", + "--set", + "name_slug=binary-e2e", + "--set", + "namespace=binary_e2e_ns", + "--set", + "include_ci=false", + "--set", + "author=Binary Tester", + }, + workspace.path(), isolated_cli_env(data_home.path())); + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(std::filesystem::is_regular_file(target.path() / "README.md")); + REQUIRE(read_text_file(target.path() / "README.md").find("# Binary E2E") != std::string::npos); + REQUIRE(std::filesystem::is_regular_file(target.path() / ".tempify-lock.json")); +} + +TEST_CASE(TempifyCliBinary_doctor_runs_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-cli-binary-doctor-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-cli-binary-doctor-data-home"); + prepare_template_workspace(workspace.path()); + + const ProcessResult result = run_cli({"doctor"}, workspace.path(), isolated_cli_env(data_home.path())); + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(result.stdout_text.find("Doctor") != std::string::npos || result.stdout_text.find("doctor") != std::string::npos); +} diff --git a/tests/security/e2e/TempifySecurityE2ETests.cpp b/tests/security/e2e/TempifySecurityE2ETests.cpp new file mode 100644 index 0000000..40f1b1a --- /dev/null +++ b/tests/security/e2e/TempifySecurityE2ETests.cpp @@ -0,0 +1,113 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::create_sensitive_template; +using tempify::test_support::create_slow_hook_template; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; +using tempify::test_support::test_template_path; + +} // namespace + +TEST_CASE(TempifySecurityE2E_sensitive_lock_values_are_redacted_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-security-sensitive-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-security-sensitive-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-security-sensitive-data-home"); + create_sensitive_template(workspace.path()); + + const ProcessResult result = run_cli({ + "sensitive_demo", + target.path().string(), + "--set", + "project_name=Secret App", + "--set", + "api_token=super-secret-token", + }, + workspace.path(), isolated_cli_env(data_home.path())); + REQUIRE_EQ(result.exit_code, 0); + + const std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); + REQUIRE(lock_text.find("super-secret-token") == std::string::npos); + REQUIRE(lock_text.find("\"api_token\"") != std::string::npos); +} + +TEST_CASE(TempifySecurityE2E_no_hooks_skips_hook_side_effects_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-security-no-hooks-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-security-no-hooks-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-security-no-hooks-data-home"); + prepare_template_workspace(workspace.path()); + + const ProcessResult result = run_cli({ + "advanced_hooks_layout", + target.path().string(), + "--no-hooks", + "--set", + "project_name=No Hooks", + "--set", + "project_slug=no-hooks", + "--set", + "use_notes=false", + }, + workspace.path(), isolated_cli_env(data_home.path())); + REQUIRE_EQ(result.exit_code, 0); + REQUIRE(!std::filesystem::exists(target.path() / "script-marker.txt")); + REQUIRE(!std::filesystem::exists(target.path() / "pre.txt")); +} + +TEST_CASE(TempifySecurityE2E_hook_timeout_aborts_slow_hook_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-security-hook-timeout-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-security-hook-timeout-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-security-hook-timeout-data-home"); + create_slow_hook_template(workspace.path()); + + const ProcessResult result = run_cli({ + "slow_hook_demo", + target.path().string(), + "--set", + "project_name=Timeout App", + "--hook-timeout-ms", + "50", + }, + workspace.path(), isolated_cli_env(data_home.path())); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("Hook phase 'post' failed") != std::string::npos + || combined.find("hook") != std::string::npos); +} + +TEST_CASE(TempifySecurityE2E_invalid_namespace_rejected_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-security-namespace-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-security-namespace-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-security-namespace-data-home"); + prepare_template_workspace(workspace.path()); + + const ProcessResult result = run_cli({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Invalid Namespace", + "--set", + "name_slug=invalid-ns", + "--set", + "namespace=invalid-ns", + "--set", + "include_ci=false", + "--set", + "author=Security Tester", + }, + workspace.path(), isolated_cli_env(data_home.path())); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("Namespace must be valid C++ identifier") != std::string::npos); +} diff --git a/tests/support/CliProcess.cpp b/tests/support/CliProcess.cpp new file mode 100644 index 0000000..3cd0267 --- /dev/null +++ b/tests/support/CliProcess.cpp @@ -0,0 +1,346 @@ +#include "CliProcess.h" + +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#else +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace tempify::test { + +namespace { + +#ifndef _WIN32 + +std::string read_pipe_data(int fd) { + std::string output; + std::array buffer{}; + while (true) { + const ssize_t bytes_read = ::read(fd, buffer.data(), buffer.size()); + if (bytes_read <= 0) { + break; + } + output.append(buffer.data(), static_cast(bytes_read)); + } + return output; +} + +void apply_extra_env(const std::map &extra_env) { + for (const auto &[name, value] : extra_env) { + ::setenv(name.c_str(), value.c_str(), 1); + } +} + +ProcessResult run_cli_posix(const std::filesystem::path &executable, const std::vector &args, + const std::filesystem::path &working_directory, + const std::map &extra_env, const std::string &stdin_text) { + int stdout_pipe[2]{-1, -1}; + int stderr_pipe[2]{-1, -1}; + int stdin_pipe[2]{-1, -1}; + if (::pipe(stdout_pipe) != 0 || ::pipe(stderr_pipe) != 0 + || (!stdin_text.empty() && ::pipe(stdin_pipe) != 0)) { + throw std::runtime_error("failed to create subprocess pipes"); + } + + const pid_t child_pid = ::fork(); + if (child_pid < 0) { + throw std::runtime_error("failed to fork subprocess"); + } + + if (child_pid == 0) { + apply_extra_env(extra_env); + if (!working_directory.empty()) { + std::error_code error; + std::filesystem::current_path(working_directory, error); + } + + ::dup2(stdout_pipe[1], STDOUT_FILENO); + ::dup2(stderr_pipe[1], STDERR_FILENO); + if (!stdin_text.empty()) { + ::dup2(stdin_pipe[0], STDIN_FILENO); + } + ::close(stdout_pipe[0]); + ::close(stdout_pipe[1]); + ::close(stderr_pipe[0]); + ::close(stderr_pipe[1]); + if (!stdin_text.empty()) { + ::close(stdin_pipe[0]); + ::close(stdin_pipe[1]); + } + + std::vector argv_storage; + argv_storage.reserve(args.size() + 1); + argv_storage.push_back(executable.string()); + for (const std::string &arg : args) { + argv_storage.push_back(arg); + } + + std::vector argv_ptrs; + argv_ptrs.reserve(argv_storage.size() + 1); + for (std::string &arg : argv_storage) { + argv_ptrs.push_back(arg.data()); + } + argv_ptrs.push_back(nullptr); + + ::execv(executable.c_str(), argv_ptrs.data()); + _exit(127); + } + + ::close(stdout_pipe[1]); + ::close(stderr_pipe[1]); + if (!stdin_text.empty()) { + ::close(stdin_pipe[0]); + const ssize_t bytes_written = + ::write(stdin_pipe[1], stdin_text.data(), static_cast(stdin_text.size())); + if (bytes_written < 0) { + throw std::runtime_error("failed to write subprocess stdin"); + } + ::close(stdin_pipe[1]); + } + + ProcessResult result; + result.stdout_text = read_pipe_data(stdout_pipe[0]); + result.stderr_text = read_pipe_data(stderr_pipe[0]); + ::close(stdout_pipe[0]); + ::close(stderr_pipe[0]); + + int status = 0; + if (::waitpid(child_pid, &status, 0) < 0) { + throw std::runtime_error("failed to wait for subprocess"); + } + if (WIFEXITED(status)) { + result.exit_code = WEXITSTATUS(status); + } else { + result.exit_code = -1; + } + return result; +} + +#else + +std::wstring to_wide(const std::string &text) { + if (text.empty()) { + return {}; + } + const int required = + ::MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(text.size()), nullptr, 0); + if (required <= 0) { + throw std::runtime_error("failed to convert text to wide string"); + } + std::wstring wide(static_cast(required), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(text.size()), wide.data(), required); + return wide; +} + +std::string quote_windows_argument(const std::string &arg) { + if (arg.empty()) { + return "\"\""; + } + bool needs_quotes = false; + for (char ch : arg) { + if (ch == ' ' || ch == '\t' || ch == '"') { + needs_quotes = true; + break; + } + } + if (!needs_quotes) { + return arg; + } + + std::string quoted = "\""; + std::size_t backslashes = 0; + for (char ch : arg) { + if (ch == '\\') { + ++backslashes; + continue; + } + if (ch == '"') { + quoted.append(backslashes * 2 + 1, '\\'); + backslashes = 0; + quoted.push_back('"'); + continue; + } + if (backslashes != 0) { + quoted.append(backslashes, '\\'); + backslashes = 0; + } + quoted.push_back(ch); + } + if (backslashes != 0) { + quoted.append(backslashes * 2, '\\'); + } + quoted.push_back('"'); + return quoted; +} + +std::wstring build_windows_command_line(const std::filesystem::path &executable, + const std::vector &args) { + std::string command = quote_windows_argument(executable.string()); + for (const std::string &arg : args) { + command.push_back(' '); + command += quote_windows_argument(arg); + } + return to_wide(command); +} + +ProcessResult run_cli_windows(const std::filesystem::path &executable, const std::vector &args, + const std::filesystem::path &working_directory, + const std::map &extra_env, const std::string &stdin_text) { + SECURITY_ATTRIBUTES security_attributes{}; + security_attributes.nLength = sizeof(security_attributes); + security_attributes.bInheritHandle = TRUE; + + HANDLE stdout_read = nullptr; + HANDLE stdout_write = nullptr; + HANDLE stderr_read = nullptr; + HANDLE stderr_write = nullptr; + HANDLE stdin_read = nullptr; + HANDLE stdin_write = nullptr; + if (!::CreatePipe(&stdout_read, &stdout_write, &security_attributes, 0) + || !::CreatePipe(&stderr_read, &stderr_write, &security_attributes, 0) + || (!stdin_text.empty() && !::CreatePipe(&stdin_read, &stdin_write, &security_attributes, 0))) { + throw std::runtime_error("failed to create subprocess pipes"); + } + + ::SetHandleInformation(stdout_read, HANDLE_FLAG_INHERIT, 0); + ::SetHandleInformation(stderr_read, HANDLE_FLAG_INHERIT, 0); + if (!stdin_text.empty()) { + ::SetHandleInformation(stdin_write, HANDLE_FLAG_INHERIT, 0); + } + + STARTUPINFOW startup_info{}; + startup_info.cb = sizeof(startup_info); + startup_info.dwFlags = STARTF_USESTDHANDLES; + startup_info.hStdOutput = stdout_write; + startup_info.hStdError = stderr_write; + startup_info.hStdInput = stdin_text.empty() ? ::GetStdHandle(STD_INPUT_HANDLE) : stdin_read; + + PROCESS_INFORMATION process_info{}; + const std::wstring command_line = build_windows_command_line(executable, args); + std::vector mutable_command_line(command_line.begin(), command_line.end()); + mutable_command_line.push_back(L'\0'); + + std::wstring environment_block; + if (!extra_env.empty()) { + for (const auto &[name, value] : extra_env) { + environment_block += to_wide(name + '=' + value); + environment_block.push_back(L'\0'); + } + environment_block.push_back(L'\0'); + } + + const BOOL created = ::CreateProcessW( + to_wide(executable.string()).c_str(), + mutable_command_line.data(), + nullptr, + nullptr, + TRUE, + 0, + environment_block.empty() ? nullptr : environment_block.data(), + working_directory.empty() ? nullptr : to_wide(working_directory.string()).c_str(), + &startup_info, + &process_info); + ::CloseHandle(stdout_write); + ::CloseHandle(stderr_write); + if (!stdin_text.empty()) { + ::CloseHandle(stdin_read); + } + + if (!created) { + ::CloseHandle(stdout_read); + ::CloseHandle(stderr_read); + throw std::runtime_error("failed to create subprocess"); + } + + ProcessResult result; + if (!stdin_text.empty()) { + DWORD bytes_written = 0; + if (!::WriteFile(stdin_write, stdin_text.data(), static_cast(stdin_text.size()), &bytes_written, + nullptr)) { + ::CloseHandle(stdout_read); + ::CloseHandle(stderr_read); + ::CloseHandle(stdin_write); + ::CloseHandle(process_info.hThread); + ::CloseHandle(process_info.hProcess); + throw std::runtime_error("failed to write subprocess stdin"); + } + ::CloseHandle(stdin_write); + } + + std::array buffer{}; + while (true) { + DWORD bytes_read = 0; + if (!::ReadFile(stdout_read, buffer.data(), static_cast(buffer.size()), &bytes_read, nullptr) + || bytes_read == 0) { + break; + } + result.stdout_text.append(buffer.data(), bytes_read); + } + while (true) { + DWORD bytes_read = 0; + if (!::ReadFile(stderr_read, buffer.data(), static_cast(buffer.size()), &bytes_read, nullptr) + || bytes_read == 0) { + break; + } + result.stderr_text.append(buffer.data(), bytes_read); + } + ::CloseHandle(stdout_read); + ::CloseHandle(stderr_read); + + ::WaitForSingleObject(process_info.hProcess, INFINITE); + DWORD exit_code = 1; + ::GetExitCodeProcess(process_info.hProcess, &exit_code); + result.exit_code = static_cast(exit_code); + + ::CloseHandle(process_info.hThread); + ::CloseHandle(process_info.hProcess); + return result; +} + +#endif + +} // namespace + +std::filesystem::path cli_binary_path() { + if (const char *override_path = std::getenv("TEMPIFY_TEST_BINARY")) { + return override_path; + } +#ifdef TEMPIFY_CLI_BINARY + return TEMPIFY_CLI_BINARY; +#else + return "tempify"; +#endif +} + +std::filesystem::path cli_working_directory() { + if (const char *override_path = std::getenv("TEMPIFY_CLI_WORKDIR")) { + return override_path; + } +#ifdef TEMPIFY_CLI_WORKDIR + return TEMPIFY_CLI_WORKDIR; +#else + return std::filesystem::current_path(); +#endif +} + +ProcessResult run_cli(const std::vector &args, const std::filesystem::path &working_directory, + const std::map &extra_env, const std::string &stdin_text) { + const std::filesystem::path executable = cli_binary_path(); +#ifndef _WIN32 + return run_cli_posix(executable, args, working_directory, extra_env, stdin_text); +#else + return run_cli_windows(executable, args, working_directory, extra_env, stdin_text); +#endif +} + +} // namespace tempify::test diff --git a/tests/support/CliProcess.h b/tests/support/CliProcess.h new file mode 100644 index 0000000..1161cd2 --- /dev/null +++ b/tests/support/CliProcess.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace tempify::test { + +struct ProcessResult { + int exit_code = -1; + std::string stdout_text; + std::string stderr_text; +}; + +std::filesystem::path cli_binary_path(); +std::filesystem::path cli_working_directory(); + +ProcessResult run_cli(const std::vector &args, + const std::filesystem::path &working_directory = cli_working_directory(), + const std::map &extra_env = {}, + const std::string &stdin_text = {}); + +} // namespace tempify::test diff --git a/tests/support/E2ETestSupport.h b/tests/support/E2ETestSupport.h new file mode 100644 index 0000000..058eb89 --- /dev/null +++ b/tests/support/E2ETestSupport.h @@ -0,0 +1,33 @@ +#pragma once + +#include "TempifyTestSupport.h" + +#include +#include +#include + +namespace tempify::test_support::e2e { + +inline std::string slug_to_namespace(const std::string &slug) { + std::string ns = slug; + std::replace(ns.begin(), ns.end(), '-', '_'); + return ns + "_ns"; +} + +inline std::map isolated_cli_env(const std::filesystem::path &data_home, + const std::filesystem::path &config_home = {}) { + std::map env; + env["XDG_DATA_HOME"] = data_home.string(); + if (!config_home.empty()) { + env["XDG_CONFIG_HOME"] = config_home.string(); + } + return env; +} + +inline std::filesystem::path prepare_template_workspace(const std::filesystem::path &workspace_root) { + std::filesystem::create_directories(workspace_root); + link_test_templates_into_workspace(workspace_root); + return workspace_root; +} + +} // namespace tempify::test_support::e2e From cc1b90a5d20fda99cfe2a4310f63df0c3bd5f92e Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 21:26:24 +0200 Subject: [PATCH 08/29] Adding new Tests --- src/main/cpp/lua/LuaHookRuntime.cpp | 128 +++- .../TempifySharedStateConcurrencyE2ETests.cpp | 287 +++++++++ .../e2e/TempifyCorruptInputE2ETests.cpp | 142 +++++ .../e2e/TempifyReapplyDeleteE2ETests.cpp | 230 +++++++ .../cli/TempifyCliCommandsE2ETests.cpp | 251 ++++++++ .../e2e/TempifyHookSandboxE2ETests.cpp | 97 +++ tests/support/TempifyTestSupport.h | 44 ++ .../tempify/CorruptInputIntegrationTests.cpp | 269 +++++++++ tests/tempify/LuaEngineHookSandboxTests.cpp | 150 +++++ .../TempifyAppReapplyIntegrationTests.cpp | 561 ++++++++++++++++++ 10 files changed, 2140 insertions(+), 19 deletions(-) create mode 100644 tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp create mode 100644 tests/correctness/e2e/TempifyCorruptInputE2ETests.cpp create mode 100644 tests/correctness/e2e/TempifyReapplyDeleteE2ETests.cpp create mode 100644 tests/portability/cli/TempifyCliCommandsE2ETests.cpp create mode 100644 tests/security/e2e/TempifyHookSandboxE2ETests.cpp create mode 100644 tests/tempify/CorruptInputIntegrationTests.cpp create mode 100644 tests/tempify/LuaEngineHookSandboxTests.cpp diff --git a/src/main/cpp/lua/LuaHookRuntime.cpp b/src/main/cpp/lua/LuaHookRuntime.cpp index 2f3e779..7d6ee18 100644 --- a/src/main/cpp/lua/LuaHookRuntime.cpp +++ b/src/main/cpp/lua/LuaHookRuntime.cpp @@ -81,17 +81,84 @@ void hook_timeout_guard(lua_State *state, lua_Debug *debug) { luaL_error(state, "Hook timed out after %d ms", timeout_message_ms(*host)); } -std::filesystem::path resolve_input_path(const HookHost &host, const std::filesystem::path &path) { +bool path_starts_with(const std::filesystem::path &path, const std::filesystem::path &prefix) { + const std::filesystem::path normalized_path = path.lexically_normal(); + const std::filesystem::path normalized_prefix = prefix.lexically_normal(); + auto path_it = normalized_path.begin(); + auto prefix_it = normalized_prefix.begin(); + for (; prefix_it != normalized_prefix.end(); ++prefix_it, ++path_it) { + if (path_it == normalized_path.end()) { + return false; + } +#if defined(_WIN32) + std::string path_part = path_it->string(); + std::string prefix_part = prefix_it->string(); + std::transform(path_part.begin(), path_part.end(), path_part.begin(), + [](const unsigned char ch) { return static_cast(std::tolower(ch)); }); + std::transform(prefix_part.begin(), prefix_part.end(), prefix_part.begin(), + [](const unsigned char ch) { return static_cast(std::tolower(ch)); }); + if (path_part != prefix_part) { + return false; + } +#else + if (*path_it != *prefix_it) { + return false; + } +#endif + } + return true; +} + +bool path_within_root(const std::filesystem::path &path, const std::filesystem::path &root) { + std::error_code error; + const std::filesystem::path resolved = std::filesystem::weakly_canonical(path, error); + const std::filesystem::path resolved_root = std::filesystem::weakly_canonical(root, error); + if (!error) { + return path_starts_with(resolved, resolved_root); + } + return path_starts_with(path.lexically_normal(), root.lexically_normal()); +} + +void ensure_relative_path_has_no_parent_segments(lua_State *state, const std::filesystem::path &path) { + for (const auto &part : path.lexically_normal()) { + if (part == "..") { + const std::string path_text = path.string(); + luaL_error(state, "Hook path escapes allowed roots: %s", path_text.c_str()); + } + } +} + +std::filesystem::path resolve_input_path(lua_State *state, const HookHost &host, const std::filesystem::path &path) { + if (path.empty()) { + luaL_error(state, "Hook path must be non-empty"); + } + if (path.is_absolute()) { - return path; + const std::filesystem::path normalized = path.lexically_normal(); + if (path_within_root(normalized, host.context->build_root) || + path_within_root(normalized, host.manifest->root)) { + return normalized; + } + const std::string path_text = path.string(); + luaL_error(state, "Hook path escapes allowed roots: %s", path_text.c_str()); } - const std::filesystem::path build_candidate = host.context->build_root / path; + ensure_relative_path_has_no_parent_segments(state, path); + + const std::filesystem::path build_candidate = (host.context->build_root / path).lexically_normal(); + if (!path_within_root(build_candidate, host.context->build_root)) { + const std::string path_text = path.string(); + luaL_error(state, "Hook path escapes build root: %s", path_text.c_str()); + } if (std::filesystem::exists(build_candidate)) { return build_candidate; } - const std::filesystem::path template_candidate = host.manifest->root / path; + const std::filesystem::path template_candidate = (host.manifest->root / path).lexically_normal(); + if (!path_within_root(template_candidate, host.manifest->root)) { + const std::string path_text = path.string(); + luaL_error(state, "Hook path escapes template root: %s", path_text.c_str()); + } if (std::filesystem::exists(template_candidate)) { return template_candidate; } @@ -99,23 +166,37 @@ std::filesystem::path resolve_input_path(const HookHost &host, const std::filesy return build_candidate; } -std::filesystem::path resolve_output_path(const HookHost &host, const std::filesystem::path &path) { - if (path.is_absolute()) { - return path; +std::filesystem::path resolve_output_path(lua_State *state, const HookHost &host, const std::filesystem::path &path) { + if (path.empty()) { + luaL_error(state, "Hook path must be non-empty"); + } + + if (!path.is_absolute()) { + ensure_relative_path_has_no_parent_segments(state, path); } - return host.context->build_root / path; + + const std::filesystem::path resolved = + path.is_absolute() ? path.lexically_normal() : (host.context->build_root / path).lexically_normal(); + if (!path_within_root(resolved, host.context->build_root)) { + const std::string path_text = path.string(); + luaL_error(state, "Hook path escapes build root: %s", path_text.c_str()); + } + + return resolved; } int hook_exists(lua_State *state) { const HookHost &host = *hook_host(state); - const std::filesystem::path path = resolve_input_path(host, luaL_checkstring(state, 1)); + const std::filesystem::path path = + resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); lua_pushboolean(state, std::filesystem::exists(path)); return 1; } int hook_mkdir(lua_State *state) { const HookHost &host = *hook_host(state); - const std::filesystem::path path = resolve_output_path(host, luaL_checkstring(state, 1)); + const std::filesystem::path path = + resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); std::filesystem::create_directories(path); const std::string path_text = path.string(); lua_pushlstring(state, path_text.c_str(), path_text.size()); @@ -124,14 +205,16 @@ int hook_mkdir(lua_State *state) { int hook_remove(lua_State *state) { const HookHost &host = *hook_host(state); - const std::filesystem::path path = resolve_output_path(host, luaL_checkstring(state, 1)); + const std::filesystem::path path = + resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); lua_pushinteger(state, static_cast(std::filesystem::remove_all(path))); return 1; } int hook_read_file(lua_State *state) { const HookHost &host = *hook_host(state); - const std::filesystem::path path = resolve_input_path(host, luaL_checkstring(state, 1)); + const std::filesystem::path path = + resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); std::ifstream input(path, std::ios::binary); if (!input) { const std::string path_text = path.string(); @@ -146,7 +229,8 @@ int hook_read_file(lua_State *state) { int hook_write_file(lua_State *state) { const HookHost &host = *hook_host(state); - const std::filesystem::path path = resolve_output_path(host, luaL_checkstring(state, 1)); + const std::filesystem::path path = + resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); const std::string content = luaL_checkstring(state, 2); std::filesystem::create_directories(path.parent_path()); std::ofstream output(path, std::ios::binary); @@ -162,7 +246,8 @@ int hook_write_file(lua_State *state) { int hook_list_files(lua_State *state) { const HookHost &host = *hook_host(state); - const std::filesystem::path path = resolve_input_path(host, luaL_checkstring(state, 1)); + const std::filesystem::path path = + resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); lua_newtable(state); if (!std::filesystem::is_directory(path)) { return 1; @@ -181,7 +266,8 @@ int hook_list_files(lua_State *state) { int hook_list_dirs(lua_State *state) { const HookHost &host = *hook_host(state); - const std::filesystem::path path = resolve_input_path(host, luaL_checkstring(state, 1)); + const std::filesystem::path path = + resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); lua_newtable(state); if (!std::filesystem::is_directory(path)) { return 1; @@ -200,8 +286,10 @@ int hook_list_dirs(lua_State *state) { int hook_copy(lua_State *state) { const HookHost &host = *hook_host(state); - const std::filesystem::path source = resolve_input_path(host, luaL_checkstring(state, 1)); - const std::filesystem::path target = resolve_output_path(host, luaL_checkstring(state, 2)); + const std::filesystem::path source = + resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); + const std::filesystem::path target = + resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 2))); std::filesystem::create_directories(target.parent_path()); if (std::filesystem::is_directory(source)) { @@ -237,8 +325,10 @@ int hook_process_string(lua_State *state) { int hook_process_file(lua_State *state) { const HookHost &host = *hook_host(state); - const std::filesystem::path input = resolve_input_path(host, luaL_checkstring(state, 1)); - const std::filesystem::path output = resolve_output_path(host, luaL_checkstring(state, 2)); + const std::filesystem::path input = + resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); + const std::filesystem::path output = + resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 2))); std::map values = host.context->values; if (lua_istable(state, 3) != 0) { const auto overrides = lua_internal::table_to_string_map(state, 3); diff --git a/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp b/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp new file mode 100644 index 0000000..e09f95f --- /dev/null +++ b/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp @@ -0,0 +1,287 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include "parser/JsonParser.h" +#include "TempifyTestSupport.h" + +#include +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::create_shared_template; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::e2e::slug_to_namespace; +using tempify::test_support::inject_stale_managed_file; +using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; + +std::size_t count_successes(const std::vector &results) { + std::size_t count = 0; + for (const ProcessResult &result : results) { + if (result.exit_code == 0) { + ++count; + } + } + return count; +} + +void require_parseable_json_file(const std::filesystem::path &path) { + prebyte::JsonParser parser; + const prebyte::Data data = parser.parse(path); + REQUIRE(data.is_map()); +} + +std::size_t count_indexed_templates(const std::filesystem::path &index_file) { + prebyte::JsonParser parser; + const prebyte::Data data = parser.parse(index_file); + REQUIRE(data.is_map()); + const auto &root = data.as_map(); + const auto it = root.find("templates"); + REQUIRE(it != root.end()); + REQUIRE(it->second.is_array()); + return it->second.as_array().size(); +} + +void require_no_stale_index_temp_files(const std::filesystem::path &index_dir) { + if (!std::filesystem::is_directory(index_dir)) { + return; + } + for (const auto &entry : std::filesystem::directory_iterator(index_dir)) { + REQUIRE(entry.path().extension() != ".tmp"); + } +} + +std::vector basic_cpp_render_args(const std::filesystem::path &target, const std::string &slug, + const bool overwrite = false) { + std::vector args = { + "basic_cpp", + target.string(), + "--set", + "project_name=Shared State " + slug, + "--set", + "name_slug=" + slug, + "--set", + "namespace=" + slug_to_namespace(slug), + "--set", + "include_ci=false", + "--set", + "author=Shared State Tester", + }; + if (overwrite) { + args.push_back("--overwrite-if-exists"); + } + return args; +} + +void seed_shared_templates(const std::filesystem::path &shared_root) { + create_shared_template(shared_root, "shared_alpha", "Shared Alpha", "1.0.0", "Alpha template"); + create_shared_template(shared_root, "shared_beta", "Shared Beta", "1.0.0", "Beta template"); + create_shared_template(shared_root, "shared_gamma", "Shared Gamma", "1.0.0", "Gamma template"); +} + +} // namespace + +TEST_CASE(TempifySharedStateConcurrencyE2E_parallel_refresh_keeps_shared_index_consistent) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-shared-state-refresh-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-state-refresh-data-home"); + seed_shared_templates(data_home.shared_root()); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + + constexpr int parallel_count = 6; + std::vector> futures; + futures.reserve(parallel_count); + for (int index = 0; index < parallel_count; ++index) { + futures.push_back(std::async(std::launch::async, [workspace_path, env, use_json = index % 2 == 0]() { + if (use_json) { + return run_cli({"refresh", "--json"}, workspace_path, env); + } + return run_cli({"refresh"}, workspace_path, env); + })); + } + + std::vector results; + results.reserve(parallel_count); + for (int index = 0; index < parallel_count; ++index) { + results.push_back(futures[static_cast(index)].get()); + } + + REQUIRE(count_successes(results) >= 1U); + + const ProcessResult stabilize = run_cli({"refresh"}, workspace_path, env); + REQUIRE_EQ(stabilize.exit_code, 0); + + const std::filesystem::path index_file = data_home.shared_root() / "index" / "templates.json"; + REQUIRE(std::filesystem::is_regular_file(index_file)); + require_parseable_json_file(index_file); + REQUIRE_EQ(count_indexed_templates(index_file), 3U); + require_no_stale_index_temp_files(index_file.parent_path()); + + const ProcessResult list = run_cli({"list"}, workspace_path, env); + REQUIRE_EQ(list.exit_code, 0); + REQUIRE(list.stdout_text.find("shared_alpha") != std::string::npos); + REQUIRE(list.stdout_text.find("shared_beta") != std::string::npos); + REQUIRE(list.stdout_text.find("shared_gamma") != std::string::npos); + static_cast(results); +} + +TEST_CASE(TempifySharedStateConcurrencyE2E_refresh_list_and_doctor_share_data_home_without_corruption) { + ScopedDirectoryCleanup workspace( + std::filesystem::temp_directory_path() / "tempify-shared-state-catalog-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-state-catalog-data-home"); + seed_shared_templates(data_home.shared_root()); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + + auto refresh_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"refresh", "--json"}, workspace_path, env); + }); + auto list_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"list", "--json"}, workspace_path, env); + }); + auto doctor_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"doctor"}, workspace_path, env); + }); + auto info_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"info", "shared_beta"}, workspace_path, env); + }); + + const ProcessResult refresh = refresh_future.get(); + const ProcessResult list = list_future.get(); + const ProcessResult doctor = doctor_future.get(); + const ProcessResult info = info_future.get(); + + REQUIRE_EQ(refresh.exit_code, 0); + REQUIRE_EQ(list.exit_code, 0); + REQUIRE(list.stdout_text.find("\"shared_alpha\"") != std::string::npos); + REQUIRE(list.stdout_text.find("\"shared_beta\"") != std::string::npos); + REQUIRE_EQ(doctor.exit_code, 0); + REQUIRE_EQ(info.exit_code, 0); + REQUIRE(info.stdout_text.find("shared_beta") != std::string::npos); + + const std::filesystem::path index_file = data_home.shared_root() / "index" / "templates.json"; + require_parseable_json_file(index_file); + require_no_stale_index_temp_files(index_file.parent_path()); +} + +TEST_CASE(TempifySharedStateConcurrencyE2E_concurrent_renders_to_same_target_leave_valid_lock) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-shared-state-render-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-shared-state-render-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-state-render-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path target_path = target.path(); + + constexpr int parallel_count = 4; + std::vector> futures; + futures.reserve(parallel_count); + for (int index = 0; index < parallel_count; ++index) { + futures.push_back(std::async(std::launch::async, [workspace_path, env, target_path]() { + return run_cli(basic_cpp_render_args(target_path, "same-target", true), workspace_path, env); + })); + } + + std::vector results; + results.reserve(parallel_count); + for (int index = 0; index < parallel_count; ++index) { + results.push_back(futures[static_cast(index)].get()); + } + + REQUIRE(count_successes(results) >= 1U); + REQUIRE(std::filesystem::is_regular_file(target_path / "README.md")); + REQUIRE(std::filesystem::is_regular_file(target_path / ".tempify-lock.json")); + + const std::string lock_text = read_text_file(target_path / ".tempify-lock.json"); + require_parseable_json_file(target_path / ".tempify-lock.json"); + REQUIRE(lock_text.find("\"basic_cpp\"") != std::string::npos); + REQUIRE(read_text_file(target_path / "README.md").find("# Shared State same-target") != std::string::npos); +} + +TEST_CASE(TempifySharedStateConcurrencyE2E_concurrent_reapply_on_same_target_deletes_stale_file) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-shared-state-reapply-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-shared-state-reapply-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-state-reapply-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path target_path = target.path(); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target_path, "reapply-target"), workspace_path, env).exit_code, 0); + inject_stale_managed_file(target_path, "old-managed.txt", "removed-by-template-update\n"); + REQUIRE(std::filesystem::is_regular_file(target_path / "old-managed.txt")); + + constexpr int parallel_count = 4; + std::vector> futures; + futures.reserve(parallel_count); + for (int index = 0; index < parallel_count; ++index) { + futures.push_back(std::async(std::launch::async, [workspace_path, env, target_path]() { + std::vector args = basic_cpp_render_args(target_path, "reapply-target"); + args.push_back("--reapply"); + return run_cli(args, workspace_path, env); + })); + } + + std::vector results; + results.reserve(parallel_count); + for (int index = 0; index < parallel_count; ++index) { + results.push_back(futures[static_cast(index)].get()); + } + + REQUIRE(count_successes(results) >= 1U); + REQUIRE(!std::filesystem::exists(target_path / "old-managed.txt")); + require_parseable_json_file(target_path / ".tempify-lock.json"); + REQUIRE(read_text_file(target_path / ".tempify-lock.json").find("old-managed.txt") == std::string::npos); +} + +TEST_CASE(TempifySharedStateConcurrencyE2E_render_from_shared_store_while_refresh_runs) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-shared-state-mixed-workspace"); + ScopedDirectoryCleanup output(std::filesystem::temp_directory_path() / "tempify-shared-state-mixed-output"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-state-mixed-data-home"); + create_shared_template(data_home.shared_root(), "shared_render", "Shared Render", "1.0.0", "Shared render template", + "Rendered from shared store."); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path output_path = output.path(); + + auto refresh_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"refresh"}, workspace_path, env); + }); + auto list_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"list", "--json"}, workspace_path, env); + }); + auto render_future = std::async(std::launch::async, [workspace_path, env, output_path]() { + return run_cli({ + "shared_render", + output_path.string(), + "--set", + "project_name=Concurrent Shared", + "--set", + "project_slug=concurrent-shared", + }, + workspace_path, env); + }); + + const ProcessResult refresh = refresh_future.get(); + const ProcessResult list = list_future.get(); + const ProcessResult render = render_future.get(); + + REQUIRE_EQ(refresh.exit_code, 0); + REQUIRE_EQ(list.exit_code, 0); + REQUIRE(list.stdout_text.find("\"shared_render\"") != std::string::npos); + REQUIRE_EQ(render.exit_code, 0); + const std::filesystem::path readme_path = output_path / "README.md"; + REQUIRE(std::filesystem::is_regular_file(readme_path)); + REQUIRE(read_text_file(readme_path).find("Rendered from shared store") != std::string::npos); + require_parseable_json_file(data_home.shared_root() / "index" / "templates.json"); +} diff --git a/tests/correctness/e2e/TempifyCorruptInputE2ETests.cpp b/tests/correctness/e2e/TempifyCorruptInputE2ETests.cpp new file mode 100644 index 0000000..2f20a57 --- /dev/null +++ b/tests/correctness/e2e/TempifyCorruptInputE2ETests.cpp @@ -0,0 +1,142 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include "TempifyTestSupport.h" + +#include +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::e2e::slug_to_namespace; +using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; +using tempify::test_support::write_text_file; + +std::vector basic_cpp_render_args(const std::filesystem::path &target, const std::string &slug) { + return { + "basic_cpp", + target.string(), + "--set", + "project_name=Corrupt " + slug, + "--set", + "name_slug=" + slug, + "--set", + "namespace=" + slug_to_namespace(slug), + "--set", + "include_ci=false", + "--set", + "author=Corrupt Input Tester", + }; +} + +} // namespace + +TEST_CASE(TempifyCorruptInputE2E_subprocess_rejects_corrupt_lock_on_diff) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-diff-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-diff-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-diff-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "corrupt-diff"), workspace.path(), env).exit_code, 0); + write_text_file(target.path() / ".tempify-lock.json", "{ broken lock file\n"); + + std::vector diff_args = basic_cpp_render_args(target.path(), "corrupt-diff"); + diff_args.push_back("--diff"); + const ProcessResult diff = run_cli(diff_args, workspace.path(), env); + REQUIRE(diff.exit_code != 0); + const std::string combined = diff.stdout_text + diff.stderr_text; + REQUIRE(combined.find("Could not parse lock file") != std::string::npos); +} + +TEST_CASE(TempifyCorruptInputE2E_subprocess_rejects_corrupt_lock_on_reapply) { + ScopedDirectoryCleanup workspace( + std::filesystem::temp_directory_path() / "tempify-corrupt-lock-reapply-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-reapply-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-reapply-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "corrupt-reapply"), workspace.path(), env).exit_code, 0); + write_text_file(target.path() / ".tempify-lock.json", "{ broken lock file\n"); + + std::vector reapply_args = basic_cpp_render_args(target.path(), "corrupt-reapply"); + reapply_args.push_back("--reapply"); + const ProcessResult reapply = run_cli(reapply_args, workspace.path(), env); + REQUIRE(reapply.exit_code != 0); + const std::string combined = reapply.stdout_text + reapply.stderr_text; + REQUIRE(combined.find("Could not parse lock file") != std::string::npos); +} + +TEST_CASE(TempifyCorruptInputE2E_subprocess_rejects_corrupt_answers_file) { + ScopedDirectoryCleanup workspace( + std::filesystem::temp_directory_path() / "tempify-corrupt-answers-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-e2e-target"); + ScopedDirectoryCleanup answers_root(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-e2e-root"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path answers_file = answers_root.path() / "broken-answers.json"; + write_text_file(answers_file, "{ broken answers\n"); + + std::vector render_args = basic_cpp_render_args(target.path(), "corrupt-answers"); + render_args.push_back("--answers"); + render_args.push_back(answers_file.string()); + render_args.push_back("--set"); + render_args.push_back("author=Corrupt Input Tester"); + render_args.push_back("--non-interactive"); + + const ProcessResult render = run_cli(render_args, workspace.path(), env); + REQUIRE(render.exit_code != 0); + const std::string combined = render.stdout_text + render.stderr_text; + REQUIRE(combined.find("Could not parse answer file") != std::string::npos); +} + +TEST_CASE(TempifyCorruptInputE2E_subprocess_rejects_missing_answers_file) { + ScopedDirectoryCleanup workspace( + std::filesystem::temp_directory_path() / "tempify-corrupt-answers-missing-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-missing-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-missing-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path answers_file = + std::filesystem::temp_directory_path() / "tempify-corrupt-answers-missing-e2e.json"; + std::filesystem::remove(answers_file); + + std::vector render_args = basic_cpp_render_args(target.path(), "missing-answers"); + render_args.push_back("--answers"); + render_args.push_back(answers_file.string()); + render_args.push_back("--non-interactive"); + + const ProcessResult render = run_cli(render_args, workspace.path(), env); + REQUIRE(render.exit_code != 0); + const std::string combined = render.stdout_text + render.stderr_text; + REQUIRE(combined.find("Answer file not found") != std::string::npos); +} + +TEST_CASE(TempifyCorruptInputE2E_subprocess_rejects_lock_with_invalid_managed_files_type) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-type-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-type-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-type-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "corrupt-lock-type"), workspace.path(), env).exit_code, 0); + write_text_file(target.path() / ".tempify-lock.json", "{\n \"managed_files\": \"not-an-array\"\n}\n"); + + std::vector diff_args = basic_cpp_render_args(target.path(), "corrupt-lock-type"); + diff_args.push_back("--diff"); + const ProcessResult diff = run_cli(diff_args, workspace.path(), env); + REQUIRE(diff.exit_code != 0); + const std::string combined = diff.stdout_text + diff.stderr_text; + REQUIRE(combined.find("managed_files") != std::string::npos); + REQUIRE(combined.find("must be array") != std::string::npos); +} diff --git a/tests/correctness/e2e/TempifyReapplyDeleteE2ETests.cpp b/tests/correctness/e2e/TempifyReapplyDeleteE2ETests.cpp new file mode 100644 index 0000000..b32afdd --- /dev/null +++ b/tests/correctness/e2e/TempifyReapplyDeleteE2ETests.cpp @@ -0,0 +1,230 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include "TempifyTestSupport.h" + +#include +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::e2e::slug_to_namespace; +using tempify::test_support::inject_stale_managed_file; +using tempify::test_support::inject_stale_managed_file_without_hash; +using tempify::test_support::read_text_file; +using tempify::test_support::write_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; + +std::vector basic_cpp_render_args(const std::filesystem::path &target, const std::string &slug) { + return { + "basic_cpp", + target.string(), + "--set", + "project_name=Reapply Delete " + slug, + "--set", + "name_slug=" + slug, + "--set", + "namespace=" + slug_to_namespace(slug), + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Tester", + }; +} + +} // namespace + +TEST_CASE(TempifyReapplyDeleteE2E_subprocess_deletes_removed_managed_file) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-reapply-delete-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-reapply-delete-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-reapply-delete-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "delete-e2e"), workspace.path(), env).exit_code, 0); + inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); + + std::vector reapply_args = basic_cpp_render_args(target.path(), "delete-e2e"); + reapply_args.push_back("--reapply"); + const ProcessResult reapply = run_cli(reapply_args, workspace.path(), env); + REQUIRE_EQ(reapply.exit_code, 0); + REQUIRE(reapply.stdout_text.find("Deleted: 1") != std::string::npos + || reapply.stdout_text.find("\"delete\": 1") != std::string::npos); + REQUIRE(!std::filesystem::exists(target.path() / "old-managed.txt")); + REQUIRE(read_text_file(target.path() / ".tempify-lock.json").find("old-managed.txt") == std::string::npos); +} + +TEST_CASE(TempifyReapplyDeleteE2E_reapply_subcommand_deletes_removed_managed_file) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-reapply-delete-subcommand-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-reapply-delete-subcommand-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-reapply-delete-subcommand-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "delete-subcommand"), workspace.path(), env).exit_code, 0); + inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + + const ProcessResult reapply = run_cli({ + "reapply", + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete delete-subcommand", + "--set", + "name_slug=delete-subcommand", + "--set", + "namespace=delete_subcommand_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Tester", + }, + workspace.path(), env); + REQUIRE_EQ(reapply.exit_code, 0); + REQUIRE(!std::filesystem::exists(target.path() / "old-managed.txt")); + REQUIRE(reapply.stdout_text.find("Deleted: 1") != std::string::npos + || reapply.stdout_text.find("Reapplied basic_cpp") != std::string::npos); +} + +TEST_CASE(TempifyReapplyDeleteE2E_subprocess_blocks_delete_when_stale_file_was_locally_modified) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-reapply-delete-conflict-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-reapply-delete-conflict-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-reapply-delete-conflict-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "delete-conflict-e2e"), workspace.path(), env).exit_code, 0); + inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + write_text_file(target.path() / "old-managed.txt", "user-edited-stale-content\n"); + + std::vector reapply_args = basic_cpp_render_args(target.path(), "delete-conflict-e2e"); + reapply_args.push_back("--reapply"); + const ProcessResult reapply = run_cli(reapply_args, workspace.path(), env); + REQUIRE(reapply.exit_code != 0); + const std::string combined = reapply.stdout_text + reapply.stderr_text; + REQUIRE(combined.find("conflict item") != std::string::npos || combined.find("REAPPLY_BLOCKED") != std::string::npos); + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); + REQUIRE(read_text_file(target.path() / "old-managed.txt") == "user-edited-stale-content\n"); +} + +TEST_CASE(TempifyReapplyDeleteE2E_subprocess_report_shows_delete_without_removing_file) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-reapply-delete-report-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-reapply-delete-report-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-reapply-delete-report-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "delete-report-e2e"), workspace.path(), env).exit_code, 0); + inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + const std::string stale_content = read_text_file(target.path() / "old-managed.txt"); + + std::vector report_args = basic_cpp_render_args(target.path(), "delete-report-e2e"); + report_args.push_back("--reapply"); + report_args.push_back("--report"); + const ProcessResult report = run_cli(report_args, workspace.path(), env); + REQUIRE_EQ(report.exit_code, 0); + REQUIRE(report.stdout_text.find("delete old-managed.txt") != std::string::npos); + REQUIRE_EQ(read_text_file(target.path() / "old-managed.txt"), stale_content); +} + +TEST_CASE(TempifyReapplyDeleteE2E_diff_only_leaves_stale_file_on_disk) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-reapply-delete-diff-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-reapply-delete-diff-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-reapply-delete-diff-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "delete-diff-e2e"), workspace.path(), env).exit_code, 0); + inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + const std::string stale_content = read_text_file(target.path() / "old-managed.txt"); + + std::vector diff_args = basic_cpp_render_args(target.path(), "delete-diff-e2e"); + diff_args.push_back("--diff"); + const ProcessResult diff = run_cli(diff_args, workspace.path(), env); + REQUIRE_EQ(diff.exit_code, 0); + REQUIRE(diff.stdout_text.find("delete old-managed.txt") != std::string::npos); + REQUIRE_EQ(read_text_file(target.path() / "old-managed.txt"), stale_content); +} + +TEST_CASE(TempifyReapplyDeleteE2E_subprocess_blocks_delete_when_lock_has_no_baseline_hash) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-reapply-delete-review-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-reapply-delete-review-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-reapply-delete-review-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "delete-review-e2e"), workspace.path(), env).exit_code, 0); + inject_stale_managed_file_without_hash(target.path(), "old-managed.txt", "removed-by-template-update\n"); + + std::vector reapply_args = basic_cpp_render_args(target.path(), "delete-review-e2e"); + reapply_args.push_back("--reapply"); + const ProcessResult reapply = run_cli(reapply_args, workspace.path(), env); + REQUIRE(reapply.exit_code != 0); + const std::string combined = reapply.stdout_text + reapply.stderr_text; + REQUIRE(combined.find("review item") != std::string::npos || combined.find("REAPPLY_BLOCKED") != std::string::npos); + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); +} + +TEST_CASE(TempifyReapplyDeleteE2E_json_blocks_delete_conflict_without_removing_file) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-reapply-delete-json-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-reapply-delete-json-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-reapply-delete-json-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "delete-json-e2e"), workspace.path(), env).exit_code, 0); + inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + write_text_file(target.path() / "old-managed.txt", "user-edited-stale-content\n"); + + std::vector reapply_args = basic_cpp_render_args(target.path(), "delete-json-e2e"); + reapply_args.push_back("--reapply"); + reapply_args.push_back("--json"); + const ProcessResult reapply = run_cli(reapply_args, workspace.path(), env); + REQUIRE(reapply.exit_code != 0); + const std::string combined = reapply.stdout_text + reapply.stderr_text; + REQUIRE(combined.find("REAPPLY_BLOCKED") != std::string::npos); + REQUIRE(combined.find("old-managed.txt") != std::string::npos); + REQUIRE(read_text_file(target.path() / "old-managed.txt") == "user-edited-stale-content\n"); +} + +TEST_CASE(TempifyReapplyDeleteE2E_reapply_subcommand_blocks_delete_conflict) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-reapply-delete-sub-conflict-e2e-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-reapply-delete-sub-conflict-e2e-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-reapply-delete-sub-conflict-e2e-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "delete-sub-conflict-e2e"), workspace.path(), env).exit_code, 0); + inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + write_text_file(target.path() / "old-managed.txt", "user-edited-stale-content\n"); + + const ProcessResult reapply = run_cli({ + "reapply", + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete delete-sub-conflict-e2e", + "--set", + "name_slug=delete-sub-conflict-e2e", + "--set", + "namespace=delete_sub_conflict_e2e_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Tester", + "--json", + }, + workspace.path(), env); + REQUIRE(reapply.exit_code != 0); + const std::string combined = reapply.stdout_text + reapply.stderr_text; + REQUIRE(combined.find("old-managed.txt") != std::string::npos); + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); +} diff --git a/tests/portability/cli/TempifyCliCommandsE2ETests.cpp b/tests/portability/cli/TempifyCliCommandsE2ETests.cpp new file mode 100644 index 0000000..449568a --- /dev/null +++ b/tests/portability/cli/TempifyCliCommandsE2ETests.cpp @@ -0,0 +1,251 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include "TempifyTestSupport.h" + +#include +#include +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::e2e::slug_to_namespace; +using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; +using tempify::test_support::write_text_file; + +struct CommandWorkspace { + ScopedDirectoryCleanup workspace; + ScopedTempifyDataHome data_home; + std::map env; + + explicit CommandWorkspace(const char *label) + : workspace(std::filesystem::temp_directory_path() / ("tempify-cli-commands-" + std::string(label) + "-workspace")), + data_home(std::filesystem::temp_directory_path() / ("tempify-cli-commands-" + std::string(label) + "-data-home")) { + prepare_template_workspace(workspace.path()); + env = isolated_cli_env(data_home.path()); + } + + const std::filesystem::path &cwd() const { + return workspace.path(); + } +}; + +std::vector basic_cpp_render_args(const std::filesystem::path &target, const std::string &slug) { + return { + "basic_cpp", + target.string(), + "--set", + "project_name=CLI " + slug, + "--set", + "name_slug=" + slug, + "--set", + "namespace=" + slug_to_namespace(slug), + "--set", + "include_ci=false", + "--set", + "author=CLI Commands Tester", + }; +} + +std::vector hooks_render_args(const std::filesystem::path &target, const std::string &slug) { + return { + "advanced_hooks_layout", + target.string(), + "--accept-hooks", + "no", + "--set", + "project_name=CLI " + slug, + "--set", + "project_slug=" + slug, + "--set", + "use_notes=false", + }; +} + +} // namespace + +TEST_CASE(TempifyCliCommandsE2E_info_and_list_json_in_subprocess) { + CommandWorkspace fixture("catalog"); + const ProcessResult info = run_cli({"info", "basic_cpp"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(info.exit_code, 0); + REQUIRE(info.stdout_text.find("basic_cpp (Basic C++ App)") != std::string::npos); + + const ProcessResult info_json = run_cli({"info", "basic_cpp", "--json"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(info_json.exit_code, 0); + REQUIRE(info_json.stdout_text.find("\"template_id\": \"basic_cpp\"") != std::string::npos); + REQUIRE(info_json.stdout_text.find("\"question_count\": 6") != std::string::npos); + + const ProcessResult list_json = run_cli({"list", "--json"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(list_json.exit_code, 0); + REQUIRE(list_json.stdout_text.find("\"templates\": [") != std::string::npos); + REQUIRE(list_json.stdout_text.find("\"id\": \"basic_cpp\"") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_validate_inspect_and_lint_in_subprocess) { + CommandWorkspace fixture("validate-pipeline"); + const ProcessResult validate = run_cli({"validate", "basic_cpp"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(validate.exit_code, 0); + REQUIRE(validate.stdout_text.find("Validated basic_cpp -> OK") != std::string::npos); + + const ProcessResult validate_json = run_cli({"validate", "basic_cpp", "--json"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(validate_json.exit_code, 0); + REQUIRE(validate_json.stdout_text.find("\"status\": \"ok\"") != std::string::npos); + + const ProcessResult inspect_json = run_cli({"inspect", "layered_cpp_product", "--json"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(inspect_json.exit_code, 0); + REQUIRE(inspect_json.stdout_text.find("\"template_id\": \"layered_cpp_product\"") != std::string::npos); + REQUIRE(inspect_json.stdout_text.find("\"source_roots\": [") != std::string::npos); + + const ProcessResult lint = run_cli({"lint", "layered_cpp_product"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(lint.exit_code, 0); + REQUIRE(lint.stdout_text.find("Lint layered_cpp_product") != std::string::npos); + + const ProcessResult lint_json = run_cli({"lint", "layered_cpp_product", "--json"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(lint_json.exit_code, 0); + REQUIRE(lint_json.stdout_text.find("\"warning_count\": ") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_test_command_runs_fixtures_in_subprocess) { + CommandWorkspace fixture("test-command"); + const ProcessResult test_run = run_cli({"test", "basic_cpp"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(test_run.exit_code, 0); + REQUIRE(test_run.stdout_text.find("Test basic_cpp") != std::string::npos); + REQUIRE(test_run.stdout_text.find("2/2 fixtures passed") != std::string::npos); + + const ProcessResult test_json = run_cli({"test", "basic_cpp", "--json"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(test_json.exit_code, 0); + REQUIRE(test_json.stdout_text.find("\"template_id\": \"basic_cpp\"") != std::string::npos); + REQUIRE(test_json.stdout_text.find("\"fixtures\": [") != std::string::npos); + + const ProcessResult list_fixtures = run_cli({"test", "basic_cpp", "--list-fixtures"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(list_fixtures.exit_code, 0); + REQUIRE(list_fixtures.stdout_text.find("default_no_ci") != std::string::npos); + REQUIRE(list_fixtures.stdout_text.find("ci_enabled") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_refresh_and_completion_in_subprocess) { + CommandWorkspace fixture("refresh"); + const ProcessResult refresh = run_cli({"refresh"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(refresh.exit_code, 0); + REQUIRE(refresh.stdout_text.find("Refreshed ") != std::string::npos); + REQUIRE(std::filesystem::is_regular_file(fixture.data_home.shared_root() / "index" / "templates.json")); + + const ProcessResult refresh_json = run_cli({"refresh", "--json"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(refresh_json.exit_code, 0); + REQUIRE(refresh_json.stdout_text.find("\"refreshed\": ") != std::string::npos); + REQUIRE(refresh_json.stdout_text.find("\"index_file\": ") != std::string::npos); + + const ProcessResult completion = run_cli({"completion", "bash"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(completion.exit_code, 0); + REQUIRE(completion.stdout_text.find("tempify") != std::string::npos); + REQUIRE(completion.stdout_text.find("complete") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_questions_overview_in_subprocess) { + CommandWorkspace fixture("questions"); + const ProcessResult questions = run_cli({"advanced_hooks_layout", "-q"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(questions.exit_code, 0); + REQUIRE(questions.stdout_text.find("advanced_hooks_layout (Advanced Hooks Layout)") != std::string::npos); + REQUIRE(questions.stdout_text.find("[Project]") != std::string::npos); + + const ProcessResult questions_json = run_cli({"advanced_hooks_layout", "--questions", "--json"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(questions_json.exit_code, 0); + REQUIRE(questions_json.stdout_text.find("\"template\"") != std::string::npos); + REQUIRE(questions_json.stdout_text.find("\"advanced_hooks_layout\"") != std::string::npos); + REQUIRE(questions_json.stdout_text.find("\"Project\": [") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_dry_run_and_plan_json_write_no_files_in_subprocess) { + CommandWorkspace fixture("plan"); + ScopedDirectoryCleanup dry_target(fixture.cwd().parent_path() / "tempify-cli-commands-dry-run-target"); + ScopedDirectoryCleanup plan_target(fixture.cwd().parent_path() / "tempify-cli-commands-plan-json-target"); + + std::vector dry_args = hooks_render_args(dry_target.path(), "dry-run"); + dry_args.push_back("--dry-run"); + const ProcessResult dry_run = run_cli(dry_args, fixture.cwd(), fixture.env); + REQUIRE_EQ(dry_run.exit_code, 0); + REQUIRE(dry_run.stdout_text.find("Build root:") != std::string::npos); + REQUIRE(!std::filesystem::exists(dry_target.path() / "README.md")); + + std::vector plan_args = basic_cpp_render_args(plan_target.path(), "plan-json"); + plan_args.push_back("--plan-json"); + const ProcessResult plan_json = run_cli(plan_args, fixture.cwd(), fixture.env); + REQUIRE_EQ(plan_json.exit_code, 0); + REQUIRE(plan_json.stdout_text.find("\"build_root\"") != std::string::npos); + REQUIRE(plan_json.stdout_text.find("\"files\"") != std::string::npos); + REQUIRE(!std::filesystem::exists(plan_target.path() / "README.md")); +} + +TEST_CASE(TempifyCliCommandsE2E_diff_json_reports_changes_in_subprocess) { + CommandWorkspace fixture("diff-json"); + ScopedDirectoryCleanup target(fixture.cwd().parent_path() / "tempify-cli-commands-diff-json-target"); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "diff-json"), fixture.cwd(), fixture.env).exit_code, 0); + write_text_file(target.path() / "README.md", "# changed in subprocess\n"); + + std::vector diff_args = basic_cpp_render_args(target.path(), "diff-json"); + diff_args.push_back("--diff"); + diff_args.push_back("--json"); + const ProcessResult diff = run_cli(diff_args, fixture.cwd(), fixture.env); + REQUIRE_EQ(diff.exit_code, 0); + REQUIRE(diff.stdout_text.find("\"origin\": {") != std::string::npos); + REQUIRE(diff.stdout_text.find("\"reapply\": {") != std::string::npos); + REQUIRE(diff.stdout_text.find("README.md") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_reapply_subcommand_succeeds_in_subprocess) { + CommandWorkspace fixture("reapply"); + ScopedDirectoryCleanup target(fixture.cwd().parent_path() / "tempify-cli-commands-reapply-target"); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "reapply-cmd"), fixture.cwd(), fixture.env).exit_code, 0); + write_text_file(target.path() / "README.md", "# local readme\n"); + + const ProcessResult reapply = run_cli({ + "reapply", + "basic_cpp", + target.path().string(), + "--set", + "project_name=CLI reapply-cmd", + "--set", + "name_slug=reapply-cmd", + "--set", + "namespace=reapply_cmd_ns", + "--set", + "include_ci=false", + "--set", + "author=CLI Commands Tester", + }, + fixture.cwd(), fixture.env); + REQUIRE_EQ(reapply.exit_code, 0); + REQUIRE(reapply.stdout_text.find("Reapplied basic_cpp") != std::string::npos); + REQUIRE(read_text_file(target.path() / "README.md").find("# local readme") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_prebyte_passthrough_help_in_subprocess) { + CommandWorkspace fixture("prebyte"); + const ProcessResult prebyte_short = run_cli({"-p", "-h"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(prebyte_short.exit_code, 0); + + const ProcessResult prebyte_process = run_cli({"process", "--help"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(prebyte_process.exit_code, 0); + const std::string combined = prebyte_short.stdout_text + prebyte_short.stderr_text + prebyte_process.stdout_text + + prebyte_process.stderr_text; + REQUIRE(combined.find("Usage:") != std::string::npos || combined.find("usage") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_validate_missing_template_fails_in_subprocess) { + CommandWorkspace fixture("validate-missing"); + const ProcessResult result = run_cli({"validate", "definitely_missing_template_12345"}, fixture.cwd(), fixture.env); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("Template not found") != std::string::npos + || combined.find("not found") != std::string::npos); +} diff --git a/tests/security/e2e/TempifyHookSandboxE2ETests.cpp b/tests/security/e2e/TempifyHookSandboxE2ETests.cpp new file mode 100644 index 0000000..ae1b628 --- /dev/null +++ b/tests/security/e2e/TempifyHookSandboxE2ETests.cpp @@ -0,0 +1,97 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include "TempifyTestSupport.h" + +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; +using tempify::test_support::write_text_file; + +void create_minimal_hook_template(const std::filesystem::path &template_root, const std::string &template_id, + const std::string &post_hook_body) { + std::filesystem::create_directories(template_root / "files"); + std::filesystem::create_directories(template_root / "hooks"); + write_text_file(template_root / "template.lua", + "return {\n" + " id = \"" + + template_id + + "\",\n" + " name = \"Hook Sandbox Demo\",\n" + " version = \"1.0.0\",\n" + " source_dir = \"files\",\n" + " output = { path = \"{{ project_name }}\", overwrite = false },\n" + "}\n"); + write_text_file(template_root / "questions.lua", + "return {\n" + " order = { \"General\" },\n" + " groups = {\n" + " General = {\n" + " { key = \"project_name\", type = \"string\", prompt = \"Project name\" },\n" + " },\n" + " },\n" + "}\n"); + write_text_file(template_root / "files" / "README.md.pbt", "# {{ project_name }}\n"); + write_text_file(template_root / "hooks" / "post.lua", post_hook_body); +} + +} // namespace + +TEST_CASE(TempifyHookSandboxE2E_render_aborts_when_post_hook_writes_outside_build_root) { + ScopedDirectoryCleanup sandbox_root(std::filesystem::temp_directory_path() / "tempify-hook-sandbox-e2e-sandbox"); + ScopedDirectoryCleanup workspace(sandbox_root.path() / "workspace"); + ScopedDirectoryCleanup target(sandbox_root.path() / "target"); + ScopedDirectoryCleanup outside(sandbox_root.path() / "outside"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-hook-sandbox-e2e-data-home"); + const std::filesystem::path escape_file = outside.path() / "escaped.txt"; + create_minimal_hook_template(workspace.path() / "escape_hook_demo", + "escape_hook_demo", + "write_file('" + escape_file.string() + "', 'escaped')\n"); + + const ProcessResult result = run_cli({ + "escape_hook_demo", + target.path().string(), + "--set", + "project_name=Escape App", + }, + workspace.path(), isolated_cli_env(data_home.path())); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("Hook path escapes build root") != std::string::npos + || combined.find("Hook phase 'post' failed") != std::string::npos); + REQUIRE(!std::filesystem::exists(escape_file)); +} + +TEST_CASE(TempifyHookSandboxE2E_render_aborts_when_post_hook_uses_relative_traversal) { + ScopedDirectoryCleanup sandbox_root( + std::filesystem::temp_directory_path() / "tempify-hook-sandbox-traversal-sandbox"); + ScopedDirectoryCleanup workspace(sandbox_root.path() / "workspace"); + ScopedDirectoryCleanup target(sandbox_root.path() / "target"); + ScopedDirectoryCleanup outside(sandbox_root.path() / "outside"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-hook-sandbox-traversal-data-home"); + const std::filesystem::path escape_file = outside.path() / "traversal-escaped.txt"; + create_minimal_hook_template(workspace.path() / "traversal_hook_demo", + "traversal_hook_demo", + "write_file('../outside/traversal-escaped.txt', 'escaped')\n"); + + const ProcessResult result = run_cli({ + "traversal_hook_demo", + target.path().string(), + "--set", + "project_name=Traversal App", + }, + workspace.path(), isolated_cli_env(data_home.path())); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("Hook path escapes") != std::string::npos + || combined.find("Hook phase 'post' failed") != std::string::npos); + REQUIRE(!std::filesystem::exists(escape_file)); +} diff --git a/tests/support/TempifyTestSupport.h b/tests/support/TempifyTestSupport.h index 477d4e5..27a563c 100644 --- a/tests/support/TempifyTestSupport.h +++ b/tests/support/TempifyTestSupport.h @@ -2,12 +2,15 @@ #include "TestHarness.h" +#include "tempify/build/GenerationLock.h" + #include #include #include #include #include #include +#include #include #include #include @@ -401,4 +404,45 @@ inline void write_available_template_cache(const std::filesystem::path &shared_r write_text_file(shared_root / "index" / "reqpack-available.json", payload); } +inline void inject_stale_managed_file(const std::filesystem::path &target, const std::string &relative_path, + const std::string &content) { + write_text_file(target / relative_path, content); + const std::string hash = tempify::content_fingerprint_hex(content); + const std::filesystem::path lock_path = target / ".tempify-lock.json"; + std::string lock_text = read_text_file(lock_path); + + const std::string files_close = " ],\n \"managed_file_hashes\""; + const std::size_t files_close_pos = lock_text.find(files_close); + if (files_close_pos == std::string::npos) { + throw std::runtime_error("managed_files section not found in lock file"); + } + lock_text.insert(files_close_pos, + ",\n \"" + json_escape(relative_path) + "\""); + + const std::string hashes_close = " },\n \"values\""; + const std::size_t hashes_close_pos = lock_text.find(hashes_close); + if (hashes_close_pos == std::string::npos) { + throw std::runtime_error("managed_file_hashes section not found in lock file"); + } + lock_text.insert(hashes_close_pos, ",\n \"" + json_escape(relative_path) + "\": \"" + hash + "\""); + + write_text_file(lock_path, lock_text); +} + +inline void inject_stale_managed_file_without_hash(const std::filesystem::path &target, + const std::string &relative_path, const std::string &content) { + write_text_file(target / relative_path, content); + const std::filesystem::path lock_path = target / ".tempify-lock.json"; + std::string lock_text = read_text_file(lock_path); + + const std::string files_close = " ],\n \"managed_file_hashes\""; + const std::size_t files_close_pos = lock_text.find(files_close); + if (files_close_pos == std::string::npos) { + throw std::runtime_error("managed_files section not found in lock file"); + } + lock_text.insert(files_close_pos, ",\n \"" + json_escape(relative_path) + "\""); + + write_text_file(lock_path, lock_text); +} + } // namespace tempify::test_support diff --git a/tests/tempify/CorruptInputIntegrationTests.cpp b/tests/tempify/CorruptInputIntegrationTests.cpp new file mode 100644 index 0000000..a9b01a3 --- /dev/null +++ b/tests/tempify/CorruptInputIntegrationTests.cpp @@ -0,0 +1,269 @@ +#include "TempifyAppTestSupport.h" + +#include "tempify/build/GenerationLock.h" +#include "tempify/question/AnswerFile.h" +#include "tempify/support/Errors.h" + +#include +#include + +namespace { + +using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedStdoutCapture; +using tempify::test_support::ScopedTempifyDataHome; +using tempify::test_support::write_text_file; + +std::filesystem::path answers_path(const char *label) { + return std::filesystem::temp_directory_path() / ("tempify-corrupt-answers-" + std::string(label) + ".json"); +} + +std::filesystem::path lock_path(const std::filesystem::path &target) { + return target / ".tempify-lock.json"; +} + +void render_basic_cpp_target(tempify::TempifyApp &app, const std::filesystem::path &target) { + REQUIRE_EQ(app.run({ + "basic_cpp", + target.string(), + "--set", + "project_name=Corrupt Input App", + "--set", + "name_slug=corrupt-input-app", + "--set", + "namespace=corrupt_input_ns", + "--set", + "include_ci=false", + "--set", + "author=Corrupt Input Tester", + }), + 0); +} + +std::vector basic_cpp_render_args(const std::filesystem::path &target) { + return { + "basic_cpp", + target.string(), + "--set", + "project_name=Corrupt Input App", + "--set", + "name_slug=corrupt-input-app", + "--set", + "namespace=corrupt_input_ns", + "--set", + "include_ci=false", + "--set", + "author=Corrupt Input Tester", + }; +} + +} // namespace + +TEST_CASE(load_answer_file_rejects_malformed_json) { + const std::filesystem::path path = answers_path("malformed-json"); + write_text_file(path, "{ not valid json\n"); + + try { + static_cast(tempify::load_answer_file(path, false)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Could not parse answer file") != std::string::npos); + } +} + +TEST_CASE(load_answer_file_rejects_non_object_root) { + const std::filesystem::path path = answers_path("non-object-root"); + write_text_file(path, "[\"project_name\"]\n"); + + try { + static_cast(tempify::load_answer_file(path, false)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Answer file must be JSON object") != std::string::npos); + } +} + +TEST_CASE(load_answer_file_rejects_non_scalar_values) { + const std::filesystem::path path = answers_path("non-scalar-value"); + write_text_file(path, "{\n \"project_name\": { \"nested\": true }\n}\n"); + + try { + static_cast(tempify::load_answer_file(path, false)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("must be scalar") != std::string::npos); + } +} + +TEST_CASE(load_answer_file_reports_missing_file) { + const std::filesystem::path path = answers_path("missing-file"); + std::filesystem::remove(path); + + try { + static_cast(tempify::load_answer_file(path, false)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Answer file not found") != std::string::npos); + } +} + +TEST_CASE(load_generation_lock_rejects_malformed_json) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-corrupt-lock-malformed.json"; + write_text_file(path, "{ broken lock\n"); + + try { + static_cast(tempify::load_generation_lock(path)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Could not parse lock file") != std::string::npos); + } +} + +TEST_CASE(load_generation_lock_rejects_non_object_root) { + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-corrupt-lock-array.json"; + write_text_file(path, "[]\n"); + + try { + static_cast(tempify::load_generation_lock(path)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Lock file must be JSON object") != std::string::npos); + } +} + +TEST_CASE(load_generation_lock_rejects_invalid_managed_files_type) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-corrupt-lock-managed-files.json"; + write_text_file(path, "{\n \"managed_files\": \"not-an-array\"\n}\n"); + + try { + static_cast(tempify::load_generation_lock(path)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("managed_files") != std::string::npos); + REQUIRE(std::string(error.what()).find("must be array") != std::string::npos); + } +} + +TEST_CASE(load_generation_lock_rejects_invalid_template_field_type) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-corrupt-lock-template-field.json"; + write_text_file(path, "{\n \"template\": \"not-an-object\"\n}\n"); + + try { + static_cast(tempify::load_generation_lock(path)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("template") != std::string::npos); + REQUIRE(std::string(error.what()).find("must be object") != std::string::npos); + } +} + +TEST_CASE(load_generation_lock_rejects_invalid_managed_file_hashes_value_type) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-corrupt-lock-managed-hashes.json"; + write_text_file(path, "{\n \"managed_file_hashes\": { \"README.md\": 123 }\n}\n"); + + try { + static_cast(tempify::load_generation_lock(path)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("managed_file_hashes") != std::string::npos); + REQUIRE(std::string(error.what()).find("must contain only string values") != std::string::npos); + } +} + +TEST_CASE(TempifyApp_diff_rejects_corrupt_lock_file) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-diff-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-diff-data-home"); + tempify::TempifyApp app; + render_basic_cpp_target(app, target.path()); + write_text_file(lock_path(target.path()), "{ broken lock file\n"); + + try { + auto args = basic_cpp_render_args(target.path()); + args.push_back("--diff"); + static_cast(app.run(args)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Could not parse lock file") != std::string::npos); + } +} + +TEST_CASE(TempifyApp_reapply_rejects_corrupt_lock_file) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-reapply-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-lock-reapply-data-home"); + tempify::TempifyApp app; + render_basic_cpp_target(app, target.path()); + write_text_file(lock_path(target.path()), "{ broken lock file\n"); + + try { + auto args = basic_cpp_render_args(target.path()); + args.push_back("--reapply"); + static_cast(app.run(args)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Could not parse lock file") != std::string::npos); + } +} + +TEST_CASE(TempifyApp_render_rejects_corrupt_answers_file) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-render-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-render-data-home"); + const std::filesystem::path answers_file = answers_path("render-corrupt"); + write_text_file(answers_file, "{ broken answers\n"); + + tempify::TempifyApp app; + try { + static_cast(app.run({ + "basic_cpp", + target.path().string(), + "--answers", + answers_file.string(), + "--set", + "author=Corrupt Input Tester", + "--non-interactive", + })); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Could not parse answer file") != std::string::npos); + } +} + +TEST_CASE(TempifyApp_render_rejects_answers_file_with_nested_object_value) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-nested-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-nested-data-home"); + const std::filesystem::path answers_file = answers_path("render-nested"); + write_text_file(answers_file, "{\n \"project_name\": { \"nested\": true }\n}\n"); + + tempify::TempifyApp app; + REQUIRE_THROWS_AS(app.run({ + "basic_cpp", + target.path().string(), + "--answers", + answers_file.string(), + "--set", + "author=Corrupt Input Tester", + "--non-interactive", + }), + tempify::TempifyError); +} + +TEST_CASE(TempifyApp_list_fails_when_available_cache_json_is_corrupt) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-corrupt-cache-list-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-cache-list-data-home"); + tempify::test_support::link_test_templates_into_workspace(workspace.path()); + const std::filesystem::path cache_file = data_home.shared_root() / "index" / "reqpack-available.json"; + std::filesystem::create_directories(cache_file.parent_path()); + write_text_file(cache_file, "{ broken cache\n"); + + tempify::TempifyApp app; + try { + static_cast(app.run({"list"})); + REQUIRE(false); + } catch (const std::exception &error) { + REQUIRE(std::string(error.what()).find("JSON") != std::string::npos); + } +} diff --git a/tests/tempify/LuaEngineHookSandboxTests.cpp b/tests/tempify/LuaEngineHookSandboxTests.cpp new file mode 100644 index 0000000..8618ffe --- /dev/null +++ b/tests/tempify/LuaEngineHookSandboxTests.cpp @@ -0,0 +1,150 @@ +#include "TempifyTestSupport.h" +#include "TestHarness.h" +#include "tempify/lua/LuaEngine.h" +#include "tempify/prebyte/PrebyteRenderer.h" +#include "tempify/support/Errors.h" + +#include +#include + +namespace { + +using tempify::test_support::read_text_file; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::test_template_path; +using tempify::test_support::write_text_file; + +struct HookSandboxFixture { + ScopedDirectoryCleanup sandbox_root; + ScopedDirectoryCleanup build_root; + ScopedDirectoryCleanup outside_root; + + HookSandboxFixture(const char *label) + : sandbox_root(std::filesystem::temp_directory_path() / (std::string("tempify-hook-sandbox-") + label)), + build_root(sandbox_root.path() / "build"), + outside_root(sandbox_root.path() / "outside") { + std::filesystem::create_directories(build_root.path()); + std::filesystem::create_directories(outside_root.path()); + } + + void run_hook_script(const std::string &script_body) const { + const std::filesystem::path hook_path = build_root.path() / "hook.lua"; + write_text_file(hook_path, script_body); + + tempify::LuaEngine lua_engine; + const tempify::TemplateManifest manifest = + lua_engine.load_partial_manifest(test_template_path("advanced_hooks_layout")); + const tempify::PrebyteRenderer renderer; + const tempify::BuildContext context{ + .template_root = manifest.root, + .build_root = build_root.path(), + .values = {{"project_name", "Sandbox"}, {"project_slug", "sandbox"}, {"use_notes", "false"}}, + }; + + lua_engine.run_hook(hook_path, manifest, context, renderer); + } +}; + +std::string escape_message(const tempify::TempifyError &error) { + return std::string(error.what()); +} + +} // namespace + +TEST_CASE(LuaEngine_run_hook_write_file_rejects_absolute_path_outside_build_root) { + HookSandboxFixture fixture("absolute-write"); + const std::filesystem::path escape_file = fixture.outside_root.path() / "escaped.txt"; + const std::string hook = "write_file('" + escape_file.string() + "', 'escaped')\n"; + + try { + fixture.run_hook_script(hook); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(escape_message(error).find("Hook path escapes build root") != std::string::npos); + } + + REQUIRE(!std::filesystem::exists(escape_file)); +} + +TEST_CASE(LuaEngine_run_hook_write_file_rejects_relative_path_traversal) { + HookSandboxFixture fixture("relative-write"); + const std::filesystem::path escape_file = fixture.outside_root.path() / "escaped.txt"; + const std::string hook = "write_file('../outside/escaped.txt', 'escaped')\n"; + + try { + fixture.run_hook_script(hook); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + const std::string message = escape_message(error); + REQUIRE(message.find("Hook path escapes") != std::string::npos); + } + + REQUIRE(!std::filesystem::exists(escape_file)); +} + +TEST_CASE(LuaEngine_run_hook_read_file_rejects_absolute_path_outside_allowed_roots) { + HookSandboxFixture fixture("absolute-read"); + const std::filesystem::path secret_file = fixture.outside_root.path() / "secret.txt"; + write_text_file(secret_file, "top-secret"); + + const std::string hook = "local content = read_file('" + secret_file.string() + "')\n" + "write_file('copied.txt', content)\n"; + + try { + fixture.run_hook_script(hook); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(escape_message(error).find("Hook path escapes allowed roots") != std::string::npos); + } + + REQUIRE(!std::filesystem::exists(fixture.build_root.path() / "copied.txt")); +} + +TEST_CASE(LuaEngine_run_hook_mkdir_and_remove_reject_paths_outside_build_root) { + HookSandboxFixture fixture("mkdir-remove"); + const std::filesystem::path escape_dir = fixture.outside_root.path() / "escaped-dir"; + + try { + fixture.run_hook_script("mkdir('" + escape_dir.string() + "')\n"); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(escape_message(error).find("Hook path escapes build root") != std::string::npos); + } + REQUIRE(!std::filesystem::exists(escape_dir)); + + write_text_file(fixture.outside_root.path() / "remove-me.txt", "delete"); + try { + fixture.run_hook_script("remove('" + (fixture.outside_root.path() / "remove-me.txt").string() + "')\n"); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(escape_message(error).find("Hook path escapes build root") != std::string::npos); + } + REQUIRE(std::filesystem::exists(fixture.outside_root.path() / "remove-me.txt")); +} + +TEST_CASE(LuaEngine_run_hook_copy_rejects_output_outside_build_root) { + HookSandboxFixture fixture("copy-output"); + write_text_file(fixture.build_root.path() / "inside.txt", "inside"); + const std::filesystem::path escape_file = fixture.outside_root.path() / "escaped-copy.txt"; + + try { + fixture.run_hook_script("copy('inside.txt', '" + escape_file.string() + "')\n"); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(escape_message(error).find("Hook path escapes build root") != std::string::npos); + } + + REQUIRE(!std::filesystem::exists(escape_file)); +} + +TEST_CASE(LuaEngine_run_hook_still_allows_template_relative_reads_and_build_root_writes) { + HookSandboxFixture fixture("allowed-ops"); + fixture.run_hook_script( + "local template_text = read_file('files/docs/readme.txt.pbt')\n" + "write_file('allowed.txt', template_text)\n" + "mkdir('nested')\n"); + + REQUIRE(std::filesystem::exists(fixture.build_root.path() / "allowed.txt")); + REQUIRE(read_text_file(fixture.build_root.path() / "allowed.txt").find("{{ project_name }}") != std::string::npos); + REQUIRE(std::filesystem::is_directory(fixture.build_root.path() / "nested")); +} diff --git a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp index d5e2aed..b345e80 100644 --- a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp +++ b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp @@ -825,3 +825,564 @@ TEST_CASE(Tempify_json_error_wraps_reapply_subcommand_block_with_structured_deta REQUIRE(error_output.find("README.md") != std::string::npos); std::filesystem::remove(stderr_file); } + +TEST_CASE(TempifyApp_reapply_deletes_managed_file_no_longer_in_template_output) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete App", + "--set", + "name_slug=reapply-delete-app", + "--set", + "namespace=reapply_delete_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); + + ScopedStdoutCapture capture; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete App", + "--set", + "name_slug=reapply-delete-app", + "--set", + "namespace=reapply_delete_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Tester", + "--reapply", + }), + 0); + + const std::string output = capture.str(); + REQUIRE(output.find("Reapplied basic_cpp") != std::string::npos); + REQUIRE(output.find("Deleted: 1") != std::string::npos); + REQUIRE(!std::filesystem::exists(target.path() / "old-managed.txt")); + const std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); + REQUIRE(lock_text.find("old-managed.txt") == std::string::npos); + REQUIRE(std::filesystem::is_regular_file(target.path() / "README.md")); +} + +TEST_CASE(TempifyApp_reapply_blocks_delete_when_stale_file_was_locally_modified) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-conflict-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-conflict-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Conflict App", + "--set", + "name_slug=reapply-delete-conflict-app", + "--set", + "namespace=reapply_delete_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Conflict Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + write_text_file(target.path() / "old-managed.txt", "user-edited-stale-content\n"); + + try { + static_cast(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Conflict App", + "--set", + "name_slug=reapply-delete-conflict-app", + "--set", + "namespace=reapply_delete_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Conflict Tester", + "--reapply", + })); + REQUIRE(false); + } catch (const tempify::ReapplyBlockedError &error) { + REQUIRE(std::string(error.what()).find("conflict item") != std::string::npos); + REQUIRE(std::string(error.what()).find("old-managed.txt") != std::string::npos); + } + + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); + REQUIRE_EQ(read_text_file(target.path() / "old-managed.txt"), std::string("user-edited-stale-content\n")); +} + +TEST_CASE(TempifyApp_reapply_json_reports_deleted_managed_file) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Json App", + "--set", + "name_slug=reapply-delete-json-app", + "--set", + "namespace=reapply_delete_json_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Json Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + + ScopedStdoutCapture capture; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Json App", + "--set", + "name_slug=reapply-delete-json-app", + "--set", + "namespace=reapply_delete_json_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Json Tester", + "--reapply", + "--json", + }), + 0); + + const std::string output = capture.str(); + REQUIRE(output.find("\"delete\": 1") != std::string::npos); + REQUIRE(output.find("\"applied\"") != std::string::npos); + REQUIRE(output.find("old-managed.txt") != std::string::npos); + REQUIRE(!std::filesystem::exists(target.path() / "old-managed.txt")); +} + +TEST_CASE(TempifyApp_reapply_blocks_delete_when_lock_has_no_baseline_hash) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-review-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-review-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Review App", + "--set", + "name_slug=reapply-delete-review-app", + "--set", + "namespace=reapply_delete_review_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Review Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file_without_hash(target.path(), "old-managed.txt", + "removed-by-template-update\n"); + + try { + static_cast(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Review App", + "--set", + "name_slug=reapply-delete-review-app", + "--set", + "namespace=reapply_delete_review_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Review Tester", + "--reapply", + })); + REQUIRE(false); + } catch (const tempify::ReapplyBlockedError &error) { + REQUIRE(std::string(error.what()).find("review item") != std::string::npos); + REQUIRE(std::string(error.what()).find("old-managed.txt") != std::string::npos); + } + + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); +} + +TEST_CASE(TempifyApp_reapply_report_shows_delete_candidate_without_removing_file) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-report-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-report-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Report App", + "--set", + "name_slug=reapply-delete-report-app", + "--set", + "namespace=reapply_delete_report_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Report Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + const std::string stale_content = read_text_file(target.path() / "old-managed.txt"); + + ScopedStdoutCapture capture; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Report App", + "--set", + "name_slug=reapply-delete-report-app", + "--set", + "namespace=reapply_delete_report_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Report Tester", + "--reapply", + "--report", + }), + 0); + + const std::string output = capture.str(); + REQUIRE(output.find("delete old-managed.txt") != std::string::npos); + REQUIRE(output.find("Reapply delete: 1") != std::string::npos); + REQUIRE_EQ(read_text_file(target.path() / "old-managed.txt"), stale_content); + REQUIRE(read_text_file(target.path() / ".tempify-lock.json").find("old-managed.txt") != std::string::npos); +} + +TEST_CASE(TempifyApp_reapply_json_blocks_delete_conflict_without_removing_stale_file) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-conflict-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-json-conflict-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Json Conflict App", + "--set", + "name_slug=reapply-delete-json-conflict-app", + "--set", + "namespace=reapply_delete_json_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Json Conflict Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + write_text_file(target.path() / "old-managed.txt", "user-edited-stale-content\n"); + + const std::filesystem::path stderr_file = + std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-conflict-stderr.txt"; + std::filesystem::remove(stderr_file); + const int exit_code = run_tempify_and_capture_stderr( + { + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Json Conflict App", + "--set", + "name_slug=reapply-delete-json-conflict-app", + "--set", + "namespace=reapply_delete_json_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Json Conflict Tester", + "--reapply", + "--json", + }, + stderr_file); + + REQUIRE(exit_code != 0); + const std::string error_output = read_text_file(stderr_file); + REQUIRE(error_output.find("\"code\": \"REAPPLY_BLOCKED\"") != std::string::npos); + REQUIRE(error_output.find("old-managed.txt") != std::string::npos); + REQUIRE(error_output.find("\"conflict\": [") != std::string::npos); + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); + std::filesystem::remove(stderr_file); +} + +TEST_CASE(TempifyApp_reapply_subcommand_blocks_delete_when_stale_file_was_locally_modified) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-subcommand-conflict-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-subcommand-conflict-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Subcommand Conflict App", + "--set", + "name_slug=reapply-delete-subcommand-conflict-app", + "--set", + "namespace=reapply_delete_subcommand_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Subcommand Conflict Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + write_text_file(target.path() / "old-managed.txt", "user-edited-stale-content\n"); + + const std::filesystem::path stderr_file = + std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-subcommand-conflict-stderr.txt"; + std::filesystem::remove(stderr_file); + const int exit_code = run_tempify_and_capture_stderr( + { + "reapply", + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Subcommand Conflict App", + "--set", + "name_slug=reapply-delete-subcommand-conflict-app", + "--set", + "namespace=reapply_delete_subcommand_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Subcommand Conflict Tester", + }, + stderr_file); + + REQUIRE(exit_code != 0); + const std::string error_output = read_text_file(stderr_file); + REQUIRE(error_output.find("conflict item") != std::string::npos || error_output.find("REAPPLY_BLOCKED") != std::string::npos); + REQUIRE(error_output.find("old-managed.txt") != std::string::npos); + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); + std::filesystem::remove(stderr_file); +} + +TEST_CASE(TempifyApp_reapply_blocks_delete_on_version_downgrade_without_removing_stale_file) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-downgrade-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-downgrade-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Downgrade App", + "--set", + "name_slug=reapply-delete-downgrade-app", + "--set", + "namespace=reapply_delete_downgrade_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Downgrade Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + const std::string stale_content = read_text_file(target.path() / "old-managed.txt"); + + std::string lock_text = read_text_file(target.path() / ".tempify-lock.json"); + const std::string current_version = "\"version\": \"0.1.0\""; + const auto version_pos = lock_text.find(current_version); + REQUIRE(version_pos != std::string::npos); + lock_text.replace(version_pos, current_version.size(), "\"version\": \"0.2.0\""); + write_text_file(target.path() / ".tempify-lock.json", lock_text); + + try { + static_cast(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Downgrade App", + "--set", + "name_slug=reapply-delete-downgrade-app", + "--set", + "namespace=reapply_delete_downgrade_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Downgrade Tester", + "--reapply", + })); + REQUIRE(false); + } catch (const tempify::ReapplyBlockedError &error) { + REQUIRE(std::string(error.what()).find("version downgrade") != std::string::npos); + } + + REQUIRE_EQ(read_text_file(target.path() / "old-managed.txt"), stale_content); +} + +TEST_CASE(TempifyApp_diff_only_leaves_stale_managed_file_on_disk) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-diff-only-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-diff-only-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Diff App", + "--set", + "name_slug=reapply-delete-diff-app", + "--set", + "namespace=reapply_delete_diff_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Diff Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + const std::string stale_content = read_text_file(target.path() / "old-managed.txt"); + + ScopedStdoutCapture capture; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Diff App", + "--set", + "name_slug=reapply-delete-diff-app", + "--set", + "namespace=reapply_delete_diff_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Diff Tester", + "--diff", + }), + 0); + + const std::string output = capture.str(); + REQUIRE(output.find("delete old-managed.txt") != std::string::npos); + REQUIRE(output.find("Delete: 1") != std::string::npos); + REQUIRE_EQ(read_text_file(target.path() / "old-managed.txt"), stale_content); +} + +TEST_CASE(TempifyApp_reapply_json_blocks_delete_review_when_lock_has_no_baseline_hash) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-review-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-json-review-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Json Review App", + "--set", + "name_slug=reapply-delete-json-review-app", + "--set", + "namespace=reapply_delete_json_review_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Json Review Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file_without_hash(target.path(), "old-managed.txt", + "removed-by-template-update\n"); + + const std::filesystem::path stderr_file = + std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-review-stderr.txt"; + std::filesystem::remove(stderr_file); + const int exit_code = run_tempify_and_capture_stderr( + { + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Json Review App", + "--set", + "name_slug=reapply-delete-json-review-app", + "--set", + "namespace=reapply_delete_json_review_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Json Review Tester", + "--reapply", + "--json", + }, + stderr_file); + + REQUIRE(exit_code != 0); + const std::string error_output = read_text_file(stderr_file); + REQUIRE(error_output.find("\"code\": \"REAPPLY_BLOCKED\"") != std::string::npos); + REQUIRE(error_output.find("old-managed.txt") != std::string::npos); + REQUIRE(error_output.find("\"review\": [") != std::string::npos); + REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); + std::filesystem::remove(stderr_file); +} + +TEST_CASE(TempifyApp_reapply_report_json_shows_delete_without_removing_file) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-report-json-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-report-json-data-home"); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Report Json App", + "--set", + "name_slug=reapply-delete-report-json-app", + "--set", + "namespace=reapply_delete_report_json_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Report Json Tester", + }), + 0); + + tempify::test_support::inject_stale_managed_file(target.path(), "old-managed.txt", "removed-by-template-update\n"); + const std::string stale_content = read_text_file(target.path() / "old-managed.txt"); + + ScopedStdoutCapture capture; + REQUIRE_EQ(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Reapply Delete Report Json App", + "--set", + "name_slug=reapply-delete-report-json-app", + "--set", + "namespace=reapply_delete_report_json_ns", + "--set", + "include_ci=false", + "--set", + "author=Reapply Delete Report Json Tester", + "--reapply", + "--report", + "--json", + }), + 0); + + const std::string output = capture.str(); + REQUIRE(output.find("\"reapply_action\": \"delete\"") != std::string::npos); + REQUIRE(output.find("old-managed.txt") != std::string::npos); + REQUIRE(output.find("\"applied\": {") == std::string::npos); + REQUIRE_EQ(read_text_file(target.path() / "old-managed.txt"), stale_content); +} + From b7703ac72271ae8f34ee92d547adf29a752c518d Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 21:37:11 +0200 Subject: [PATCH 09/29] Adding more tests --- .../cli/TempifyCliCommandsE2ETests.cpp | 25 +++-- .../cli/TempifyPrebytePassthroughE2ETests.cpp | 52 +++++++++ .../cli/TempifyRemovedCommandsE2ETests.cpp | 42 ++++++++ tests/tempify/AnswerFileTests.cpp | 44 ++++++++ .../tempify/CliParserRemovedCommandsTests.cpp | 46 ++++++++ tests/tempify/EnvLoaderTests.cpp | 46 ++++++++ tests/tempify/GenerationLockTests.cpp | 61 +++++++++++ tests/tempify/PrebyteCommandRunnerTests.cpp | 7 ++ tests/tempify/ReapplySerializationTests.cpp | 85 +++++++++++++++ tests/tempify/ShellCompletionTests.cpp | 75 +++++++++++++ .../TempifyAppConfigPlanIntegrationTests.cpp | 102 ++++++++++++++++++ tests/tempify/TemplateInspectorTests.cpp | 24 +++++ tests/tempify/TemplateLinterTests.cpp | 92 ++++++++++++++++ tests/tempify/TemplateValidatorTests.cpp | 70 ++++++++++++ 14 files changed, 765 insertions(+), 6 deletions(-) create mode 100644 tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp create mode 100644 tests/portability/cli/TempifyRemovedCommandsE2ETests.cpp create mode 100644 tests/tempify/AnswerFileTests.cpp create mode 100644 tests/tempify/CliParserRemovedCommandsTests.cpp create mode 100644 tests/tempify/EnvLoaderTests.cpp create mode 100644 tests/tempify/GenerationLockTests.cpp create mode 100644 tests/tempify/PrebyteCommandRunnerTests.cpp create mode 100644 tests/tempify/ReapplySerializationTests.cpp create mode 100644 tests/tempify/ShellCompletionTests.cpp create mode 100644 tests/tempify/TemplateInspectorTests.cpp create mode 100644 tests/tempify/TemplateLinterTests.cpp create mode 100644 tests/tempify/TemplateValidatorTests.cpp diff --git a/tests/portability/cli/TempifyCliCommandsE2ETests.cpp b/tests/portability/cli/TempifyCliCommandsE2ETests.cpp index 449568a..86c4c14 100644 --- a/tests/portability/cli/TempifyCliCommandsE2ETests.cpp +++ b/tests/portability/cli/TempifyCliCommandsE2ETests.cpp @@ -131,7 +131,25 @@ TEST_CASE(TempifyCliCommandsE2E_test_command_runs_fixtures_in_subprocess) { REQUIRE(list_fixtures.stdout_text.find("ci_enabled") != std::string::npos); } -TEST_CASE(TempifyCliCommandsE2E_refresh_and_completion_in_subprocess) { +TEST_CASE(TempifyCliCommandsE2E_completion_scripts_for_all_shells_in_subprocess) { + CommandWorkspace fixture("completion"); + const ProcessResult bash = run_cli({"completion", "bash"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(bash.exit_code, 0); + REQUIRE(bash.stdout_text.find("complete -F _tempify tempify") != std::string::npos); + REQUIRE(bash.stdout_text.find("reapply") != std::string::npos); + + const ProcessResult zsh = run_cli({"completion", "zsh"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(zsh.exit_code, 0); + REQUIRE(zsh.stdout_text.find("#compdef tempify") != std::string::npos); + REQUIRE(zsh.stdout_text.find("compdef _tempify tempify") != std::string::npos); + + const ProcessResult fish = run_cli({"completion", "fish"}, fixture.cwd(), fixture.env); + REQUIRE_EQ(fish.exit_code, 0); + REQUIRE(fish.stdout_text.find("complete -c tempify -f") != std::string::npos); + REQUIRE(fish.stdout_text.find("__fish_use_subcommand") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_refresh_in_subprocess) { CommandWorkspace fixture("refresh"); const ProcessResult refresh = run_cli({"refresh"}, fixture.cwd(), fixture.env); REQUIRE_EQ(refresh.exit_code, 0); @@ -142,11 +160,6 @@ TEST_CASE(TempifyCliCommandsE2E_refresh_and_completion_in_subprocess) { REQUIRE_EQ(refresh_json.exit_code, 0); REQUIRE(refresh_json.stdout_text.find("\"refreshed\": ") != std::string::npos); REQUIRE(refresh_json.stdout_text.find("\"index_file\": ") != std::string::npos); - - const ProcessResult completion = run_cli({"completion", "bash"}, fixture.cwd(), fixture.env); - REQUIRE_EQ(completion.exit_code, 0); - REQUIRE(completion.stdout_text.find("tempify") != std::string::npos); - REQUIRE(completion.stdout_text.find("complete") != std::string::npos); } TEST_CASE(TempifyCliCommandsE2E_questions_overview_in_subprocess) { diff --git a/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp b/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp new file mode 100644 index 0000000..8e2eba7 --- /dev/null +++ b/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp @@ -0,0 +1,52 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; + +} // namespace + +TEST_CASE(TempifyPrebytePassthroughE2E_short_and_long_aliases_run_embedded_prebyte) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-prebyte-passthrough-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-prebyte-passthrough-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + + const ProcessResult short_version = run_cli({"-p", "--version"}, workspace_path, env); + REQUIRE_EQ(short_version.exit_code, 0); + REQUIRE(short_version.stdout_text.find("v1.") != std::string::npos); + + const ProcessResult long_version = run_cli({"--prebyte", "--version"}, workspace_path, env); + REQUIRE_EQ(long_version.exit_code, 0); + REQUIRE(long_version.stdout_text.find("v1.") != std::string::npos); + + const ProcessResult process_version = run_cli({"process", "--version"}, workspace_path, env); + REQUIRE_EQ(process_version.exit_code, 0); + REQUIRE(process_version.stdout_text.find("v1.") != std::string::npos); +} + +TEST_CASE(TempifyPrebytePassthroughE2E_process_help_matches_tempify_wrapper) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-prebyte-help-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-prebyte-help-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + + const ProcessResult short_help = run_cli({"-p", "-h"}, workspace_path, env); + REQUIRE_EQ(short_help.exit_code, 0); + REQUIRE(short_help.stdout_text.find("Embedded Prebyte Passthrough") != std::string::npos); + + const ProcessResult process_help = run_cli({"process", "--help"}, workspace_path, env); + REQUIRE_EQ(process_help.exit_code, 0); + REQUIRE(process_help.stdout_text.find("Embedded Prebyte Passthrough") != std::string::npos); +} diff --git a/tests/portability/cli/TempifyRemovedCommandsE2ETests.cpp b/tests/portability/cli/TempifyRemovedCommandsE2ETests.cpp new file mode 100644 index 0000000..4773a32 --- /dev/null +++ b/tests/portability/cli/TempifyRemovedCommandsE2ETests.cpp @@ -0,0 +1,42 @@ +#include "CliProcess.h" +#include "E2ETestSupport.h" +#include "TestHarness.h" + +#include +#include +#include + +namespace { + +using tempify::test::ProcessResult; +using tempify::test::run_cli; +using tempify::test_support::e2e::isolated_cli_env; +using tempify::test_support::e2e::prepare_template_workspace; +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; + +void require_removed_command_subprocess(const std::vector &args, const std::string &command_name, + const std::string &migration_hint, const std::filesystem::path &workspace, + const std::map &env) { + const ProcessResult result = run_cli(args, workspace, env); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("Command `" + command_name + "` removed") != std::string::npos); + REQUIRE(combined.find(migration_hint) != std::string::npos); +} + +} // namespace + +TEST_CASE(TempifyRemovedCommandsE2E_subprocess_reports_migration_errors) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-removed-commands-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-removed-commands-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + + require_removed_command_subprocess({"template", "list"}, "template", "tempify list", workspace_path, env); + require_removed_command_subprocess({"prebyte", "render"}, "prebyte", "tempify -p", workspace_path, env); + require_removed_command_subprocess({"schema", "basic_cpp"}, "schema", "-q|--questions", workspace_path, env); + require_removed_command_subprocess({"questions", "basic_cpp"}, "questions", "-q|--questions", workspace_path, env); + require_removed_command_subprocess({"registry", "refresh"}, "registry", "tempify refresh", workspace_path, env); +} diff --git a/tests/tempify/AnswerFileTests.cpp b/tests/tempify/AnswerFileTests.cpp new file mode 100644 index 0000000..0e6c58c --- /dev/null +++ b/tests/tempify/AnswerFileTests.cpp @@ -0,0 +1,44 @@ +#include "TempifyTestSupport.h" +#include "TestHarness.h" +#include "tempify/question/AnswerFile.h" + +#include +#include + +namespace { + +using tempify::test_support::read_text_file; +using tempify::test_support::write_text_file; + +} // namespace + +TEST_CASE(AnswerFile_roundtrip_preserves_scalar_values) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-answer-file-roundtrip.json"; + const std::map original = { + {"project_name", "Roundtrip App"}, + {"include_ci", "true"}, + {"count", "3"}, + }; + tempify::write_answer_file(path, original); + + const std::map loaded = tempify::load_answer_file(path, false); + REQUIRE_EQ(loaded.at("project_name"), std::string("Roundtrip App")); + REQUIRE_EQ(loaded.at("include_ci"), std::string("true")); + REQUIRE_EQ(loaded.at("count"), std::string("3")); + REQUIRE(read_text_file(path).find("\"project_name\": \"Roundtrip App\"") != std::string::npos); +} + +TEST_CASE(AnswerFile_loads_bool_and_number_scalars) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-answer-file-scalars.json"; + write_text_file(path, + "{\n" + " \"enabled\": false,\n" + " \"retries\": 2\n" + "}\n"); + + const std::map loaded = tempify::load_answer_file(path, false); + REQUIRE_EQ(loaded.at("enabled"), std::string("false")); + REQUIRE_EQ(loaded.at("retries"), std::string("2")); +} diff --git a/tests/tempify/CliParserRemovedCommandsTests.cpp b/tests/tempify/CliParserRemovedCommandsTests.cpp new file mode 100644 index 0000000..59441a6 --- /dev/null +++ b/tests/tempify/CliParserRemovedCommandsTests.cpp @@ -0,0 +1,46 @@ +#include "TestHarness.h" +#include "tempify/cli/CliParser.h" +#include "tempify/support/Errors.h" + +#include + +namespace { + +void require_removed_command_error(tempify::CliParser &parser, const std::vector &args, + const std::string &command_name, const std::string &migration_hint) { + try { + static_cast(parser.parse(args)); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + const std::string message = error.what(); + REQUIRE(message.find("Command `" + command_name + "` removed") != std::string::npos); + REQUIRE(message.find(migration_hint) != std::string::npos); + } +} + +} // namespace + +TEST_CASE(CliParser_reports_migration_errors_for_removed_commands) { + tempify::CliParser parser; + + require_removed_command_error(parser, {"template", "list"}, "template", "tempify list"); + require_removed_command_error(parser, {"prebyte", "render"}, "prebyte", "tempify -p"); + require_removed_command_error(parser, {"schema", "basic_cpp"}, "schema", "-q|--questions"); + require_removed_command_error(parser, {"questions", "basic_cpp"}, "questions", "-q|--questions"); + require_removed_command_error(parser, {"registry", "refresh"}, "registry", "tempify refresh"); +} + +TEST_CASE(CliParser_parses_prebyte_passthrough_aliases) { + tempify::CliParser parser; + + const tempify::CliRequest short_form = parser.parse({"-p", "--version"}); + REQUIRE(short_form.mode == tempify::CliMode::PrebytePassthrough); + REQUIRE_EQ(short_form.raw_prebyte_args.size(), static_cast(1)); + REQUIRE_EQ(short_form.raw_prebyte_args[0], std::string("--version")); + + const tempify::CliRequest long_form = parser.parse({"--prebyte", "render", "--flag"}); + REQUIRE(long_form.mode == tempify::CliMode::PrebytePassthrough); + REQUIRE_EQ(long_form.raw_prebyte_args.size(), static_cast(2)); + REQUIRE_EQ(long_form.raw_prebyte_args[0], std::string("render")); + REQUIRE_EQ(long_form.raw_prebyte_args[1], std::string("--flag")); +} diff --git a/tests/tempify/EnvLoaderTests.cpp b/tests/tempify/EnvLoaderTests.cpp new file mode 100644 index 0000000..b819c4a --- /dev/null +++ b/tests/tempify/EnvLoaderTests.cpp @@ -0,0 +1,46 @@ +#include "TempifyTestSupport.h" +#include "TestHarness.h" +#include "tempify/support/EnvLoader.h" + +#include + +namespace { + +using tempify::test_support::write_text_file; + +} // namespace + +TEST_CASE(EnvLoader_ignores_comments_and_blank_lines) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-env-loader-comments.env"; + write_text_file(path, + "# comment\n" + "\n" + "PROJECT_NAME=Demo App\n" + "AUTHOR='Quoted Author'\n"); + + const std::map values = tempify::load_env_file(path); + REQUIRE_EQ(values.at("PROJECT_NAME"), std::string("Demo App")); + REQUIRE_EQ(values.at("AUTHOR"), std::string("Quoted Author")); +} + +TEST_CASE(EnvLoader_returns_empty_map_for_missing_file) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-env-loader-missing.env"; + std::filesystem::remove(path); + REQUIRE(tempify::load_env_file(path).empty()); +} + +TEST_CASE(EnvLoader_skips_lines_without_separator) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-env-loader-invalid.env"; + write_text_file(path, + "VALID=yes\n" + "INVALID_LINE\n" + "OTHER=value\n"); + + const std::map values = tempify::load_env_file(path); + REQUIRE_EQ(values.size(), static_cast(2)); + REQUIRE_EQ(values.at("VALID"), std::string("yes")); + REQUIRE_EQ(values.at("OTHER"), std::string("value")); +} diff --git a/tests/tempify/GenerationLockTests.cpp b/tests/tempify/GenerationLockTests.cpp new file mode 100644 index 0000000..feccf6d --- /dev/null +++ b/tests/tempify/GenerationLockTests.cpp @@ -0,0 +1,61 @@ +#include "TempifyTestSupport.h" +#include "TestHarness.h" +#include "tempify/build/GenerationLock.h" + +#include + +namespace { + +using tempify::test_support::write_text_file; + +} // namespace + +TEST_CASE(GenerationLock_content_fingerprint_is_stable_for_same_input) { + REQUIRE_EQ(tempify::content_fingerprint_hex("hello"), tempify::content_fingerprint_hex("hello")); + REQUIRE(tempify::content_fingerprint_hex("hello") != tempify::content_fingerprint_hex("hello\n")); +} + +TEST_CASE(GenerationLock_missing_file_returns_nullopt) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-generation-lock-missing.json"; + std::filesystem::remove(path); + REQUIRE(!tempify::load_generation_lock(path).has_value()); +} + +TEST_CASE(GenerationLock_loads_managed_files_and_template_metadata) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-generation-lock-valid.json"; + write_text_file(path, + "{\n" + " \"tempify_version\": \"0.1.0\",\n" + " \"build_root\": \"/tmp/out\",\n" + " \"generated_at\": \"2026-01-01T00:00:00Z\",\n" + " \"existing_path_behavior\": \"error\",\n" + " \"hook_acceptance\": \"yes\",\n" + " \"hooks_disabled\": false,\n" + " \"managed_files\": [\n" + " \"README.md\"\n" + " ],\n" + " \"managed_file_hashes\": {\n" + " \"README.md\": \"abc123\"\n" + " },\n" + " \"values\": {\n" + " \"project_name\": \"Demo\"\n" + " },\n" + " \"template\": {\n" + " \"id\": \"basic_cpp\",\n" + " \"name\": \"Basic C++\",\n" + " \"version\": \"1.0.0\",\n" + " \"root\": \"/tmp/templates/basic_cpp\"\n" + " }\n" + "}\n"); + + const std::optional record = tempify::load_generation_lock(path); + REQUIRE(record.has_value()); + REQUIRE_EQ(record->template_info.id, std::string("basic_cpp")); + REQUIRE_EQ(record->template_info.version, std::string("1.0.0")); + REQUIRE_EQ(record->build_root, std::string("/tmp/out")); + REQUIRE(record->managed_files.contains("README.md")); + REQUIRE_EQ(record->managed_file_hashes.at("README.md"), std::string("abc123")); + REQUIRE_EQ(record->values.at("project_name"), std::string("Demo")); +} diff --git a/tests/tempify/PrebyteCommandRunnerTests.cpp b/tests/tempify/PrebyteCommandRunnerTests.cpp new file mode 100644 index 0000000..1d571f0 --- /dev/null +++ b/tests/tempify/PrebyteCommandRunnerTests.cpp @@ -0,0 +1,7 @@ +#include "TestHarness.h" +#include "tempify/prebyte/PrebyteCommandRunner.h" + +TEST_CASE(PrebyteCommandRunner_runs_embedded_prebyte_version_command) { + tempify::PrebyteCommandRunner runner; + REQUIRE_EQ(runner.run({"--version"}), 0); +} diff --git a/tests/tempify/ReapplySerializationTests.cpp b/tests/tempify/ReapplySerializationTests.cpp new file mode 100644 index 0000000..c559046 --- /dev/null +++ b/tests/tempify/ReapplySerializationTests.cpp @@ -0,0 +1,85 @@ +#include "TestHarness.h" +#include "tempify/build/BuildDiffReport.h" +#include "tempify/build/ReapplySerialization.h" + +#include + +namespace { + +tempify::BuildDiffReport make_conflict_report() { + tempify::BuildDiffReport report; + report.build_root = std::filesystem::path("/tmp/reapply-target"); + report.template_id = "basic_cpp"; + report.template_version = "1.0.0"; + report.origin.detected = true; + report.origin.matches_requested_template = true; + report.origin.matches_requested_version = true; + report.origin.lockfile_path = report.build_root / ".tempify-lock.json"; + report.origin.template_id = "basic_cpp"; + report.origin.template_version = "1.0.0"; + report.update.kind = tempify::BuildUpdateKind::SameTemplate; + report.update.from_version = "1.0.0"; + report.update.to_version = "1.0.0"; + report.update_policy.action = tempify::BuildUpdatePolicyAction::Allow; + report.reapply.status = tempify::BuildReapplyStatus::Conflict; + report.reapply.conflict_count = 1; + report.entries.push_back({ + .relative_path = "README.md", + .status = tempify::BuildDiffStatus::Change, + .reason = tempify::BuildDiffReason::LocalEdit, + .reapply_action = tempify::BuildReapplyAction::Conflict, + }); + return report; +} + +tempify::BuildDiffReport make_success_report() { + tempify::BuildDiffReport report; + report.build_root = std::filesystem::path("/tmp/reapply-target"); + report.template_id = "basic_cpp"; + report.template_version = "1.0.0"; + report.origin.detected = true; + report.origin.matches_requested_template = true; + report.origin.template_id = "basic_cpp"; + report.origin.template_version = "1.0.0"; + report.update.kind = tempify::BuildUpdateKind::SameTemplate; + report.update.from_version = "1.0.0"; + report.update.to_version = "1.0.0"; + report.update_policy.action = tempify::BuildUpdatePolicyAction::Allow; + report.reapply.status = tempify::BuildReapplyStatus::Ready; + report.reapply.delete_count = 1; + report.entries.push_back({ + .relative_path = "old-managed.txt", + .status = tempify::BuildDiffStatus::Delete, + .reason = tempify::BuildDiffReason::TemplateUpdate, + .reapply_action = tempify::BuildReapplyAction::Delete, + }); + return report; +} + +} // namespace + +TEST_CASE(ReapplySerialization_formats_successful_reapply_text_and_json) { + const tempify::BuildDiffReport report = make_success_report(); + + const std::string text = tempify::format_reapply_result_text("basic_cpp", report.build_root, report); + REQUIRE(text.find("Reapplied basic_cpp") != std::string::npos); + REQUIRE(text.find("Deleted: 1") != std::string::npos); + + const std::string json = tempify::format_reapply_result_json(report); + REQUIRE(json.find("\"status\": \"ok\"") != std::string::npos); + REQUIRE(json.find("\"delete\": 1") != std::string::npos); + REQUIRE(json.find("\"old-managed.txt\"") != std::string::npos); +} + +TEST_CASE(ReapplySerialization_builds_blocked_error_for_conflicts) { + const tempify::BuildDiffReport report = make_conflict_report(); + const tempify::ReapplyBlockedError error = tempify::build_reapply_blocked_error(report); + REQUIRE(std::string(error.what()).find("Reapply blocked") != std::string::npos); + REQUIRE(std::string(error.what()).find("README.md") != std::string::npos); + REQUIRE_EQ(error.conflict_paths().size(), static_cast(1)); + REQUIRE_EQ(error.conflict_paths().front(), std::string("README.md")); + + const std::string json = tempify::format_reapply_blocked_error_json(error); + REQUIRE(json.find("\"code\": \"REAPPLY_BLOCKED\"") != std::string::npos); + REQUIRE(json.find("\"README.md\"") != std::string::npos); +} diff --git a/tests/tempify/ShellCompletionTests.cpp b/tests/tempify/ShellCompletionTests.cpp new file mode 100644 index 0000000..3d0d3d2 --- /dev/null +++ b/tests/tempify/ShellCompletionTests.cpp @@ -0,0 +1,75 @@ +#include "TestHarness.h" +#include "tempify/cli/ShellCompletion.h" +#include "tempify/support/Errors.h" + +#include +#include + +namespace { + +void require_contains_all(const std::string &script, const std::vector &needles) { + for (const std::string &needle : needles) { + REQUIRE(script.find(needle) != std::string::npos); + } +} + +const std::vector &top_level_commands() { + static const std::vector commands = { + "list", "info", "doctor", "completion", "validate", "inspect", + "lint", "test", "refresh", "reapply", "process", + }; + return commands; +} + +const std::vector &render_flags() { + static const std::vector flags = { + "--set", "--answers", "--non-interactive", "--strict", "--hook-timeout-ms", "--dry-run", "--plan-json", + "--diff", "--reapply", "--report", "--json", "--questions", "--accept-hooks", "--no-hooks", + }; + return flags; +} + +const std::vector &reapply_flags() { + static const std::vector flags = { + "--set", "--answers", "--report", "--json", "--hook-timeout-ms", + }; + return flags; +} + +} // namespace + +TEST_CASE(ShellCompletion_bash_script_lists_commands_render_and_reapply_flags) { + const std::string script = tempify::render_shell_completion("bash"); + require_contains_all(script, top_level_commands()); + require_contains_all(script, render_flags()); + require_contains_all(script, reapply_flags()); + REQUIRE(script.find("complete -F _tempify tempify") != std::string::npos); + REQUIRE(script.find("tempify list 2>/dev/null | cut -f1") != std::string::npos); + REQUIRE(script.find("bash zsh fish") != std::string::npos); +} + +TEST_CASE(ShellCompletion_zsh_script_lists_commands_and_template_lookup) { + const std::string script = tempify::render_shell_completion("zsh"); + require_contains_all(script, top_level_commands()); + REQUIRE(script.find("#compdef tempify") != std::string::npos); + REQUIRE(script.find("compdef _tempify tempify") != std::string::npos); + REQUIRE(script.find("tempify list 2>/dev/null | cut -f1") != std::string::npos); + REQUIRE(script.find("shells=(bash zsh fish)") != std::string::npos); + REQUIRE(script.find("--questions") != std::string::npos); + REQUIRE(script.find("--reapply") != std::string::npos); +} + +TEST_CASE(ShellCompletion_fish_script_lists_commands_and_render_flags) { + const std::string script = tempify::render_shell_completion("fish"); + require_contains_all(script, top_level_commands()); + REQUIRE(script.find("complete -c tempify -f") != std::string::npos); + REQUIRE(script.find("__fish_use_subcommand") != std::string::npos); + REQUIRE(script.find("__fish_seen_subcommand_from reapply") != std::string::npos); + REQUIRE(script.find("dry-run") != std::string::npos); + REQUIRE(script.find("plan-json") != std::string::npos); + REQUIRE(script.find("tempify list 2>/dev/null | cut -f1") != std::string::npos); +} + +TEST_CASE(ShellCompletion_rejects_unknown_shell) { + REQUIRE_THROWS_AS(tempify::render_shell_completion("pwsh"), tempify::TempifyError); +} diff --git a/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp b/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp index e4df087..3e343a4 100644 --- a/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp +++ b/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp @@ -8,6 +8,7 @@ namespace { using tempify::test_support::create_basic_template_at; using tempify::test_support::create_required_only_template; using tempify::test_support::create_sensitive_template; +using tempify::test_support::create_slow_hook_template; using tempify::test_support::json_escaped_path; using tempify::test_support::link_test_templates_into_workspace; using tempify::test_support::read_text_file; @@ -370,3 +371,104 @@ TEST_CASE(TempifyApp_doctor_json_outputs_machine_readable_summary) { REQUIRE(output.find("\"shared_index_status\": \"ok\"") != std::string::npos); REQUIRE(output.find("\"catalog_status\": ") != std::string::npos); } + +TEST_CASE(TempifyApp_config_hook_timeout_ms_applies_during_render_without_cli_flag) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-data-home"); + ScopedTempifyConfigHome config_home(std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-config-home"); + const std::filesystem::path template_root = create_slow_hook_template(workspace.path()); + + write_text_file(config_home.path() / "tempify" / "config.json", "{\n" + " \"render\": {\n" + " \"accept_hooks\": \"yes\",\n" + " \"hook_timeout_ms\": 25\n" + " }\n" + "}\n"); + + ScopedCurrentPath cwd(workspace.path()); + tempify::TempifyApp app; + + try { + static_cast(app.run({ + template_root.string(), + target.path().string(), + "--set", + "project_name=Config Timeout App", + "--non-interactive", + "--strict", + })); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + const std::string message = error.what(); + REQUIRE(message.find("Hook phase 'post' failed") != std::string::npos); + REQUIRE(message.find("post.lua") != std::string::npos); + REQUIRE(message.find("timed out after 25 ms") != std::string::npos); + } +} + +TEST_CASE(TempifyApp_config_hook_timeout_ms_cli_flag_overrides_config_value) { + ScopedDirectoryCleanup workspace( + std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-override-workspace"); + ScopedDirectoryCleanup target( + std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-override-target"); + ScopedTempifyDataHome data_home( + std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-override-data-home"); + ScopedTempifyConfigHome config_home( + std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-override-config-home"); + const std::filesystem::path template_root = create_slow_hook_template(workspace.path()); + + write_text_file(config_home.path() / "tempify" / "config.json", "{\n" + " \"render\": {\n" + " \"accept_hooks\": \"yes\",\n" + " \"hook_timeout_ms\": 5000\n" + " }\n" + "}\n"); + + ScopedCurrentPath cwd(workspace.path()); + tempify::TempifyApp app; + + try { + static_cast(app.run({ + template_root.string(), + target.path().string(), + "--hook-timeout-ms", + "25", + "--set", + "project_name=Config Timeout Override App", + "--non-interactive", + "--strict", + })); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + const std::string message = error.what(); + REQUIRE(message.find("timed out after 25 ms") != std::string::npos); + REQUIRE(message.find("timed out after 5000 ms") == std::string::npos); + } +} + +TEST_CASE(TempifyApp_template_env_file_supplies_defaults_during_render) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-app-template-env-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-template-env-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-template-env-data-home"); + const std::filesystem::path template_root = + create_basic_template_at(workspace.path() / "templates" / "env_defaults_demo", "env_defaults_demo", + "Env Defaults Demo", "1.0.0", "Template with .env defaults", "FROM ENV DEFAULTS\n"); + write_text_file(template_root / ".env", + "project_name=From Env File\n" + "project_slug=from-env-file\n"); + + ScopedCurrentPath cwd(workspace.path()); + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + "env_defaults_demo", + target.path().string(), + "--non-interactive", + "--strict", + }), + 0); + + const std::string readme = read_text_file(target.path() / "README.md"); + REQUIRE(readme.find("# From Env File") != std::string::npos); + REQUIRE(readme.find("FROM ENV DEFAULTS") != std::string::npos); +} diff --git a/tests/tempify/TemplateInspectorTests.cpp b/tests/tempify/TemplateInspectorTests.cpp new file mode 100644 index 0000000..623cfba --- /dev/null +++ b/tests/tempify/TemplateInspectorTests.cpp @@ -0,0 +1,24 @@ +#include "TempifyTestSupport.h" +#include "TestHarness.h" +#include "tempify/lua/LuaEngine.h" +#include "tempify/template/TemplateInspector.h" +#include "tempify/template/TemplateLoader.h" + +#include + +TEST_CASE(TemplateInspector_describes_loaded_template_structure) { + const tempify::LuaEngine lua_engine; + const tempify::TemplateLoader loader(lua_engine); + const std::filesystem::path template_root = + tempify::test_support::test_template_path("advanced_hooks_layout"); + const tempify::TemplateManifest manifest = + loader.load(template_root, {{"advanced_hooks_layout", template_root}}); + + const std::string text = tempify::inspect_template_text(manifest); + REQUIRE(text.find("advanced_hooks_layout") != std::string::npos); + REQUIRE(text.find("Source Roots:") != std::string::npos); + REQUIRE(text.find("Files:") != std::string::npos); + REQUIRE(text.find("Questions:") != std::string::npos); + REQUIRE(text.find("use_notes") != std::string::npos); + REQUIRE(text.find("Hooks:") != std::string::npos); +} diff --git a/tests/tempify/TemplateLinterTests.cpp b/tests/tempify/TemplateLinterTests.cpp new file mode 100644 index 0000000..99f17b8 --- /dev/null +++ b/tests/tempify/TemplateLinterTests.cpp @@ -0,0 +1,92 @@ +#include "TestHarness.h" +#include "tempify/template/TemplateLinter.h" + +#include + +namespace { + +tempify::TemplateManifest make_manifest() { + tempify::TemplateManifest manifest; + manifest.info.id = "lint_sample"; + manifest.info.name = "Lint Sample"; + manifest.root = std::filesystem::path("/tmp/lint-sample"); + manifest.source_roots.push_back({ + .path = manifest.root / "files", + .template_id = "lint_sample", + }); + return manifest; +} + +} // namespace + +TEST_CASE(TemplateLinter_warns_about_missing_metadata_and_question_details) { + tempify::TemplateManifest manifest = make_manifest(); + manifest.info.description.clear(); + manifest.info.version.clear(); + manifest.questions.push_back({ + .key = "project_name", + .type = "string", + .prompt = "", + }); + manifest.questions.push_back({ + .key = "mode", + .type = "choice", + .prompt = "Mode", + .choices = {}, + }); + manifest.questions.push_back({ + .key = "notes", + .type = "string", + .prompt = "Notes", + .optional = true, + .help = "", + }); + + const tempify::TemplateLinter linter; + const std::vector warnings = linter.lint(manifest); + REQUIRE(warnings.size() >= 4U); + REQUIRE(warnings[0].find("description is empty") != std::string::npos); + REQUIRE(warnings[1].find("version is empty") != std::string::npos); + + bool found_prompt_warning = false; + bool found_choice_warning = false; + bool found_optional_help_warning = false; + for (const std::string &warning : warnings) { + if (warning.find("missing prompt") != std::string::npos) { + found_prompt_warning = true; + } + if (warning.find("choice question has no choices") != std::string::npos) { + found_choice_warning = true; + } + if (warning.find("optional question missing help text") != std::string::npos) { + found_optional_help_warning = true; + } + } + REQUIRE(found_prompt_warning); + REQUIRE(found_choice_warning); + REQUIRE(found_optional_help_warning); +} + +TEST_CASE(TemplateLinter_warns_about_rendered_file_without_pbt_extension) { + tempify::TemplateManifest manifest = make_manifest(); + manifest.info.description = "desc"; + manifest.info.version = "1.0.0"; + manifest.files.push_back({ + .relative_path = "README.md", + .source_path = manifest.root / "files" / "README.md", + .render_with_prebyte = true, + .source_template_id = "lint_sample", + }); + + const tempify::TemplateLinter linter; + const std::vector warnings = linter.lint(manifest); + REQUIRE(!warnings.empty()); + REQUIRE(warnings.front().find("does not use .pbt extension") != std::string::npos); +} + +TEST_CASE(format_template_lint_text_includes_template_id_and_warnings) { + const std::vector warnings = {"template description is empty"}; + const std::string text = tempify::format_template_lint_text("lint_sample", warnings); + REQUIRE(text.find("lint_sample") != std::string::npos); + REQUIRE(text.find("template description is empty") != std::string::npos); +} diff --git a/tests/tempify/TemplateValidatorTests.cpp b/tests/tempify/TemplateValidatorTests.cpp new file mode 100644 index 0000000..ff6a39b --- /dev/null +++ b/tests/tempify/TemplateValidatorTests.cpp @@ -0,0 +1,70 @@ +#include "TempifyTestSupport.h" +#include "TestHarness.h" +#include "tempify/lua/LuaEngine.h" +#include "tempify/support/Errors.h" +#include "tempify/template/TemplateLoader.h" +#include "tempify/template/TemplateValidator.h" + +#include +#include + +namespace { + +tempify::TemplateManifest load_manifest(const std::filesystem::path &template_root, const std::string &template_id) { + const tempify::LuaEngine lua_engine; + const tempify::TemplateLoader loader(lua_engine); + return loader.load(template_root, {{template_id, template_root}}); +} + +} // namespace + +TEST_CASE(TemplateValidator_accepts_known_good_template) { + const tempify::TemplateValidator validator; + const tempify::TemplateManifest manifest = + load_manifest(tempify::test_support::test_template_path("advanced_hooks_layout"), "advanced_hooks_layout"); + validator.validate(manifest); +} + +TEST_CASE(TemplateValidator_rejects_duplicate_question_keys) { + tempify::TemplateManifest manifest; + manifest.questions.push_back({ + .key = "project_name", + .type = "string", + .prompt = "Name", + }); + manifest.questions.push_back({ + .key = "project_name", + .type = "string", + .prompt = "Duplicate", + .source_path = std::filesystem::path("questions.lua"), + .source_index = 2, + }); + + const tempify::TemplateValidator validator; + REQUIRE_THROWS_AS(validator.validate(manifest), tempify::TempifyError); +} + +TEST_CASE(TemplateValidator_rejects_layout_rule_for_missing_source) { + tempify::TemplateManifest manifest; + manifest.files.push_back({ + .relative_path = "README.md", + .source_path = std::filesystem::path("/tmp/README.md"), + .render_with_prebyte = false, + .source_template_id = "sample", + }); + manifest.layout_rules.push_back({ + .source = "missing.txt", + .source_template_id = "sample", + }); + + const tempify::TemplateValidator validator; + REQUIRE_THROWS_AS(validator.validate(manifest), tempify::TempifyError); +} + +TEST_CASE(TemplateValidator_rejects_missing_hook_file) { + tempify::TemplateManifest manifest; + manifest.pre_hook_path = std::filesystem::path("/tmp/tempify-missing-pre-hook.lua"); + + const tempify::TemplateValidator validator; + REQUIRE_THROWS_AS(validator.validate(manifest), tempify::TempifyError); +} From dcd9d2294d736c85f5a7346bffb0757b23e45329 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 21:42:54 +0200 Subject: [PATCH 10/29] Adding Negative Tests --- .../e2e/TempifyParallelCliE2ETests.cpp | 36 ++++++++ .../e2e/TempifyWorkflowE2ETests.cpp | 41 +++++++++ .../cli/TempifyCliBinaryE2ETests.cpp | 41 +++++++++ .../cli/TempifyCliCommandsE2ETests.cpp | 90 +++++++++++++++++++ .../cli/TempifyPrebytePassthroughE2ETests.cpp | 21 +++++ tests/tempify/PrebyteCommandRunnerTests.cpp | 11 +++ .../SharedTemplateStoreIntegrationTests.cpp | 12 +++ .../TempifyAppCliCatalogIntegrationTests.cpp | 6 ++ .../TempifyAppConfigPlanIntegrationTests.cpp | 77 ++++++++++++++++ .../TempifyAppHooksIntegrationTests.cpp | 27 ++++++ tests/tempify/TemplateInspectorTests.cpp | 14 +++ 11 files changed, 376 insertions(+) diff --git a/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp b/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp index 0776fc4..f6fc8e8 100644 --- a/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp +++ b/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp @@ -100,3 +100,39 @@ TEST_CASE(TempifyConcurrencyE2E_parallel_list_and_inspect_do_not_interfere) { REQUIRE(inspect.stdout_text.find("\"template_id\": \"basic_cpp\"") != std::string::npos); REQUIRE_EQ(doctor.exit_code, 0); } + +TEST_CASE(TempifyConcurrencyE2E_parallel_renders_to_same_target_without_overwrite_report_failures) { + ScopedDirectoryCleanup workspace( + std::filesystem::temp_directory_path() / "tempify-concurrency-same-target-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-concurrency-same-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-concurrency-same-target-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path target_path = target.path(); + + constexpr int parallel_count = 4; + std::vector> futures; + futures.reserve(parallel_count); + for (int index = 0; index < parallel_count; ++index) { + const std::string slug = "same-target-" + std::to_string(index); + futures.push_back(std::async(std::launch::async, [workspace_path, env, target_path, slug]() { + return run_cli(parallel_render_args(target_path, slug), workspace_path, env); + })); + } + + std::size_t success_count = 0; + std::size_t failure_count = 0; + for (int index = 0; index < parallel_count; ++index) { + const ProcessResult result = futures[static_cast(index)].get(); + if (result.exit_code == 0) { + ++success_count; + } else { + ++failure_count; + } + } + + REQUIRE_EQ(success_count, static_cast(1)); + REQUIRE_EQ(failure_count, static_cast(3)); + REQUIRE(std::filesystem::is_regular_file(target_path / "README.md")); +} diff --git a/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp b/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp index 6a67a8b..15d86a1 100644 --- a/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp +++ b/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp @@ -123,3 +123,44 @@ TEST_CASE(TempifyWorkflowE2E_diff_reports_changes_after_manual_edit) { REQUIRE_EQ(diff.exit_code, 0); REQUIRE(diff.stdout_text.find("README.md") != std::string::npos || diff.stdout_text.find("Diff ") != std::string::npos); } + +TEST_CASE(TempifyWorkflowE2E_reapply_without_lock_fails_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-workflow-reapply-fail-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-workflow-reapply-fail-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-workflow-reapply-fail-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + std::filesystem::create_directories(target.path()); + + std::vector args = basic_cpp_render_args(target.path(), "reapply-fail"); + args.push_back("--reapply"); + const ProcessResult result = run_cli(args, workspace.path(), env); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find(".tempify-lock.json") != std::string::npos); +} + +TEST_CASE(TempifyWorkflowE2E_strict_rejects_unknown_answers_keys_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-workflow-strict-fail-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-workflow-strict-fail-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-workflow-strict-fail-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path answers_file = workspace.path() / "bad-answers.json"; + write_text_file(answers_file, "{\n \"project_name\": \"Strict Fail\",\n \"mystery\": \"value\"\n}\n"); + + const ProcessResult result = run_cli({ + "basic_cpp", + target.path().string(), + "--answers", + answers_file.string(), + "--set", + "author=Workflow Tester", + "--non-interactive", + "--strict", + }, + workspace.path(), env); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("mystery") != std::string::npos || combined.find("Unknown key") != std::string::npos); +} diff --git a/tests/portability/cli/TempifyCliBinaryE2ETests.cpp b/tests/portability/cli/TempifyCliBinaryE2ETests.cpp index f9f7136..e23cfc8 100644 --- a/tests/portability/cli/TempifyCliBinaryE2ETests.cpp +++ b/tests/portability/cli/TempifyCliBinaryE2ETests.cpp @@ -76,3 +76,44 @@ TEST_CASE(TempifyCliBinary_doctor_runs_in_subprocess) { REQUIRE_EQ(result.exit_code, 0); REQUIRE(result.stdout_text.find("Doctor") != std::string::npos || result.stdout_text.find("doctor") != std::string::npos); } + +TEST_CASE(TempifyCliBinary_render_unknown_template_fails_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-cli-binary-missing-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-cli-binary-missing-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-cli-binary-missing-data-home"); + prepare_template_workspace(workspace.path()); + + const ProcessResult result = run_cli({"definitely_missing_template_12345", target.path().string()}, workspace.path(), + isolated_cli_env(data_home.path())); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("Template not found") != std::string::npos + || combined.find("not found") != std::string::npos); +} + +TEST_CASE(TempifyCliBinary_render_existing_target_without_overwrite_fails_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-cli-binary-conflict-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-cli-binary-conflict-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-cli-binary-conflict-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + const std::vector render_args = { + "basic_cpp", + target.path().string(), + "--set", + "project_name=Binary Conflict", + "--set", + "name_slug=binary-conflict", + "--set", + "namespace=binary_conflict_ns", + "--set", + "include_ci=false", + "--set", + "author=Binary Tester", + }; + REQUIRE_EQ(run_cli(render_args, workspace.path(), env).exit_code, 0); + + const ProcessResult second_render = run_cli(render_args, workspace.path(), env); + REQUIRE(second_render.exit_code != 0); +} diff --git a/tests/portability/cli/TempifyCliCommandsE2ETests.cpp b/tests/portability/cli/TempifyCliCommandsE2ETests.cpp index 86c4c14..fcacee8 100644 --- a/tests/portability/cli/TempifyCliCommandsE2ETests.cpp +++ b/tests/portability/cli/TempifyCliCommandsE2ETests.cpp @@ -262,3 +262,93 @@ TEST_CASE(TempifyCliCommandsE2E_validate_missing_template_fails_in_subprocess) { REQUIRE(combined.find("Template not found") != std::string::npos || combined.find("not found") != std::string::npos); } + +TEST_CASE(TempifyCliCommandsE2E_render_existing_target_without_overwrite_fails_in_subprocess) { + CommandWorkspace fixture("render-conflict"); + ScopedDirectoryCleanup target(fixture.cwd().parent_path() / "tempify-cli-commands-render-conflict-target"); + + REQUIRE_EQ(run_cli(basic_cpp_render_args(target.path(), "render-conflict"), fixture.cwd(), fixture.env).exit_code, + 0); + const ProcessResult second_render = run_cli(basic_cpp_render_args(target.path(), "render-conflict-2"), + fixture.cwd(), fixture.env); + REQUIRE(second_render.exit_code != 0); + const std::string combined = second_render.stdout_text + second_render.stderr_text; + REQUIRE(combined.find("exists") != std::string::npos || combined.find("conflict") != std::string::npos + || combined.find("already") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_unknown_template_catalog_commands_fail_in_subprocess) { + CommandWorkspace fixture("catalog-missing"); + const char *missing = "definitely_missing_template_12345"; + + const ProcessResult info = run_cli({"info", missing}, fixture.cwd(), fixture.env); + REQUIRE(info.exit_code != 0); + + const ProcessResult inspect = run_cli({"inspect", missing}, fixture.cwd(), fixture.env); + REQUIRE(inspect.exit_code != 0); + + const ProcessResult lint = run_cli({"lint", missing}, fixture.cwd(), fixture.env); + REQUIRE(lint.exit_code != 0); + + const ProcessResult test_run = run_cli({"test", missing}, fixture.cwd(), fixture.env); + REQUIRE(test_run.exit_code != 0); + + const std::string combined = info.stdout_text + info.stderr_text + inspect.stdout_text + inspect.stderr_text; + REQUIRE(combined.find("Template not found") != std::string::npos + || combined.find("not found") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_reapply_without_lock_fails_in_subprocess) { + CommandWorkspace fixture("reapply-no-lock"); + ScopedDirectoryCleanup target(fixture.cwd().parent_path() / "tempify-cli-commands-reapply-no-lock-target"); + std::filesystem::create_directories(target.path()); + + std::vector args = basic_cpp_render_args(target.path(), "reapply-no-lock"); + args.push_back("--reapply"); + const ProcessResult result = run_cli(args, fixture.cwd(), fixture.env); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find(".tempify-lock.json") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_strict_answers_file_with_unknown_key_fails_in_subprocess) { + CommandWorkspace fixture("strict-answers"); + ScopedDirectoryCleanup target(fixture.cwd().parent_path() / "tempify-cli-commands-strict-answers-target"); + const std::filesystem::path answers_file = fixture.cwd() / "bad-answers.json"; + write_text_file(answers_file, + "{\n \"project_name\": \"Strict Fail App\",\n \"mystery_key\": \"nope\"\n}\n"); + + const ProcessResult result = run_cli({ + "basic_cpp", + target.path().string(), + "--answers", + answers_file.string(), + "--set", + "author=CLI Commands Tester", + "--non-interactive", + "--strict", + }, + fixture.cwd(), fixture.env); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("mystery_key") != std::string::npos || combined.find("Unknown key") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_test_unknown_fixture_fails_in_subprocess) { + CommandWorkspace fixture("test-unknown-fixture"); + const ProcessResult result = + run_cli({"test", "basic_cpp", "--fixture", "definitely_missing_fixture"}, fixture.cwd(), fixture.env); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("fixture") != std::string::npos || combined.find("not found") != std::string::npos); +} + +TEST_CASE(TempifyCliCommandsE2E_prebyte_invalid_args_fail_in_subprocess) { + CommandWorkspace fixture("prebyte-invalid"); + const ProcessResult result = + run_cli({"-p", "definitely-not-a-prebyte-input-file"}, fixture.cwd(), fixture.env); + REQUIRE(result.exit_code != 0); + const std::string combined = result.stdout_text + result.stderr_text; + REQUIRE(combined.find("Cannot read input file") != std::string::npos + || combined.find("error") != std::string::npos); +} diff --git a/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp b/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp index 8e2eba7..cff3846 100644 --- a/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp +++ b/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp @@ -50,3 +50,24 @@ TEST_CASE(TempifyPrebytePassthroughE2E_process_help_matches_tempify_wrapper) { REQUIRE_EQ(process_help.exit_code, 0); REQUIRE(process_help.stdout_text.find("Embedded Prebyte Passthrough") != std::string::npos); } + +TEST_CASE(TempifyPrebytePassthroughE2E_invalid_prebyte_args_fail_in_subprocess) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-prebyte-invalid-workspace"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-prebyte-invalid-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + const std::filesystem::path workspace_path = workspace.path(); + + const ProcessResult short_form = + run_cli({"-p", "definitely-not-a-prebyte-input-file"}, workspace_path, env); + REQUIRE(short_form.exit_code != 0); + + const ProcessResult process_form = + run_cli({"process", "definitely-not-a-prebyte-input-file"}, workspace_path, env); + REQUIRE(process_form.exit_code != 0); + + const std::string combined = short_form.stdout_text + short_form.stderr_text + process_form.stdout_text + + process_form.stderr_text; + REQUIRE(combined.find("Cannot read input file") != std::string::npos + || combined.find("error") != std::string::npos); +} diff --git a/tests/tempify/PrebyteCommandRunnerTests.cpp b/tests/tempify/PrebyteCommandRunnerTests.cpp index 1d571f0..455e0c6 100644 --- a/tests/tempify/PrebyteCommandRunnerTests.cpp +++ b/tests/tempify/PrebyteCommandRunnerTests.cpp @@ -1,7 +1,18 @@ #include "TestHarness.h" #include "tempify/prebyte/PrebyteCommandRunner.h" +#include "tempify/support/Errors.h" TEST_CASE(PrebyteCommandRunner_runs_embedded_prebyte_version_command) { tempify::PrebyteCommandRunner runner; REQUIRE_EQ(runner.run({"--version"}), 0); } + +TEST_CASE(PrebyteCommandRunner_invalid_command_throws) { + tempify::PrebyteCommandRunner runner; + try { + static_cast(runner.run({"definitely-not-a-prebyte-input-file"})); + REQUIRE(false); + } catch (const std::exception &) { + REQUIRE(true); + } +} diff --git a/tests/tempify/SharedTemplateStoreIntegrationTests.cpp b/tests/tempify/SharedTemplateStoreIntegrationTests.cpp index 54330d8..5f7a3c0 100644 --- a/tests/tempify/SharedTemplateStoreIntegrationTests.cpp +++ b/tests/tempify/SharedTemplateStoreIntegrationTests.cpp @@ -4,6 +4,7 @@ #include namespace { +using tempify::test_support::create_basic_template_at; using tempify::test_support::create_shared_template; using tempify::test_support::read_text_file; using tempify::test_support::ScopedDirectoryCleanup; @@ -136,3 +137,14 @@ TEST_CASE(TempifyApp_stale_shared_index_entry_missing_on_disk_is_not_listed_and_ REQUIRE_THROWS_AS(app.run({"info", "ghost_tpl"}), tempify::TempifyError); } + +TEST_CASE(TempifyApp_refresh_rejects_duplicate_shared_template_ids) { + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-store-duplicate-data-home"); + static_cast(create_basic_template_at(data_home.shared_root() / "templates" / "dup_tpl_alpha", "dup_tpl", + "Duplicate Alpha", "1.0.0", "Alpha copy")); + static_cast(create_basic_template_at(data_home.shared_root() / "templates" / "dup_tpl_beta", "dup_tpl", + "Duplicate Beta", "1.0.0", "Beta copy")); + + tempify::TempifyApp app; + REQUIRE_THROWS_AS(app.run({"refresh"}), tempify::TempifyError); +} diff --git a/tests/tempify/TempifyAppCliCatalogIntegrationTests.cpp b/tests/tempify/TempifyAppCliCatalogIntegrationTests.cpp index 9179a86..b279fe9 100644 --- a/tests/tempify/TempifyAppCliCatalogIntegrationTests.cpp +++ b/tests/tempify/TempifyAppCliCatalogIntegrationTests.cpp @@ -274,6 +274,12 @@ TEST_CASE(TempifyApp_info_outputs_template_details) { REQUIRE(output.find("Questions: 6") != std::string::npos); } +TEST_CASE(TempifyApp_info_unknown_template_throws) { + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-info-missing-data-home"); + tempify::TempifyApp app; + REQUIRE_THROWS_AS(app.run({"info", "definitely_missing_template_12345"}), tempify::TempifyError); +} + TEST_CASE(TempifyApp_list_json_outputs_machine_readable_catalog) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-list-json-data-home"); tempify::TempifyApp app; diff --git a/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp b/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp index 3e343a4..1a864bb 100644 --- a/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp +++ b/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp @@ -472,3 +472,80 @@ TEST_CASE(TempifyApp_template_env_file_supplies_defaults_during_render) { REQUIRE(readme.find("# From Env File") != std::string::npos); REQUIRE(readme.find("FROM ENV DEFAULTS") != std::string::npos); } + +TEST_CASE(TempifyApp_render_rejects_corrupt_workspace_config_file) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-app-corrupt-config-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-corrupt-config-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-corrupt-config-data-home"); + std::filesystem::create_directories(workspace.path()); + link_test_templates_into_workspace(workspace.path()); + std::filesystem::create_directories(workspace.path() / ".tempify"); + write_text_file(workspace.path() / ".tempify" / "config.json", "{ broken config\n"); + + ScopedCurrentPath cwd(workspace.path()); + tempify::TempifyApp app; + + try { + static_cast(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Corrupt Config App", + "--set", + "name_slug=corrupt-config", + "--set", + "namespace=corrupt_config_ns", + "--set", + "include_ci=false", + "--set", + "author=Corrupt Config Tester", + "--non-interactive", + "--strict", + })); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Could not parse config file") != std::string::npos); + } +} + +TEST_CASE(TempifyApp_render_rejects_invalid_accept_hooks_value_in_config) { + ScopedDirectoryCleanup workspace( + std::filesystem::temp_directory_path() / "tempify-app-invalid-hooks-config-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-invalid-hooks-config-target"); + ScopedTempifyDataHome data_home( + std::filesystem::temp_directory_path() / "tempify-app-invalid-hooks-config-data-home"); + ScopedTempifyConfigHome config_home( + std::filesystem::temp_directory_path() / "tempify-app-invalid-hooks-config-config-home"); + std::filesystem::create_directories(workspace.path()); + link_test_templates_into_workspace(workspace.path()); + write_text_file(config_home.path() / "tempify" / "config.json", "{\n" + " \"render\": {\n" + " \"accept_hooks\": \"maybe\"\n" + " }\n" + "}\n"); + + ScopedCurrentPath cwd(workspace.path()); + tempify::TempifyApp app; + + try { + static_cast(app.run({ + "basic_cpp", + target.path().string(), + "--set", + "project_name=Invalid Hooks Config", + "--set", + "name_slug=invalid-hooks-config", + "--set", + "namespace=invalid_hooks_config_ns", + "--set", + "include_ci=false", + "--set", + "author=Invalid Hooks Tester", + "--non-interactive", + "--strict", + })); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("accept_hooks") != std::string::npos); + } +} diff --git a/tests/tempify/TempifyAppHooksIntegrationTests.cpp b/tests/tempify/TempifyAppHooksIntegrationTests.cpp index 93326d1..5cb210c 100644 --- a/tests/tempify/TempifyAppHooksIntegrationTests.cpp +++ b/tests/tempify/TempifyAppHooksIntegrationTests.cpp @@ -155,3 +155,30 @@ TEST_CASE(TempifyApp_render_hook_timeout_flag_aborts_slow_hook_with_phase_diagno REQUIRE(std::string(error.what()).find("timed out after 25 ms") != std::string::npos); } } + +TEST_CASE(TempifyApp_accept_hooks_ask_tty_declining_prompt_skips_hooks) { + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-accept-hooks-no-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-accept-hooks-no-data-home"); + prebyte::test::ScopedEnvironmentVariable force_prompt("TEMPIFY_FORCE_HOOK_PROMPT", "1"); + tempify::TempifyApp app; + + ScopedStdinCapture input("no\n"); + REQUIRE_EQ(app.run({ + "advanced_hooks_layout", + target.path().string(), + "--accept-hooks", + "ask", + "--set", + "project_name=Declined Hooks", + "--set", + "project_slug=declined-hooks", + "--set", + "use_notes=false", + }), + 0); + + REQUIRE(std::filesystem::exists(target.path() / "README.md")); + REQUIRE(!std::filesystem::exists(target.path() / "pre.txt")); + REQUIRE(!std::filesystem::exists(target.path() / "post.txt")); + REQUIRE(!std::filesystem::exists(target.path() / "script-marker.txt")); +} diff --git a/tests/tempify/TemplateInspectorTests.cpp b/tests/tempify/TemplateInspectorTests.cpp index 623cfba..9ebf843 100644 --- a/tests/tempify/TemplateInspectorTests.cpp +++ b/tests/tempify/TemplateInspectorTests.cpp @@ -22,3 +22,17 @@ TEST_CASE(TemplateInspector_describes_loaded_template_structure) { REQUIRE(text.find("use_notes") != std::string::npos); REQUIRE(text.find("Hooks:") != std::string::npos); } + +TEST_CASE(TemplateInspector_describes_empty_sections_for_minimal_manifest) { + tempify::TemplateManifest manifest; + manifest.info.id = "minimal_template"; + manifest.root = std::filesystem::path("/tmp/minimal-template"); + + const std::string text = tempify::inspect_template_text(manifest); + REQUIRE(text.find("minimal_template") != std::string::npos); + REQUIRE(text.find("Description: ") != std::string::npos); + REQUIRE(text.find("Includes:\n- ") != std::string::npos); + REQUIRE(text.find("Files:\n- ") != std::string::npos); + REQUIRE(text.find("Questions:\n- ") != std::string::npos); + REQUIRE(text.find("Hooks:\n- ") != std::string::npos); +} From cd812662795c31a843439cc0a1543ac0290e2344 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 22:00:39 +0200 Subject: [PATCH 11/29] Adding more tests --- .../e2e/TempifyWorkflowE2ETests.cpp | 19 ++ tests/support/TempifyTestSupport.h | 71 ++++ tests/tempify/DirectoryStructureTests.cpp | 321 ++++++++++++++++++ .../TempifyAppHooksIntegrationTests.cpp | 2 + 4 files changed, 413 insertions(+) create mode 100644 tests/tempify/DirectoryStructureTests.cpp diff --git a/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp b/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp index 15d86a1..6f9e7f5 100644 --- a/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp +++ b/tests/correctness/e2e/TempifyWorkflowE2ETests.cpp @@ -164,3 +164,22 @@ TEST_CASE(TempifyWorkflowE2E_strict_rejects_unknown_answers_keys_in_subprocess) const std::string combined = result.stdout_text + result.stderr_text; REQUIRE(combined.find("mystery") != std::string::npos || combined.find("Unknown key") != std::string::npos); } + +TEST_CASE(TempifyWorkflowE2E_render_creates_nested_target_directories) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-workflow-nested-target-workspace"); + ScopedDirectoryCleanup target_root(std::filesystem::temp_directory_path() / "tempify-workflow-nested-target-root"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-workflow-nested-target-data-home"); + prepare_template_workspace(workspace.path()); + const auto env = isolated_cli_env(data_home.path()); + + const std::filesystem::path target = target_root.path() / "deep" / "nested" / "cli-target"; + REQUIRE(!std::filesystem::exists(target.parent_path())); + + const ProcessResult render = run_cli(basic_cpp_render_args(target, "workflow-nested-target"), workspace.path(), env); + REQUIRE_EQ(render.exit_code, 0); + + REQUIRE(std::filesystem::is_directory(target)); + REQUIRE(std::filesystem::is_directory(target / "src")); + REQUIRE(std::filesystem::is_regular_file(target / "src" / "main.cpp")); + REQUIRE(std::filesystem::is_regular_file(target / ".tempify-lock.json")); +} diff --git a/tests/support/TempifyTestSupport.h b/tests/support/TempifyTestSupport.h index 27a563c..d4769eb 100644 --- a/tests/support/TempifyTestSupport.h +++ b/tests/support/TempifyTestSupport.h @@ -5,6 +5,7 @@ #include "tempify/build/GenerationLock.h" #include +#include #include #include #include @@ -15,6 +16,13 @@ #include #include +#if defined(_WIN32) +#include +#else +#include +#include +#endif + namespace tempify::test_support { inline const std::filesystem::path &project_root() { @@ -107,6 +115,69 @@ class ScopedStdinCapture { std::streambuf *previous_ = nullptr; }; +// Redirects the real stdin file descriptor so isatty(STDIN_FILENO) is false. +// ScopedStdinCapture only replaces std::cin and does not affect hook TTY checks. +class ScopedNonTtyStdin { + public: + ScopedNonTtyStdin() { +#if defined(_WIN32) + saved_fd_ = _dup(_fileno(stdin)); + FILE *null_stream = std::fopen("NUL", "r"); + if (null_stream == nullptr || saved_fd_ == -1) { + throw std::runtime_error("failed to prepare non-tty stdin"); + } + const int null_fd = _fileno(null_stream); + if (_dup2(null_fd, _fileno(stdin)) != 0) { + std::fclose(null_stream); + _close(saved_fd_); + saved_fd_ = -1; + throw std::runtime_error("failed to redirect stdin"); + } + std::fclose(null_stream); +#else + saved_fd_ = ::dup(STDIN_FILENO); + const int null_fd = ::open("/dev/null", O_RDONLY); + if (saved_fd_ == -1 || null_fd == -1) { + if (null_fd != -1) { + ::close(null_fd); + } + if (saved_fd_ != -1) { + ::close(saved_fd_); + saved_fd_ = -1; + } + throw std::runtime_error("failed to prepare non-tty stdin"); + } + if (::dup2(null_fd, STDIN_FILENO) != 0) { + ::close(null_fd); + ::close(saved_fd_); + saved_fd_ = -1; + throw std::runtime_error("failed to redirect stdin"); + } + ::close(null_fd); +#endif + } + + ScopedNonTtyStdin(const ScopedNonTtyStdin &) = delete; + ScopedNonTtyStdin &operator=(const ScopedNonTtyStdin &) = delete; + + ~ScopedNonTtyStdin() { + if (saved_fd_ == -1) { + return; + } +#if defined(_WIN32) + _dup2(saved_fd_, _fileno(stdin)); + _close(saved_fd_); +#else + ::dup2(saved_fd_, STDIN_FILENO); + ::close(saved_fd_); +#endif + saved_fd_ = -1; + } + + private: + int saved_fd_ = -1; +}; + class ScopedCurrentPath { public: explicit ScopedCurrentPath(const std::filesystem::path &path) : previous_(std::filesystem::current_path()) { diff --git a/tests/tempify/DirectoryStructureTests.cpp b/tests/tempify/DirectoryStructureTests.cpp new file mode 100644 index 0000000..fa4d023 --- /dev/null +++ b/tests/tempify/DirectoryStructureTests.cpp @@ -0,0 +1,321 @@ +#include "TempifyAppTestSupport.h" +#include "TestHarness.h" +#include "tempify/build/BuildExecutor.h" +#include "tempify/build/BuildPlanner.h" +#include "tempify/lua/LuaEngine.h" +#include "tempify/prebyte/PrebyteRenderer.h" +#include "tempify/template/TemplateLoader.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedTempifyDataHome; +using tempify::test_support::write_text_file; + +class ScopedDirectoryCleanupLocal { + public: + explicit ScopedDirectoryCleanupLocal(std::filesystem::path path) : path_(std::move(path)) { + std::filesystem::remove_all(path_); + } + + ~ScopedDirectoryCleanupLocal() { + std::filesystem::remove_all(path_); + } + + const std::filesystem::path &path() const noexcept { + return path_; + } + + private: + std::filesystem::path path_; +}; + +std::string read_text_file(const std::filesystem::path &path) { + std::ifstream input(path, std::ios::binary); + std::ostringstream stream; + stream << input.rdbuf(); + return stream.str(); +} + +std::filesystem::path create_directory_layout_template(const std::filesystem::path &root) { + const std::filesystem::path template_root = root / "directory_layout"; + std::filesystem::create_directories(template_root / "files" / "empty_keep"); + std::filesystem::create_directories(template_root / "files" / "deep" / "nested"); + write_text_file(template_root / "template.lua", + "return {\n" + " id = \"directory_layout\",\n" + " name = \"Directory Layout\",\n" + " version = \"1.0.0\",\n" + " source_dir = \"files\",\n" + " output = { path = \"{{ project_slug }}\", overwrite = false },\n" + "}\n"); + write_text_file(template_root / "questions.lua", + "return {\n" + " order = { \"General\" },\n" + " groups = {\n" + " General = {\n" + " { key = \"project_name\", type = \"string\", prompt = \"Project name\" },\n" + " { key = \"project_slug\", type = \"string\", prompt = \"Project slug\" },\n" + " },\n" + " },\n" + "}\n"); + write_text_file(template_root / "files" / "README.md.pbt", "# {{ project_name }}\n"); + write_text_file(template_root / "files" / "deep" / "nested" / "leaf.txt.pbt", "leaf={{ project_slug }}\n"); + return template_root; +} + +tempify::TemplateManifest make_flat_manifest(const std::filesystem::path &template_root) { + tempify::TemplateManifest manifest; + manifest.root = template_root; + manifest.info.id = "directory_exec"; + manifest.source_roots.push_back({ + .path = template_root / "files", + .template_id = "directory_exec", + }); + return manifest; +} + +} // namespace + +TEST_CASE(BuildPlanner_collects_parent_and_empty_template_directories_in_plan) { + ScopedDirectoryCleanupLocal workspace(std::filesystem::temp_directory_path() / "tempify-build-planner-directory-plan"); + const std::filesystem::path template_root = create_directory_layout_template(workspace.path()); + + tempify::LuaEngine lua_engine; + tempify::TemplateLoader loader(lua_engine); + const tempify::TemplateManifest manifest = + loader.load(template_root, {{"directory_layout", template_root}}); + + bool has_empty_keep = false; + for (const auto &directory : manifest.directories) { + if (directory.relative_path == "empty_keep") { + has_empty_keep = true; + } + } + REQUIRE(has_empty_keep); + + const tempify::PrebyteRenderer renderer; + const tempify::BuildPlanner planner(renderer); + const std::filesystem::path target = workspace.path() / "planned-out"; + const tempify::BuildPlan plan = + planner.plan(manifest, {{"project_name", "Dir Plan"}, {"project_slug", "dir-plan"}}, target); + + REQUIRE(plan.build_root == target.lexically_normal()); + REQUIRE(std::filesystem::exists(plan.build_root) == false); + + bool has_build_root = false; + bool has_empty_keep_dir = false; + bool has_deep_parent = false; + for (const auto &directory : plan.directories) { + if (directory == plan.build_root) { + has_build_root = true; + } + if (directory.filename() == "empty_keep") { + has_empty_keep_dir = true; + } + if (directory.filename() == "nested" && directory.parent_path().filename() == "deep") { + has_deep_parent = true; + } + } + + REQUIRE(has_build_root); + REQUIRE(has_empty_keep_dir); + REQUIRE(has_deep_parent); + REQUIRE_EQ(plan.files.size(), static_cast(2)); +} + +TEST_CASE(BuildExecutor_creates_build_root_and_nested_output_directories) { + ScopedDirectoryCleanupLocal workspace(std::filesystem::temp_directory_path() / + "tempify-build-executor-directory-create"); + const std::filesystem::path template_root = workspace.path() / "template"; + const std::filesystem::path build_root = workspace.path() / "brand" / "new" / "out"; + const std::filesystem::path source_file = template_root / "files" / "deep" / "nested.txt"; + write_text_file(source_file, "nested content\n"); + + tempify::TemplateManifest manifest = make_flat_manifest(template_root); + + tempify::BuildPlan plan; + plan.build_root = build_root; + plan.existing_path_behavior = tempify::ExistingPathBehavior::Error; + plan.directories = {build_root, build_root / "deep"}; + plan.files = { + tempify::PlannedFile{ + .source_path = source_file, + .output_path = build_root / "deep" / "nested.txt", + .render_with_prebyte = false, + }, + }; + + const tempify::PrebyteRenderer renderer; + const tempify::LuaEngine lua_engine; + const tempify::BuildExecutor executor(renderer, lua_engine); + + REQUIRE(!std::filesystem::exists(build_root.parent_path())); + executor.execute(plan, manifest, {}, true); + + REQUIRE(std::filesystem::is_directory(build_root)); + REQUIRE(std::filesystem::is_directory(build_root / "deep")); + REQUIRE_EQ(read_text_file(build_root / "deep" / "nested.txt"), std::string("nested content\n")); +} + +TEST_CASE(BuildExecutor_rejects_build_root_that_exists_as_file) { + ScopedDirectoryCleanupLocal workspace(std::filesystem::temp_directory_path() / + "tempify-build-executor-build-root-file"); + const std::filesystem::path template_root = workspace.path() / "template"; + const std::filesystem::path build_root = workspace.path() / "out"; + const std::filesystem::path source_file = template_root / "files" / "ok.txt"; + write_text_file(source_file, "ok\n"); + write_text_file(build_root, "target is a file\n"); + + tempify::TemplateManifest manifest = make_flat_manifest(template_root); + + tempify::BuildPlan plan; + plan.build_root = build_root; + plan.existing_path_behavior = tempify::ExistingPathBehavior::Error; + plan.directories = {build_root}; + plan.files = { + tempify::PlannedFile{ + .source_path = source_file, + .output_path = build_root / "ok.txt", + .render_with_prebyte = false, + }, + }; + + const tempify::PrebyteRenderer renderer; + const tempify::LuaEngine lua_engine; + const tempify::BuildExecutor executor(renderer, lua_engine); + + try { + executor.execute(plan, manifest, {}, true); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Target path exists as file and cannot be used as directory") != + std::string::npos); + REQUIRE(std::string(error.what()).find("out") != std::string::npos); + } +} + +TEST_CASE(BuildExecutor_rejects_output_file_path_that_exists_as_directory) { + ScopedDirectoryCleanupLocal workspace(std::filesystem::temp_directory_path() / + "tempify-build-executor-file-directory-conflict"); + const std::filesystem::path template_root = workspace.path() / "template"; + const std::filesystem::path build_root = workspace.path() / "out"; + const std::filesystem::path source_file = template_root / "files" / "README.md"; + write_text_file(source_file, "readme\n"); + std::filesystem::create_directories(build_root); + std::filesystem::create_directories(build_root / "README.md"); + + tempify::TemplateManifest manifest = make_flat_manifest(template_root); + + tempify::BuildPlan plan; + plan.build_root = build_root; + plan.existing_path_behavior = tempify::ExistingPathBehavior::Overwrite; + plan.directories = {build_root}; + plan.files = { + tempify::PlannedFile{ + .source_path = source_file, + .output_path = build_root / "README.md", + .render_with_prebyte = false, + }, + }; + + const tempify::PrebyteRenderer renderer; + const tempify::LuaEngine lua_engine; + const tempify::BuildExecutor executor(renderer, lua_engine); + + try { + executor.execute(plan, manifest, {}, true); + REQUIRE(false); + } catch (const tempify::TempifyError &error) { + REQUIRE(std::string(error.what()).find("Output file path already exists as directory") != std::string::npos); + REQUIRE(std::string(error.what()).find("README.md") != std::string::npos); + } +} + +TEST_CASE(LuaEngine_run_hook_mkdir_creates_nested_directories) { + ScopedDirectoryCleanupLocal build_root(std::filesystem::temp_directory_path() / + "tempify-lua-hook-nested-mkdir-test"); + std::filesystem::create_directories(build_root.path()); + + tempify::LuaEngine lua_engine; + const tempify::TemplateManifest manifest = + lua_engine.load_partial_manifest(tempify::test_support::test_template_path("advanced_hooks_layout")); + const tempify::PrebyteRenderer renderer; + const tempify::BuildContext context{ + .template_root = manifest.root, + .build_root = build_root.path(), + .values = {{"project_name", "Nested Mkdir"}, {"project_slug", "nested-mkdir"}, {"use_notes", "false"}}, + }; + + const std::filesystem::path hook_path = build_root.path() / "nested_mkdir.lua"; + write_text_file(hook_path, + "mkdir('nested/deep/leaf')\n" + "write_file('nested/deep/leaf/marker.txt', 'created')\n"); + + lua_engine.run_hook(hook_path, manifest, context, renderer); + + REQUIRE(std::filesystem::is_directory(build_root.path() / "nested" / "deep" / "leaf")); + REQUIRE_EQ(read_text_file(build_root.path() / "nested" / "deep" / "leaf" / "marker.txt"), std::string("created")); +} + +TEST_CASE(TempifyApp_render_creates_nested_target_path_and_template_directories) { + ScopedDirectoryCleanupLocal workspace(std::filesystem::temp_directory_path() / + "tempify-app-directory-structure-workspace"); + ScopedDirectoryCleanup target_root(std::filesystem::temp_directory_path() / + "tempify-app-directory-structure-target-root"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-directory-structure-data-home"); + + const std::filesystem::path template_root = create_directory_layout_template(workspace.path()); + const std::filesystem::path target = target_root.path() / "deep" / "nested" / "render-target"; + REQUIRE(!std::filesystem::exists(target.parent_path())); + + tempify::TempifyApp app; + REQUIRE_EQ(app.run({ + template_root.string(), + target.string(), + "--set", + "project_name=Directory Structure", + "--set", + "project_slug=directory-structure", + }), + 0); + + REQUIRE(std::filesystem::is_directory(target)); + REQUIRE(std::filesystem::is_directory(target / "empty_keep")); + REQUIRE(std::filesystem::is_directory(target / "deep" / "nested")); + REQUIRE(std::filesystem::is_regular_file(target / "deep" / "nested" / "leaf.txt")); + REQUIRE(read_text_file(target / "README.md").find("# Directory Structure") != std::string::npos); + REQUIRE_EQ(read_text_file(target / "deep" / "nested" / "leaf.txt"), std::string("leaf=directory-structure\n")); +} + +TEST_CASE(TempifyApp_render_rejects_target_path_that_exists_as_file) { + ScopedDirectoryCleanupLocal workspace(std::filesystem::temp_directory_path() / + "tempify-app-target-file-conflict-workspace"); + ScopedDirectoryCleanup target_parent(std::filesystem::temp_directory_path() / + "tempify-app-target-file-conflict-parent"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-target-file-conflict-data-home"); + + const std::filesystem::path template_root = create_directory_layout_template(workspace.path()); + const std::filesystem::path target = target_parent.path() / "not-a-directory"; + write_text_file(target, "already a file\n"); + + tempify::TempifyApp app; + REQUIRE_THROWS_AS(app.run({ + template_root.string(), + target.string(), + "--set", + "project_name=Blocked Target", + "--set", + "project_slug=blocked-target", + }), + tempify::TempifyError); +} diff --git a/tests/tempify/TempifyAppHooksIntegrationTests.cpp b/tests/tempify/TempifyAppHooksIntegrationTests.cpp index 5cb210c..8a17635 100644 --- a/tests/tempify/TempifyAppHooksIntegrationTests.cpp +++ b/tests/tempify/TempifyAppHooksIntegrationTests.cpp @@ -9,6 +9,7 @@ using tempify::test_support::create_slow_hook_template; using tempify::test_support::json_escaped_path; using tempify::test_support::read_text_file; using tempify::test_support::ScopedDirectoryCleanup; +using tempify::test_support::ScopedNonTtyStdin; using tempify::test_support::ScopedStdinCapture; using tempify::test_support::ScopedStdoutCapture; using tempify::test_support::ScopedTempifyDataHome; @@ -47,6 +48,7 @@ TEST_CASE(TempifyApp_no_hooks_skips_hook_side_effects) { TEST_CASE(TempifyApp_accept_hooks_ask_without_tty_runs_hooks) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-accept-hooks-ask-test"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-accept-hooks-ask-data-home"); + ScopedNonTtyStdin non_tty_stdin; tempify::TempifyApp app; const int result = app.run({ From 1fa2ed03725fc42049dc9c39e929f79ee56b2751 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 22:23:11 +0200 Subject: [PATCH 12/29] Adding fuzzer tests --- CMakeLists.txt | 7 +++ CMakePresets.json | 20 +++++++- scripts/ci/run_fuzzers.sh | 12 +++++ tests/README.md | 33 +++++++++++++ tests/fault_tolerance/fuzz/AnswerFileFuzz.cpp | 27 +++++++++++ .../fuzz/GenerationLockFuzz.cpp | 29 ++++++++++++ .../fuzz/TempifyConfigFuzz.cpp | 26 +++++++++++ .../fault_tolerance/fuzz/regression/.gitkeep | 0 .../fuzz/seeds/answer_file/valid.json | 7 +++ .../fuzz/seeds/generation_lock/minimal.json | 21 +++++++++ .../fuzz/seeds/tempify_config/minimal.json | 10 ++++ .../fuzz/support/FuzzTempDir.h | 46 +++++++++++++++++++ 12 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 tests/fault_tolerance/fuzz/AnswerFileFuzz.cpp create mode 100644 tests/fault_tolerance/fuzz/GenerationLockFuzz.cpp create mode 100644 tests/fault_tolerance/fuzz/TempifyConfigFuzz.cpp create mode 100644 tests/fault_tolerance/fuzz/regression/.gitkeep create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_file/valid.json create mode 100644 tests/fault_tolerance/fuzz/seeds/generation_lock/minimal.json create mode 100644 tests/fault_tolerance/fuzz/seeds/tempify_config/minimal.json create mode 100644 tests/fault_tolerance/fuzz/support/FuzzTempDir.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 60e2b11..1602f8e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,12 +225,18 @@ if(TEMPIFY_BUILD_FUZZERS) fuzz_slugify fuzz_answer_json fuzz_cli_parser + fuzz_tempify_config + fuzz_generation_lock + fuzz_answer_file ) set(TEMPIFY_FUZZ_SOURCES tests/fault_tolerance/fuzz/SlugFuzz.cpp tests/fault_tolerance/fuzz/AnswerJsonFuzz.cpp tests/fault_tolerance/fuzz/CliParserFuzz.cpp + tests/fault_tolerance/fuzz/TempifyConfigFuzz.cpp + tests/fault_tolerance/fuzz/GenerationLockFuzz.cpp + tests/fault_tolerance/fuzz/AnswerFileFuzz.cpp ) list(LENGTH TEMPIFY_FUZZ_TARGETS TEMPIFY_FUZZ_TARGET_COUNT) @@ -247,6 +253,7 @@ if(TEMPIFY_BUILD_FUZZERS) list(GET TEMPIFY_FUZZ_SOURCES ${TEMPIFY_FUZZ_INDEX} TEMPIFY_FUZZ_SOURCE) add_executable(${TEMPIFY_FUZZ_TARGET} ${TEMPIFY_FUZZ_SOURCE}) + target_include_directories(${TEMPIFY_FUZZ_TARGET} PRIVATE tests/fault_tolerance/fuzz/support) target_link_libraries(${TEMPIFY_FUZZ_TARGET} PRIVATE tempify_core) target_compile_options(${TEMPIFY_FUZZ_TARGET} PRIVATE -fsanitize=fuzzer) target_link_options(${TEMPIFY_FUZZ_TARGET} PRIVATE -fsanitize=fuzzer) diff --git a/CMakePresets.json b/CMakePresets.json index 8328b4c..1dd89ac 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -80,7 +80,7 @@ "CMAKE_BUILD_TYPE": "Debug", "CMAKE_CXX_COMPILER": "clang++", "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", - "BUILD_TESTING": "OFF", + "BUILD_TESTING": "ON", "TEMPIFY_BUILD_FUZZERS": "ON", "PREBYTE_ENABLE_SANITIZERS": "ON", "PREBYTE_SANITIZERS": "address,undefined" @@ -131,7 +131,16 @@ }, { "name": "fuzz", - "configurePreset": "fuzz" + "configurePreset": "fuzz", + "targets": [ + "tempify", + "fuzz_slugify", + "fuzz_answer_json", + "fuzz_cli_parser", + "fuzz_tempify_config", + "fuzz_generation_lock", + "fuzz_answer_file" + ] }, { "name": "tidy-core", @@ -167,6 +176,13 @@ "output": { "outputOnFailure": true } + }, + { + "name": "fuzz", + "configurePreset": "fuzz", + "output": { + "outputOnFailure": true + } } ] } diff --git a/scripts/ci/run_fuzzers.sh b/scripts/ci/run_fuzzers.sh index 3f6d3c7..a944248 100755 --- a/scripts/ci/run_fuzzers.sh +++ b/scripts/ci/run_fuzzers.sh @@ -11,6 +11,9 @@ FUZZ_TARGETS=( fuzz_slugify fuzz_answer_json fuzz_cli_parser + fuzz_tempify_config + fuzz_generation_lock + fuzz_answer_file ) seed_dir_for_target() { @@ -21,9 +24,18 @@ seed_dir_for_target() { fuzz_answer_json) printf '%s/tests/fault_tolerance/fuzz/seeds/answer_json\n' "$ROOT" ;; + fuzz_answer_file) + printf '%s/tests/fault_tolerance/fuzz/seeds/answer_file\n' "$ROOT" + ;; fuzz_cli_parser) printf '%s/tests/fault_tolerance/fuzz/seeds/cli_parser\n' "$ROOT" ;; + fuzz_tempify_config) + printf '%s/tests/fault_tolerance/fuzz/seeds/tempify_config\n' "$ROOT" + ;; + fuzz_generation_lock) + printf '%s/tests/fault_tolerance/fuzz/seeds/generation_lock\n' "$ROOT" + ;; *) printf 'Unknown fuzz target: %s\n' "$1" >&2 return 1 diff --git a/tests/README.md b/tests/README.md index a842c27..c4198d3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,6 +13,39 @@ Tests are grouped by non-functional quality attributes (NFR). Each folder target | `tests/concurrency/e2e/` | Parallel CLI execution | | `tests/fault_tolerance/fuzz/` | LibFuzzer targets and regression seeds | +## Fuzzing + +Requires Clang with libFuzzer. Configure and run: + +```bash +make fuzz +# or with a shorter smoke run: +TEMPIFY_FUZZ_MAX_TOTAL_TIME=10 make fuzz +``` + +Targets: + +| Fuzzer | Input surface | +|--------|----------------| +| `fuzz_slugify` | `slugify()` | +| `fuzz_answer_json` | Prebyte JSON parser (answers/config baseline) | +| `fuzz_cli_parser` | `CliParser::parse()` | +| `fuzz_tempify_config` | `load_tempify_config_file()` | +| `fuzz_generation_lock` | `load_generation_lock()` + `content_fingerprint_hex()` | +| `fuzz_answer_file` | `load_answer_file()` (strict + non-strict) | + +Seeds live under `tests/fault_tolerance/fuzz/seeds/`. Generated corpora are written to +`tests/fault_tolerance/fuzz/corpus/` (gitignored). Replay saved crash inputs from +`tests/fault_tolerance/fuzz/regression//` via `make fuzz-regression`. + +Manual workflow: + +```bash +cmake --preset fuzz +cmake --build --preset fuzz +ctest --test-dir build-cmake/fuzz -R '^fuzz_' --output-on-failure +``` + ## Running ```bash diff --git a/tests/fault_tolerance/fuzz/AnswerFileFuzz.cpp b/tests/fault_tolerance/fuzz/AnswerFileFuzz.cpp new file mode 100644 index 0000000..a553ba0 --- /dev/null +++ b/tests/fault_tolerance/fuzz/AnswerFileFuzz.cpp @@ -0,0 +1,27 @@ +#include "tempify/question/AnswerFile.h" +#include "tempify/support/Errors.h" + +#include "support/FuzzTempDir.h" + +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size) { + FuzzTempDir temp_dir; + const std::filesystem::path path = temp_dir.path() / "answers.json"; + { + std::ofstream output(path, std::ios::binary); + output.write(reinterpret_cast(data), static_cast(size)); + } + + for (const bool strict : {false, true}) { + try { + (void)tempify::load_answer_file(path, strict); + } catch (const tempify::TempifyError &) { + } catch (const std::exception &) { + } + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/GenerationLockFuzz.cpp b/tests/fault_tolerance/fuzz/GenerationLockFuzz.cpp new file mode 100644 index 0000000..403c03d --- /dev/null +++ b/tests/fault_tolerance/fuzz/GenerationLockFuzz.cpp @@ -0,0 +1,29 @@ +#include "tempify/build/GenerationLock.h" +#include "tempify/support/Errors.h" + +#include "support/FuzzTempDir.h" + +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size) { + const std::string input(reinterpret_cast(data), size); + (void)tempify::content_fingerprint_hex(input); + + FuzzTempDir temp_dir; + const std::filesystem::path path = temp_dir.path() / "lock.json"; + { + std::ofstream output(path, std::ios::binary); + output.write(reinterpret_cast(data), static_cast(size)); + } + + try { + (void)tempify::load_generation_lock(path); + } catch (const tempify::TempifyError &) { + } catch (const std::exception &) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/TempifyConfigFuzz.cpp b/tests/fault_tolerance/fuzz/TempifyConfigFuzz.cpp new file mode 100644 index 0000000..e01a6be --- /dev/null +++ b/tests/fault_tolerance/fuzz/TempifyConfigFuzz.cpp @@ -0,0 +1,26 @@ +#include "tempify/config/TempifyConfig.h" +#include "tempify/support/Errors.h" + +#include "support/FuzzTempDir.h" + +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size) { + FuzzTempDir temp_dir; + const std::filesystem::path path = temp_dir.path() / "config.json"; + { + std::ofstream output(path, std::ios::binary); + output.write(reinterpret_cast(data), static_cast(size)); + } + + try { + (void)tempify::load_tempify_config_file(path); + } catch (const tempify::TempifyError &) { + } catch (const std::exception &) { + } + + return 0; +} diff --git a/tests/fault_tolerance/fuzz/regression/.gitkeep b/tests/fault_tolerance/fuzz/regression/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fault_tolerance/fuzz/seeds/answer_file/valid.json b/tests/fault_tolerance/fuzz/seeds/answer_file/valid.json new file mode 100644 index 0000000..2107454 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_file/valid.json @@ -0,0 +1,7 @@ +{ + "project_name": "demo", + "name_slug": "demo-app", + "namespace": "demo_ns", + "include_ci": false, + "author": "Fuzz Tester" +} diff --git a/tests/fault_tolerance/fuzz/seeds/generation_lock/minimal.json b/tests/fault_tolerance/fuzz/seeds/generation_lock/minimal.json new file mode 100644 index 0000000..a159d8f --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/generation_lock/minimal.json @@ -0,0 +1,21 @@ +{ + "tempify_version": "0.1.2", + "build_root": "/tmp/fuzz-out", + "generated_at": "2026-01-01T00:00:00Z", + "existing_path_behavior": "error", + "hook_acceptance": "yes", + "hooks_disabled": false, + "managed_files": ["README.md"], + "managed_file_hashes": { + "README.md": "abc123" + }, + "values": { + "project_name": "Fuzz" + }, + "template": { + "id": "basic_cpp", + "name": "Basic C++", + "version": "1.0.0", + "root": "/tmp/templates/basic_cpp" + } +} diff --git a/tests/fault_tolerance/fuzz/seeds/tempify_config/minimal.json b/tests/fault_tolerance/fuzz/seeds/tempify_config/minimal.json new file mode 100644 index 0000000..c28f4f5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/tempify_config/minimal.json @@ -0,0 +1,10 @@ +{ + "defaults": { + "project_name": "Fuzz Demo" + }, + "render": { + "accept_hooks": "ask", + "hook_timeout_ms": 1000, + "existing_path_behavior": "error" + } +} diff --git a/tests/fault_tolerance/fuzz/support/FuzzTempDir.h b/tests/fault_tolerance/fuzz/support/FuzzTempDir.h new file mode 100644 index 0000000..14e3ec4 --- /dev/null +++ b/tests/fault_tolerance/fuzz/support/FuzzTempDir.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +class FuzzTempDir { + public: + FuzzTempDir() { + path_ = root_directory() / std::to_string(next_sequence()); + std::filesystem::create_directories(path_); + } + + ~FuzzTempDir() { + std::error_code error; + std::filesystem::remove_all(path_, error); + } + + FuzzTempDir(const FuzzTempDir &) = delete; + FuzzTempDir &operator=(const FuzzTempDir &) = delete; + + const std::filesystem::path &path() const { + return path_; + } + + private: + static std::filesystem::path root_directory() { + static const std::filesystem::path root = []() { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::filesystem::path path = std::filesystem::temp_directory_path() / + ("tempify-fuzz-root-" + + std::to_string(static_cast(stamp))); + std::filesystem::create_directories(path); + return path; + }(); + return root; + } + + static std::uint64_t next_sequence() { + static std::atomic counter{0}; + return counter.fetch_add(1, std::memory_order_relaxed); + } + + std::filesystem::path path_; +}; From 71da5fd33c3c9d1a2c6bf1bc3318a080f674f246 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 22:32:09 +0200 Subject: [PATCH 13/29] Adding new seeds for fuzzer --- tests/README.md | 4 +-- .../seeds/answer_file/boolean_and_int.json | 5 +++ .../fuzz/seeds/answer_file/empty_object.json | 1 + .../fuzz/seeds/answer_file/extra_keys.json | 8 +++++ .../seeds/answer_file/snapshot_answers.json | 6 ++++ .../answer_file/whitespace_in_values.json | 4 +++ .../fuzz/seeds/answer_json/ci_enabled.json | 8 +++++ .../fuzz/seeds/answer_json/default_no_ci.json | 6 ++++ .../fuzz/seeds/answer_json/empty_object.json | 1 + .../seeds/answer_json/escaped_strings.json | 3 ++ .../fuzz/seeds/answer_json/scalar_types.json | 7 ++++ .../seeds/cli_parser/catalog_commands.txt | 8 +++++ .../fuzz/seeds/cli_parser/completion_help.txt | 6 ++++ .../fuzz/seeds/cli_parser/edge_flags_only.txt | 1 + .../seeds/cli_parser/hooks_and_overwrite.txt | 1 + .../seeds/cli_parser/prebyte_passthrough.txt | 2 ++ .../fuzz/seeds/cli_parser/questions_json.txt | 1 + .../fuzz/seeds/cli_parser/reapply_command.txt | 1 + .../seeds/cli_parser/reapply_diff_plan.txt | 1 + .../seeds/cli_parser/render_basic_cpp.txt | 1 + .../seeds/cli_parser/render_with_answers.txt | 1 + .../advanced_hooks_layout.json | 27 +++++++++++++++ .../seeds/generation_lock/basic_cpp_full.json | 33 +++++++++++++++++++ .../seeds/generation_lock/empty_object.json | 1 + .../generation_lock/hooks_disabled_empty.json | 11 +++++++ .../sparse_optional_fields.json | 10 ++++++ .../fuzz/seeds/slug/already_kebab.txt | 1 + .../fault_tolerance/fuzz/seeds/slug/empty.txt | 0 .../fuzz/seeds/slug/numbers_only.txt | 1 + .../fuzz/seeds/slug/single_char.txt | 1 + .../seeds/slug/spaces_and_underscores.txt | 1 + .../fuzz/seeds/slug/unicode_and_symbols.txt | 1 + .../seeds/tempify_config/defaults_only.json | 7 ++++ .../seeds/tempify_config/empty_object.json | 1 + .../seeds/tempify_config/full_workspace.json | 11 +++++++ .../render_overwrite_yes_hooks.json | 7 ++++ .../tempify_config/render_skip_no_hooks.json | 7 ++++ 37 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_file/boolean_and_int.json create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_file/empty_object.json create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_file/extra_keys.json create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_file/snapshot_answers.json create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_file/whitespace_in_values.json create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_json/ci_enabled.json create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_json/default_no_ci.json create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_json/empty_object.json create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_json/escaped_strings.json create mode 100644 tests/fault_tolerance/fuzz/seeds/answer_json/scalar_types.json create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/catalog_commands.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/completion_help.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/edge_flags_only.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/hooks_and_overwrite.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/prebyte_passthrough.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/questions_json.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/reapply_command.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/reapply_diff_plan.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/render_basic_cpp.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/cli_parser/render_with_answers.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/generation_lock/advanced_hooks_layout.json create mode 100644 tests/fault_tolerance/fuzz/seeds/generation_lock/basic_cpp_full.json create mode 100644 tests/fault_tolerance/fuzz/seeds/generation_lock/empty_object.json create mode 100644 tests/fault_tolerance/fuzz/seeds/generation_lock/hooks_disabled_empty.json create mode 100644 tests/fault_tolerance/fuzz/seeds/generation_lock/sparse_optional_fields.json create mode 100644 tests/fault_tolerance/fuzz/seeds/slug/already_kebab.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/slug/empty.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/slug/numbers_only.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/slug/single_char.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/slug/spaces_and_underscores.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/slug/unicode_and_symbols.txt create mode 100644 tests/fault_tolerance/fuzz/seeds/tempify_config/defaults_only.json create mode 100644 tests/fault_tolerance/fuzz/seeds/tempify_config/empty_object.json create mode 100644 tests/fault_tolerance/fuzz/seeds/tempify_config/full_workspace.json create mode 100644 tests/fault_tolerance/fuzz/seeds/tempify_config/render_overwrite_yes_hooks.json create mode 100644 tests/fault_tolerance/fuzz/seeds/tempify_config/render_skip_no_hooks.json diff --git a/tests/README.md b/tests/README.md index c4198d3..5ee8174 100644 --- a/tests/README.md +++ b/tests/README.md @@ -34,8 +34,8 @@ Targets: | `fuzz_generation_lock` | `load_generation_lock()` + `content_fingerprint_hex()` | | `fuzz_answer_file` | `load_answer_file()` (strict + non-strict) | -Seeds live under `tests/fault_tolerance/fuzz/seeds/`. Generated corpora are written to -`tests/fault_tolerance/fuzz/corpus/` (gitignored). Replay saved crash inputs from +Seeds live under `tests/fault_tolerance/fuzz/seeds//` (checked-in starting inputs). +Generated corpora are written to `tests/fault_tolerance/fuzz/corpus/` (gitignored). Replay saved crash inputs from `tests/fault_tolerance/fuzz/regression//` via `make fuzz-regression`. Manual workflow: diff --git a/tests/fault_tolerance/fuzz/seeds/answer_file/boolean_and_int.json b/tests/fault_tolerance/fuzz/seeds/answer_file/boolean_and_int.json new file mode 100644 index 0000000..86cfadd --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_file/boolean_and_int.json @@ -0,0 +1,5 @@ +{ + "project_name": "Bool Demo", + "include_ci": true, + "hook_timeout_ms": 0 +} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_file/empty_object.json b/tests/fault_tolerance/fuzz/seeds/answer_file/empty_object.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_file/empty_object.json @@ -0,0 +1 @@ +{} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_file/extra_keys.json b/tests/fault_tolerance/fuzz/seeds/answer_file/extra_keys.json new file mode 100644 index 0000000..f9e8904 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_file/extra_keys.json @@ -0,0 +1,8 @@ +{ + "project_name": "Strict Demo", + "name_slug": "strict-demo", + "namespace": "strict_ns", + "include_ci": false, + "author": "Fuzz Tester", + "extra_unknown_key": "ignored by non-strict" +} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_file/snapshot_answers.json b/tests/fault_tolerance/fuzz/seeds/answer_file/snapshot_answers.json new file mode 100644 index 0000000..7b128e9 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_file/snapshot_answers.json @@ -0,0 +1,6 @@ +{ + "project_name": "Snapshot App", + "project_slug": "snapshot-app", + "namespace": "snapshot_ns", + "include_ci": false +} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_file/whitespace_in_values.json b/tests/fault_tolerance/fuzz/seeds/answer_file/whitespace_in_values.json new file mode 100644 index 0000000..b634f4d --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_file/whitespace_in_values.json @@ -0,0 +1,4 @@ +{ + "project_name": "Line\nBreak", + "author": "Tab\there" +} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_json/ci_enabled.json b/tests/fault_tolerance/fuzz/seeds/answer_json/ci_enabled.json new file mode 100644 index 0000000..f9c6a91 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_json/ci_enabled.json @@ -0,0 +1,8 @@ +{ + "project_name": "CI Snapshot App", + "project_slug": "ci-snapshot-app", + "namespace": "ci_ns", + "include_ci": true, + "ci_provider": "github", + "docs_url": "https://docs.example" +} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_json/default_no_ci.json b/tests/fault_tolerance/fuzz/seeds/answer_json/default_no_ci.json new file mode 100644 index 0000000..7b128e9 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_json/default_no_ci.json @@ -0,0 +1,6 @@ +{ + "project_name": "Snapshot App", + "project_slug": "snapshot-app", + "namespace": "snapshot_ns", + "include_ci": false +} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_json/empty_object.json b/tests/fault_tolerance/fuzz/seeds/answer_json/empty_object.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_json/empty_object.json @@ -0,0 +1 @@ +{} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_json/escaped_strings.json b/tests/fault_tolerance/fuzz/seeds/answer_json/escaped_strings.json new file mode 100644 index 0000000..4939c87 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_json/escaped_strings.json @@ -0,0 +1,3 @@ +{ + "project_name": "Escaped \"quotes\" and\nnewlines\t tabs" +} diff --git a/tests/fault_tolerance/fuzz/seeds/answer_json/scalar_types.json b/tests/fault_tolerance/fuzz/seeds/answer_json/scalar_types.json new file mode 100644 index 0000000..341b2fa --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/answer_json/scalar_types.json @@ -0,0 +1,7 @@ +{ + "project_name": "Typed Values", + "count": 42, + "ratio": 3.14, + "enabled": true, + "disabled": false +} diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/catalog_commands.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/catalog_commands.txt new file mode 100644 index 0000000..6a6c888 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/catalog_commands.txt @@ -0,0 +1,8 @@ +list --json +info basic_cpp +doctor +refresh +validate basic_cpp +inspect basic_cpp --json +lint basic_cpp +test basic_cpp --fixture default_no_ci diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/completion_help.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/completion_help.txt new file mode 100644 index 0000000..c5de28b --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/completion_help.txt @@ -0,0 +1,6 @@ +completion bash +completion zsh +completion fish +--help +-h +prebyte --help diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/edge_flags_only.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/edge_flags_only.txt new file mode 100644 index 0000000..71dde64 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/edge_flags_only.txt @@ -0,0 +1 @@ +--set --var --answers --write-answers --non-interactive --strict --hook-timeout-ms --dry-run --plan-json --diff --reapply --report --json --tui -f -s -q --questions --full --accept-hooks yes diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/hooks_and_overwrite.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/hooks_and_overwrite.txt new file mode 100644 index 0000000..0a4ee6c --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/hooks_and_overwrite.txt @@ -0,0 +1 @@ +advanced_hooks_layout out-dir --accept-hooks ask --no-hooks --overwrite-if-exists -f -s --tui diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/prebyte_passthrough.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/prebyte_passthrough.txt new file mode 100644 index 0000000..5b7d5a5 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/prebyte_passthrough.txt @@ -0,0 +1,2 @@ +process --help +-p render template.txt out.txt diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/questions_json.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/questions_json.txt new file mode 100644 index 0000000..19e87ea --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/questions_json.txt @@ -0,0 +1 @@ +basic_cpp my-app --questions --json --full diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/reapply_command.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/reapply_command.txt new file mode 100644 index 0000000..eb75da1 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/reapply_command.txt @@ -0,0 +1 @@ +reapply basic_cpp my-app --set project_name=Reapply --set name_slug=reapply-app --set namespace=reapply_ns --set include_ci=false --set author=Tester diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/reapply_diff_plan.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/reapply_diff_plan.txt new file mode 100644 index 0000000..9d25f88 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/reapply_diff_plan.txt @@ -0,0 +1 @@ +basic_cpp my-app --reapply --diff --plan-json --hook-timeout-ms 2500 diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/render_basic_cpp.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/render_basic_cpp.txt new file mode 100644 index 0000000..5c13c14 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/render_basic_cpp.txt @@ -0,0 +1 @@ +basic_cpp my-app --set project_name=My App --set name_slug=my-app --set namespace=app_ns --set include_ci=false --set author=Tester diff --git a/tests/fault_tolerance/fuzz/seeds/cli_parser/render_with_answers.txt b/tests/fault_tolerance/fuzz/seeds/cli_parser/render_with_answers.txt new file mode 100644 index 0000000..a1649b2 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/cli_parser/render_with_answers.txt @@ -0,0 +1 @@ +basic_cpp my-app --answers answers.json --non-interactive --strict diff --git a/tests/fault_tolerance/fuzz/seeds/generation_lock/advanced_hooks_layout.json b/tests/fault_tolerance/fuzz/seeds/generation_lock/advanced_hooks_layout.json new file mode 100644 index 0000000..1efe751 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/generation_lock/advanced_hooks_layout.json @@ -0,0 +1,27 @@ +{ + "tempify_version": "v0.1.2", + "template": { + "id": "advanced_hooks_layout", + "name": "Advanced Hooks Layout", + "version": "0.1.0", + "root": "/tmp/templates/advanced_hooks_layout" + }, + "build_root": "/tmp/hook-app", + "generated_at": "2026-08-22T12:00:00Z", + "existing_path_behavior": "error", + "hook_acceptance": "yes", + "hooks_disabled": false, + "managed_files": [ + "README.md", + "static/output.txt" + ], + "managed_file_hashes": { + "README.md": "974d2d446bdeca4c", + "static/output.txt": "9d01498cd0f513cc" + }, + "values": { + "project_name": "Hook App", + "project_slug": "hook-app", + "use_notes": "true" + } +} diff --git a/tests/fault_tolerance/fuzz/seeds/generation_lock/basic_cpp_full.json b/tests/fault_tolerance/fuzz/seeds/generation_lock/basic_cpp_full.json new file mode 100644 index 0000000..c4a7012 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/generation_lock/basic_cpp_full.json @@ -0,0 +1,33 @@ +{ + "tempify_version": "0.1.2", + "template": { + "id": "basic_cpp", + "name": "Basic C++ App", + "version": "0.1.0", + "root": "/tmp/templates/basic_cpp" + }, + "build_root": "/tmp/basic-cpp-out", + "generated_at": "2026-08-22T10:00:00Z", + "existing_path_behavior": "skip", + "hook_acceptance": "ask", + "hooks_disabled": false, + "managed_files": [ + "README.md", + "CMakeLists.txt", + "src/main.cpp", + ".tempify-summary.txt" + ], + "managed_file_hashes": { + "README.md": "abc123def456", + "CMakeLists.txt": "deadbeef", + "src/main.cpp": "cafebabe", + ".tempify-summary.txt": "00112233" + }, + "values": { + "project_name": "Basic App", + "name_slug": "basic-app", + "namespace": "basic_ns", + "include_ci": "false", + "author": "Tester" + } +} diff --git a/tests/fault_tolerance/fuzz/seeds/generation_lock/empty_object.json b/tests/fault_tolerance/fuzz/seeds/generation_lock/empty_object.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/generation_lock/empty_object.json @@ -0,0 +1 @@ +{} diff --git a/tests/fault_tolerance/fuzz/seeds/generation_lock/hooks_disabled_empty.json b/tests/fault_tolerance/fuzz/seeds/generation_lock/hooks_disabled_empty.json new file mode 100644 index 0000000..25801d2 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/generation_lock/hooks_disabled_empty.json @@ -0,0 +1,11 @@ +{ + "tempify_version": "0.1.0", + "build_root": "/tmp/out", + "generated_at": "2026-01-01T00:00:00Z", + "existing_path_behavior": "error", + "hook_acceptance": "yes", + "hooks_disabled": true, + "managed_files": [], + "managed_file_hashes": {}, + "values": {} +} diff --git a/tests/fault_tolerance/fuzz/seeds/generation_lock/sparse_optional_fields.json b/tests/fault_tolerance/fuzz/seeds/generation_lock/sparse_optional_fields.json new file mode 100644 index 0000000..fc77285 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/generation_lock/sparse_optional_fields.json @@ -0,0 +1,10 @@ +{ + "tempify_version": "0.1.2", + "build_root": "/tmp/sparse-out", + "managed_files": [ + "README.md" + ], + "values": { + "project_name": "Sparse" + } +} diff --git a/tests/fault_tolerance/fuzz/seeds/slug/already_kebab.txt b/tests/fault_tolerance/fuzz/seeds/slug/already_kebab.txt new file mode 100644 index 0000000..b8e51a4 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/slug/already_kebab.txt @@ -0,0 +1 @@ +my-already-slugified-name diff --git a/tests/fault_tolerance/fuzz/seeds/slug/empty.txt b/tests/fault_tolerance/fuzz/seeds/slug/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fault_tolerance/fuzz/seeds/slug/numbers_only.txt b/tests/fault_tolerance/fuzz/seeds/slug/numbers_only.txt new file mode 100644 index 0000000..e56e15b --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/slug/numbers_only.txt @@ -0,0 +1 @@ +12345 diff --git a/tests/fault_tolerance/fuzz/seeds/slug/single_char.txt b/tests/fault_tolerance/fuzz/seeds/slug/single_char.txt new file mode 100644 index 0000000..f70f10e --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/slug/single_char.txt @@ -0,0 +1 @@ +A diff --git a/tests/fault_tolerance/fuzz/seeds/slug/spaces_and_underscores.txt b/tests/fault_tolerance/fuzz/seeds/slug/spaces_and_underscores.txt new file mode 100644 index 0000000..b31aed3 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/slug/spaces_and_underscores.txt @@ -0,0 +1 @@ + My Cool_Project Name diff --git a/tests/fault_tolerance/fuzz/seeds/slug/unicode_and_symbols.txt b/tests/fault_tolerance/fuzz/seeds/slug/unicode_and_symbols.txt new file mode 100644 index 0000000..4bc6337 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/slug/unicode_and_symbols.txt @@ -0,0 +1 @@ +Café & Résumé — v2.0 (Beta)! diff --git a/tests/fault_tolerance/fuzz/seeds/tempify_config/defaults_only.json b/tests/fault_tolerance/fuzz/seeds/tempify_config/defaults_only.json new file mode 100644 index 0000000..84b1227 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/tempify_config/defaults_only.json @@ -0,0 +1,7 @@ +{ + "defaults": { + "project_name": "Workspace Default", + "author": "Config Author", + "include_ci": "false" + } +} diff --git a/tests/fault_tolerance/fuzz/seeds/tempify_config/empty_object.json b/tests/fault_tolerance/fuzz/seeds/tempify_config/empty_object.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/tempify_config/empty_object.json @@ -0,0 +1 @@ +{} diff --git a/tests/fault_tolerance/fuzz/seeds/tempify_config/full_workspace.json b/tests/fault_tolerance/fuzz/seeds/tempify_config/full_workspace.json new file mode 100644 index 0000000..5adb18b --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/tempify_config/full_workspace.json @@ -0,0 +1,11 @@ +{ + "defaults": { + "project_name": "Merged", + "namespace": "merged_ns" + }, + "render": { + "accept_hooks": "ask", + "hook_timeout_ms": 250, + "existing_path_behavior": "error" + } +} diff --git a/tests/fault_tolerance/fuzz/seeds/tempify_config/render_overwrite_yes_hooks.json b/tests/fault_tolerance/fuzz/seeds/tempify_config/render_overwrite_yes_hooks.json new file mode 100644 index 0000000..340b160 --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/tempify_config/render_overwrite_yes_hooks.json @@ -0,0 +1,7 @@ +{ + "render": { + "accept_hooks": "yes", + "hook_timeout_ms": 60000, + "existing_path_behavior": "overwrite" + } +} diff --git a/tests/fault_tolerance/fuzz/seeds/tempify_config/render_skip_no_hooks.json b/tests/fault_tolerance/fuzz/seeds/tempify_config/render_skip_no_hooks.json new file mode 100644 index 0000000..e80b3df --- /dev/null +++ b/tests/fault_tolerance/fuzz/seeds/tempify_config/render_skip_no_hooks.json @@ -0,0 +1,7 @@ +{ + "render": { + "accept_hooks": "no", + "hook_timeout_ms": 0, + "existing_path_behavior": "skip" + } +} From 8fa63c4ff308d139339de7f82db9444135ae6751 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 23:03:43 +0200 Subject: [PATCH 14/29] Update Readme --- README.md | 388 ++++++++---------------------------------------------- 1 file changed, 52 insertions(+), 336 deletions(-) diff --git a/README.md b/README.md index 1e294ad..23b2606 100644 --- a/README.md +++ b/README.md @@ -1,385 +1,101 @@ # Tempify -Tempify erzeugt Projekt-Templates auf Basis von Lua-Manifests, Fragen, Hooks und eingebettetem Prebyte-Rendering. +Tempify generates project templates from Lua manifests, interactive questions, optional hooks, and embedded Prebyte rendering. -Prebyte wird nicht im Repo vendort. Tempify zieht beim ersten Configure standardmaessig gepinntes Prebyte von GitHub und baut dagegen. +**Documentation:** [GitHub Wiki](https://github.com/Coditary/Tempify/wiki) -## Build +| Topic | Wiki page | +|-------|-----------| +| First render | [Getting Started](https://github.com/Coditary/Tempify/wiki/Getting-Started) | +| CLI & flags | [CLI Reference](https://github.com/Coditary/Tempify/wiki/CLI-Reference) | +| Write templates | [Your First Template](https://github.com/Coditary/Tempify/wiki/Your-First-Template) | +| Update projects | [Reapply & Diff](https://github.com/Coditary/Tempify/wiki/Reapply) | +| Install via ReqPack | [Installation](https://github.com/Coditary/Tempify/wiki/Installation) | +| Build & tests | [Development & Build](https://github.com/Coditary/Tempify/wiki/Development-and-Build) | + +Prebyte is not vendored in this repo. On first configure, Tempify fetches a pinned Prebyte release from GitHub unless you set `TEMPIFY_PREBYTE_SOURCE_DIR`. + +## Quick start ```bash +git clone https://github.com/Coditary/Tempify.git +cd Tempify cmake -S . -B build cmake --build build + +./build/tempify list +./build/tempify basic_cpp my-app \ + --set project_name="My App" \ + --set name_slug=my-app \ + --set namespace=app_ns \ + --set include_ci=false \ + --set author=Me ``` -Install lokal: +Install locally: ```bash cmake --install build --prefix "$HOME/.local" +export PATH="$HOME/.local/bin:$PATH" ``` -Voraussetzungen: +## Requirements -- C++23 Compiler +- C++23 compiler - CMake 3.31+ -- `CLI11` -- `git` fuer ersten Configure ohne lokales Prebyte-Override - -Lokales Prebyte-Override: - -```bash -cmake -S . -B build -DTEMPIFY_PREBYTE_SOURCE_DIR=/abs/pfad/zu/Prebyte -``` - -## Release-Artefakte +- CLI11 +- `git` (for first configure without a local Prebyte checkout) -Tag `v*`-Releases publizieren: - -- Binary-Archive fuer Linux, macOS, Windows jeweils `x86_64` und `aarch64` -- `.rqp`-Pakete fuer Linux und macOS jeweils `x86_64` und `aarch64` -- `index.json` fuer ReqPack-Repository-Nutzung -- Docker-Image `ghcr.io/coditary/tempify` fuer `linux/amd64` und `linux/arm64` - -Namensschema: - -- `tempify--linux-x86_64.tar.gz` -- `tempify--linux-aarch64.tar.gz` -- `tempify--macos-x86_64.tar.gz` -- `tempify--macos-aarch64.tar.gz` -- `tempify--windows-x86_64.zip` -- `tempify--windows-aarch64.zip` -- `tempify-cli--linux-x86_64.rqp` -- `tempify-cli--linux-aarch64.rqp` -- `tempify-cli--macos-x86_64.rqp` -- `tempify-cli--macos-aarch64.rqp` - -## ReqPack-Install - -Tempify `.rqp` installiert: - -- Runtime unter `$XDG_DATA_HOME/tempify/runtime/-+r/` -- stabilen Symlink `~/.local/bin/tempify` -- gebuendeltes ReqPack-Plugin `tempify` unter `$XDG_DATA_HOME/reqpack/plugins/tempify/` - -Naming: - -- ReqPack-CLI-Paketname ist `tempify-cli` -- ReqPack-Plugin-/Systemname fuer Templates bleibt `tempify` -- dadurch bleibt `rqp install tempify ` konfliktfrei - -Beispiel mit lokalem Paket: +Local Prebyte override: ```bash -rqp install ./dist/tempify-cli-0.1.0-linux-x86_64.rqp --non-interactive -~/.local/bin/tempify --help +cmake -S . -B build -DTEMPIFY_PREBYTE_SOURCE_DIR=/absolute/path/to/Prebyte ``` -Beispiel mit Release-Repository-Index: +CMake presets (recommended for development): ```bash -rqp install rqp:tempify-cli --non-interactive +cmake --preset dev +cmake --build --preset dev +make test ``` -Hinweis: - -- dafuer muss Release-`index.json` als `rqp`-Repository in ReqPack konfiguriert sein -- Docker-Image enthaelt nur `tempify`, kein `rqp` und keine auto-installierte ReqPack-Plugin-Registrierung. - -Weitere Doku: +## ReqPack install -- `docs/README.md` -- `docs/configuration.md` - -## CLI - -```bash -tempify [target] [--set key=value ...] [--answers answers.json] [--write-answers answers.json] [-f|--overwrite-if-exists] [-s|--skip-if-file-exists] [--accept-hooks yes|ask|no] [--no-hooks] [--non-interactive] [--strict] [--hook-timeout-ms ms] [--diff|--reapply|--dry-run|--plan-json] [--tui] -tempify reapply [--set key=value ...] [--answers answers.json] [--non-interactive] [--strict] [--hook-timeout-ms ms] [--report] [--json] [--tui] -tempify -q|--questions [--json] [--full] -tempify list -tempify refresh -tempify process [prebyte-args...] -tempify -p [prebyte-args...] -``` - -## Beispiele +Release `.rqp` packages install the CLI, runtime, and bundled `tempify` plugin for template distribution. ```bash -# Lokale Templates anzeigen -./build/tempify list - -# Template rendern -./build/tempify basic_cpp my-app \ - --set project_name="My App" \ - --set name_slug=my-app \ - --set namespace=app - -# In existierenden Ordner rendern und Konflikte ueberschreiben -./build/tempify basic_cpp my-app -f \ - --set project_name="My App" \ - --set name_slug=my-app \ - --set namespace=app - -# In existierenden Ordner rendern und bestehende Dateien behalten -./build/tempify basic_cpp my-app -s \ - --set project_name="My App" \ - --set name_slug=my-app \ - --set namespace=app - -# Reapply-Plan als JSON ohne Writes -./build/tempify reapply basic_cpp my-app --report --json \ - --set project_name="My App" \ - --set name_slug=my-app \ - --set namespace=app - -# Sichere Reapply-Aenderungen anwenden -./build/tempify reapply basic_cpp my-app --json \ - --set project_name="My App" \ - --set name_slug=my-app \ - --set namespace=app - -# Fragen minimal anzeigen -./build/tempify advanced_hooks_layout --questions - -# Fragen als JSON ausgeben -./build/tempify advanced_hooks_layout --questions --json - -# Fragen mit allen Feldern ausgeben -./build/tempify advanced_hooks_layout --questions --full - -# Shared-Template-Index neu aufbauen -./build/tempify refresh - -# Prebyte-CLI durchreichen -./build/tempify process -h - -# Prebyte-CLI Alias -./build/tempify -p -h -``` - -## Wichtige Befehle - -- `list`: zeigt verfuegbare lokale Templates -- ` -q`, ` --questions`: zeigt standardmaessig knappe Fragen-Uebersicht -- `refresh`: baut Shared-Template-Index unter lokalem Tempify-Datenpfad neu auf -- `process`: reicht Restargumente an eingebettetes Prebyte weiter -- `--json`: gibt Fragen als JSON aus -- `--full`: zeigt auch leere Felder wie `choices: []` oder `help: ""` -- `--answers `: laedt Fragenwerte aus JSON-Datei -- `--write-answers `: schreibt aufgeloeste Fragenwerte als JSON-Datei -- `--non-interactive`: scheitert statt fehlende Werte interaktiv zu fragen -- `--strict`: lehnt unbekannte oder ungueltige importierte Werte ab -- `--set key=value`: setzt Template-Werte direkt ueber CLI -- `-f`, `--overwrite-if-exists`: erlaubt existierendes Ziel und ersetzt kollidierende Dateien -- `-s`, `--skip-if-file-exists`: erlaubt existierendes Ziel und ueberspringt kollidierende Dateien -- `--accept-hooks yes|ask|no`: steuert, ob Template-Hooks laufen -- `--no-hooks`: Alias fuer `--accept-hooks no` -- `--hook-timeout-ms `: bricht lange Hook-Phasen nach Timeout ab, `0` deaktiviert Timeout -- `--diff`: zeigt report-only Unterschiede fuer gemanagte Dateien, Origin-Metadaten und Update-Kind an und schreibt nichts -- `--reapply`: wendet nur sichere `create`/`update`/`delete`-Aenderungen auf existierende Ziele mit passender `.tempify-lock.json` an -- `--report`: gibt mit `--reapply` nur Report aus, ohne Writes -- `reapply`: top-level Alias fuer `tempify --reapply` -- `--dry-run`, `--plan-json`: zeigt Plan ohne Dateien zu schreiben -- `--tui`: nutzt Wizard-Frontend -- `-p`, `--prebyte`: Alias fuer `process` - -Reapply-Hinweise: - -- `--reapply` braucht explizites Ziel und vorhandene `.tempify-lock.json` -- `tempify reapply ` nutzt exakt dieselbe Reapply-Logik wie Flag-Flow -- `--reapply` blockiert cross-template Reapply, wenn Lockfile-Template nicht zum angeforderten Template passt -- `--reapply` erlaubt Patch-/Prerelease-Upgrades, blockiert aber Major-Upgrades, Pre-1.0-Minor-Upgrades, Downgrades und nicht eindeutig vergleichbare Versionswechsel zur Review -- blockiert hart bei `conflict` oder `review` -- behaelt lokale Edits mit Aktion `keep` -- fuehrt in erstem Slice keine Hooks aus -- `--diff --json`, `--reapply --report --json` und `--reapply --json` zeigen Origin-Template, Origin-Version und Update-Kind -- JSON zeigt unter `update.policy` auch Action, Reason und Next-Step - -`--diff` Textausgabe enthaelt ausserdem eine direkte Handlungsempfehlung je `update.kind`. - -## Reapply JSON Beispiele - -Erfolgreiches `--reapply --json`: - -```json -{ - "status": "ok", - "mode": "reapply", - "template": { - "id": "basic_cpp", - "version": "0.1.0" - }, - "origin": { - "detected": true, - "matches_requested_template": true, - "matches_requested_version": false, - "template_id": "basic_cpp", - "template_version": "0.1.0-rc.1" - }, - "update": { - "kind": "upgrade", - "from_version": "0.1.0-rc.1", - "to_version": "0.1.0", - "policy": { - "action": "allow", - "reason": "forward_version_change", - "next_step": "Version upgrade detected. Review diff, then reapply ready actions to move managed files forward." - } - } -} -``` - -Blockiertes `--reapply --json` bei Template-Mismatch: - -```json -{ - "status": "error", - "code": "REAPPLY_BLOCKED", - "blocked": { - "conflict": [], - "review": [ - ".tempify-lock.json" - ], - "origin_mismatch": { - "lockfile": ".tempify-lock.json", - "origin_template": { - "id": "layered_cpp_product", - "version": "0.1.0" - }, - "requested_template": { - "id": "basic_cpp", - "version": "0.1.0" - } - }, - "version_transition": null - } -} -``` - -Ausfuehrlichere Beispiele: `docs/reapply.md` - -## Answers Und Config - -Answers-Dateien: - -- `--answers ` importiert Fragenwerte aus JSON -- `--write-answers ` exportiert aufgeloeste Fragenwerte fuer spaetere Replays -- `--strict` macht unbekannte oder ungueltige importierte Werte fatal - -Config-Dateien: - -- global: `$XDG_CONFIG_HOME/tempify/config.json` -- global fallback: `~/.config/tempify/config.json` -- workspace: naechste `.tempify/config.json`, aufwaerts ab aktuellem Arbeitsverzeichnis gesucht - -Aktuelles Config-Schema: - -```json -{ - "defaults": { - "project_name": "My App", - "include_ci": false - }, - "render": { - "accept_hooks": "ask", - "hook_timeout_ms": 30000, - "existing_path_behavior": "skip" - } -} -``` - -Fragenwert-Prioritaet, niedrig nach hoch: - -- Fragen-Defaults und abgeleitete Defaults -- Template-`.env` -- globale Config `defaults` -- Workspace-Config `defaults` -- `--answers` -- CLI `--set` - -Render-Prioritaet, niedrig nach hoch: - -- globale Config `render` -- Workspace-Config `render` -- explizite CLI-Flags - -Hinweise: - -- Config ist aktuell Soft-Default-Layer, kein Policy-Lockdown -- Answer-File-Werte schlagen Config-Defaults -- explizite CLI-Werte schlagen Config und Answer-File - -## Shared Template Store - -Tempify unterstuetzt zusaetzlich zu `./templates` auch einen lokalen Shared-Template-Store. - -Pfad: - -- wenn `XDG_DATA_HOME` gesetzt ist: `$XDG_DATA_HOME/tempify` -- sonst: `~/.local/share/tempify` - -Struktur: - -```txt -/ - templates/ - index/ - templates.json +rqp install rqp:tempify-cli --non-interactive +rqp install tempify basic_cpp ``` -Hinweise: +Details: [Installation](https://github.com/Coditary/Tempify/wiki/Installation) · [Releases](https://github.com/Coditary/Tempify/wiki/Releases) · [ReqPack Plugin](https://github.com/Coditary/Tempify/wiki/ReqPack-Plugin) -- Package Manager kann `templates/` und `index/templates.json` direkt verwalten -- `tempify refresh` scannt `templates/` und erzeugt `templates.json` selbst neu -- `list` zeigt Workspace-Templates und Shared-Templates zusammen an +## Template layout (minimal) -## Template-Struktur - -Ein Template besteht typischerweise aus: - -```txt +```text templates// template.lua questions.lua files/ ``` -Optional koennen je nach Template auch `layout.lua`, Hook-Skripte und weitere Lua-Dateien vorkommen. - -## Questions-Struktur +Reference templates: `tests/test_templates/` ([Example Catalog](https://github.com/Coditary/Tempify/wiki/Example-Catalog)) -`questions.lua` ist jetzt gruppiert aufgebaut: +## Development -```lua -return { - order = { "Project", "CI" }, - groups = { - Project = { - { key = "project_name", type = "string", prompt = "Project name" }, - { key = "project_slug", type = "string" }, - }, - CI = { - { key = "include_ci", type = "bool", prompt = "Include CI?" }, - }, - }, -} +```bash +make test ``` -Regeln: - -- `order` bestimmt Reihenfolge der Gruppen -- `groups` enthaelt Fragen je Gruppe -- Fragen setzen `group` nicht mehr selbst; das kommt aus `groups.` -- jede Gruppe muss in `order` stehen -- leere Gruppen sind ungueltig +Test layout and fuzzing: [Tests & Quality](https://github.com/Coditary/Tempify/wiki/Tests-and-Quality) -## Tests +ReqPack plugin checks: ```bash -ctest --test-dir build --output-on-failure +rqp test-plugin --plugin . --preset core ``` -Plugin-Checks: +## License -```bash -rqp test-plugin --plugin . --preset core -``` +See repository license file. From 9dba44f3a986a5989c82fd4a72a75e38ee60af57 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 23:28:19 +0200 Subject: [PATCH 15/29] Adding new ci and fixing sanitize bugs --- .github/workflows/ci.yml | 130 ++++++++++++ src/main/cpp/build/BuildExecutor.cpp | 52 +++-- src/main/cpp/lua/LuaHookRuntime.cpp | 305 ++++++++++++++++++--------- 3 files changed, 374 insertions(+), 113 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 259cb51..faf7d07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,136 @@ jobs: dist/*.rqp if-no-files-found: error + quality-sanitizers: + name: quality-sanitizers + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CC: clang + CXX: clang++ + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd clang clang++ + + - name: Bootstrap vcpkg dependencies + run: | + set -euo pipefail + chmod +x scripts/ci/bootstrap_vcpkg_deps.sh + ./scripts/ci/bootstrap_vcpkg_deps.sh asan + # shellcheck disable=SC1091 + source build-cmake/.vcpkg-asan.env + echo "VCPKG_ROOT=$VCPKG_ROOT" >> "$GITHUB_ENV" + echo "VCPKG_TARGET_TRIPLET=$VCPKG_TARGET_TRIPLET" >> "$GITHUB_ENV" + + - name: Run AddressSanitizer tests + run: make sanitize + + - name: Run ThreadSanitizer tests + run: make tsan + + quality-fuzz: + name: quality-fuzz + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CC: clang + CXX: clang++ + TEMPIFY_FUZZ_MAX_TOTAL_TIME: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd clang clang++ + + - name: Bootstrap vcpkg dependencies + run: | + set -euo pipefail + chmod +x scripts/ci/bootstrap_vcpkg_deps.sh + ./scripts/ci/bootstrap_vcpkg_deps.sh fuzz + # shellcheck disable=SC1091 + source build-cmake/.vcpkg-fuzz.env + echo "VCPKG_ROOT=$VCPKG_ROOT" >> "$GITHUB_ENV" + echo "VCPKG_TARGET_TRIPLET=$VCPKG_TARGET_TRIPLET" >> "$GITHUB_ENV" + + - name: Run fuzzers + run: make fuzz + + - name: Replay fuzz regression corpus + run: make fuzz-regression + + quality-coverage: + name: quality-coverage + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd g++ gcovr + + - name: Bootstrap vcpkg dependencies + run: | + set -euo pipefail + chmod +x scripts/ci/bootstrap_vcpkg_deps.sh + ./scripts/ci/bootstrap_vcpkg_deps.sh coverage + # shellcheck disable=SC1091 + source build-cmake/.vcpkg-coverage.env + echo "VCPKG_ROOT=$VCPKG_ROOT" >> "$GITHUB_ENV" + echo "VCPKG_TARGET_TRIPLET=$VCPKG_TARGET_TRIPLET" >> "$GITHUB_ENV" + + - name: Run coverage tests + run: make coverage COVERAGE_MIN_LINE=85 + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: build-cmake/coverage/coverage.xml + if-no-files-found: error + + quality-analysis: + name: quality-analysis + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CC: clang + CXX: clang++ + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd clang clang++ clang-tidy-18 clang-format-18 + + - name: Bootstrap vcpkg dependencies + run: | + set -euo pipefail + chmod +x scripts/ci/bootstrap_vcpkg_deps.sh + ./scripts/ci/bootstrap_vcpkg_deps.sh asan + # shellcheck disable=SC1091 + source build-cmake/.vcpkg-asan.env + echo "VCPKG_ROOT=$VCPKG_ROOT" >> "$GITHUB_ENV" + echo "VCPKG_TARGET_TRIPLET=$VCPKG_TARGET_TRIPLET" >> "$GITHUB_ENV" + + - name: Check formatting + run: make format-check + + - name: Run static analysis + run: make static-analysis + release: if: startsWith(github.ref, 'refs/tags/v') needs: build-test diff --git a/src/main/cpp/build/BuildExecutor.cpp b/src/main/cpp/build/BuildExecutor.cpp index bbbfd90..2c31e92 100644 --- a/src/main/cpp/build/BuildExecutor.cpp +++ b/src/main/cpp/build/BuildExecutor.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace tempify { @@ -49,6 +50,44 @@ bool should_skip_output_file(const std::filesystem::path &path, const ExistingPa return false; } +void prepare_build_root(const std::filesystem::path &build_root, const ExistingPathBehavior behavior) { + if (std::filesystem::exists(build_root)) { + if (std::filesystem::is_directory(build_root)) { + if (behavior == ExistingPathBehavior::Error) { + throw TempifyError("Target path already exists and overwrite is disabled: " + build_root.string()); + } + return; + } + + if (behavior == ExistingPathBehavior::Overwrite) { + std::filesystem::remove(build_root); + } else { + throw TempifyError("Target path exists as file and cannot be used as directory: " + build_root.string()); + } + } + + if (behavior != ExistingPathBehavior::Error) { + return; + } + + const std::filesystem::path parent = build_root.parent_path(); + if (!parent.empty()) { + std::filesystem::create_directories(parent); + } + + std::error_code error; + if (!std::filesystem::create_directory(build_root, error)) { + if (error == std::errc::file_exists || std::filesystem::exists(build_root)) { + if (std::filesystem::is_directory(build_root)) { + throw TempifyError("Target path already exists and overwrite is disabled: " + build_root.string()); + } + throw TempifyError("Target path exists as file and cannot be used as directory: " + build_root.string()); + } + + throw TempifyError("Could not create build root '" + build_root.string() + "': " + error.message()); + } +} + void run_hook_phase(const LuaEngine &lua_engine, const std::optional &hook_path, const char *phase_name, const TemplateManifest &manifest, const BuildContext &context, const PrebyteRenderer &renderer, const std::optional timeout) { @@ -72,18 +111,7 @@ BuildExecutor::BuildExecutor(const PrebyteRenderer &renderer, const LuaEngine &l void BuildExecutor::execute(const BuildPlan &plan, const TemplateManifest &manifest, const std::map &values, const bool disable_hooks, const std::optional hook_timeout) const { - if (std::filesystem::exists(plan.build_root)) { - if (std::filesystem::is_directory(plan.build_root)) { - if (plan.existing_path_behavior == ExistingPathBehavior::Error) { - throw TempifyError("Target path already exists and overwrite is disabled: " + plan.build_root.string()); - } - } else if (plan.existing_path_behavior == ExistingPathBehavior::Overwrite) { - std::filesystem::remove(plan.build_root); - } else { - throw TempifyError("Target path exists as file and cannot be used as directory: " + - plan.build_root.string()); - } - } + prepare_build_root(plan.build_root, plan.existing_path_behavior); const BuildContext context{ .template_root = manifest.root, diff --git a/src/main/cpp/lua/LuaHookRuntime.cpp b/src/main/cpp/lua/LuaHookRuntime.cpp index 7d6ee18..fa48383 100644 --- a/src/main/cpp/lua/LuaHookRuntime.cpp +++ b/src/main/cpp/lua/LuaHookRuntime.cpp @@ -28,8 +28,13 @@ struct HookHost { const PrebyteRenderer *renderer = nullptr; std::optional deadline; std::optional timeout; + mutable std::string last_error; }; +int report_hook_failure(lua_State *state, const HookHost &host) { + return luaL_error(state, "%s", host.last_error.c_str()); +} + constexpr char kHookHostRegistryKey = '\0'; HookHost *hook_host(lua_State *state) { @@ -119,135 +124,250 @@ bool path_within_root(const std::filesystem::path &path, const std::filesystem:: return path_starts_with(path.lexically_normal(), root.lexically_normal()); } -void ensure_relative_path_has_no_parent_segments(lua_State *state, const std::filesystem::path &path) { +void set_hook_error(HookHost &host, std::string message) { + host.last_error = std::move(message); +} + +bool relative_path_has_parent_segments(const std::filesystem::path &path) { for (const auto &part : path.lexically_normal()) { if (part == "..") { - const std::string path_text = path.string(); - luaL_error(state, "Hook path escapes allowed roots: %s", path_text.c_str()); + return true; } } + return false; } -std::filesystem::path resolve_input_path(lua_State *state, const HookHost &host, const std::filesystem::path &path) { - if (path.empty()) { - luaL_error(state, "Hook path must be non-empty"); +bool try_resolve_input_path(const HookHost &host, const char *raw_path, std::filesystem::path &resolved) { + if (raw_path == nullptr || raw_path[0] == '\0') { + set_hook_error(const_cast(host), "Hook path must be non-empty"); + return false; } + const std::filesystem::path path(raw_path); if (path.is_absolute()) { const std::filesystem::path normalized = path.lexically_normal(); if (path_within_root(normalized, host.context->build_root) || path_within_root(normalized, host.manifest->root)) { - return normalized; + resolved = normalized; + return true; } - const std::string path_text = path.string(); - luaL_error(state, "Hook path escapes allowed roots: %s", path_text.c_str()); + set_hook_error(const_cast(host), "Hook path escapes allowed roots: " + path.string()); + return false; } - ensure_relative_path_has_no_parent_segments(state, path); + if (relative_path_has_parent_segments(path)) { + set_hook_error(const_cast(host), "Hook path escapes allowed roots: " + path.string()); + return false; + } const std::filesystem::path build_candidate = (host.context->build_root / path).lexically_normal(); if (!path_within_root(build_candidate, host.context->build_root)) { - const std::string path_text = path.string(); - luaL_error(state, "Hook path escapes build root: %s", path_text.c_str()); + set_hook_error(const_cast(host), "Hook path escapes build root: " + path.string()); + return false; } if (std::filesystem::exists(build_candidate)) { - return build_candidate; + resolved = build_candidate; + return true; } const std::filesystem::path template_candidate = (host.manifest->root / path).lexically_normal(); if (!path_within_root(template_candidate, host.manifest->root)) { - const std::string path_text = path.string(); - luaL_error(state, "Hook path escapes template root: %s", path_text.c_str()); + set_hook_error(const_cast(host), "Hook path escapes template root: " + path.string()); + return false; } if (std::filesystem::exists(template_candidate)) { - return template_candidate; + resolved = template_candidate; + return true; } - return build_candidate; + resolved = build_candidate; + return true; } -std::filesystem::path resolve_output_path(lua_State *state, const HookHost &host, const std::filesystem::path &path) { - if (path.empty()) { - luaL_error(state, "Hook path must be non-empty"); +bool try_resolve_output_path(const HookHost &host, const char *raw_path, std::filesystem::path &resolved) { + if (raw_path == nullptr || raw_path[0] == '\0') { + set_hook_error(const_cast(host), "Hook path must be non-empty"); + return false; } - if (!path.is_absolute()) { - ensure_relative_path_has_no_parent_segments(state, path); + const std::filesystem::path path(raw_path); + if (!path.is_absolute() && relative_path_has_parent_segments(path)) { + set_hook_error(const_cast(host), "Hook path escapes allowed roots: " + path.string()); + return false; } - const std::filesystem::path resolved = - path.is_absolute() ? path.lexically_normal() : (host.context->build_root / path).lexically_normal(); + resolved = path.is_absolute() ? path.lexically_normal() : (host.context->build_root / path).lexically_normal(); if (!path_within_root(resolved, host.context->build_root)) { - const std::string path_text = path.string(); - luaL_error(state, "Hook path escapes build root: %s", path_text.c_str()); + set_hook_error(const_cast(host), "Hook path escapes build root: " + path.string()); + return false; + } + + return true; +} + +bool mkdir_impl(HookHost &host, const char *raw_path, std::string &created_path) { + std::filesystem::path path; + if (!try_resolve_output_path(host, raw_path, path)) { + return false; + } + std::filesystem::create_directories(path); + created_path = path.string(); + return true; +} + +bool remove_impl(HookHost &host, const char *raw_path, std::uintmax_t &removed_count) { + std::filesystem::path path; + if (!try_resolve_output_path(host, raw_path, path)) { + return false; + } + removed_count = std::filesystem::remove_all(path); + return true; +} + +bool exists_impl(HookHost &host, const char *raw_path, bool &exists) { + std::filesystem::path path; + if (!try_resolve_input_path(host, raw_path, path)) { + return false; + } + exists = std::filesystem::exists(path); + return true; +} + +bool write_file_impl(HookHost &host, const char *raw_path, const char *content, std::string &written_path) { + std::filesystem::path path; + if (!try_resolve_output_path(host, raw_path, path)) { + return false; + } + + std::filesystem::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary); + if (!output) { + set_hook_error(host, "Could not write file: " + path.string()); + return false; + } + output << content; + written_path = path.string(); + return true; +} + +bool read_file_impl(HookHost &host, const char *raw_path, std::string &content) { + std::filesystem::path path; + if (!try_resolve_input_path(host, raw_path, path)) { + return false; + } + + std::ifstream input(path, std::ios::binary); + if (!input) { + set_hook_error(host, "Could not read file: " + path.string()); + return false; + } + std::ostringstream stream; + stream << input.rdbuf(); + content = stream.str(); + return true; +} + +bool copy_impl(HookHost &host, const char *raw_source, const char *raw_target, std::string &target_path) { + std::filesystem::path source; + std::filesystem::path target; + if (!try_resolve_input_path(host, raw_source, source)) { + return false; + } + if (!try_resolve_output_path(host, raw_target, target)) { + return false; + } + + std::filesystem::create_directories(target.parent_path()); + if (std::filesystem::is_directory(source)) { + std::filesystem::copy(source, target, + std::filesystem::copy_options::recursive | + std::filesystem::copy_options::overwrite_existing); + } else { + std::filesystem::copy_file(source, target, std::filesystem::copy_options::overwrite_existing); } - return resolved; + target_path = target.string(); + return true; +} + +bool process_file_impl(HookHost &host, const char *raw_input, const char *raw_output, + const std::map &overrides, std::string &output_path) { + std::filesystem::path input; + std::filesystem::path output; + if (!try_resolve_input_path(host, raw_input, input)) { + return false; + } + if (!try_resolve_output_path(host, raw_output, output)) { + return false; + } + + std::map values = host.context->values; + values.insert(overrides.begin(), overrides.end()); + + std::filesystem::create_directories(output.parent_path()); + prebyte::Prebyte engine; + host.renderer->configure(engine, values, *host.manifest); + host.renderer->render_file(engine, input, output); + output_path = output.string(); + return true; } int hook_exists(lua_State *state) { - const HookHost &host = *hook_host(state); - const std::filesystem::path path = - resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); - lua_pushboolean(state, std::filesystem::exists(path)); + HookHost &host = *hook_host(state); + bool exists = false; + if (!exists_impl(host, luaL_checkstring(state, 1), exists)) { + return report_hook_failure(state, host); + } + lua_pushboolean(state, exists); return 1; } int hook_mkdir(lua_State *state) { - const HookHost &host = *hook_host(state); - const std::filesystem::path path = - resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); - std::filesystem::create_directories(path); - const std::string path_text = path.string(); - lua_pushlstring(state, path_text.c_str(), path_text.size()); + HookHost &host = *hook_host(state); + std::string created_path; + if (!mkdir_impl(host, luaL_checkstring(state, 1), created_path)) { + return report_hook_failure(state, host); + } + lua_pushlstring(state, created_path.c_str(), created_path.size()); return 1; } int hook_remove(lua_State *state) { - const HookHost &host = *hook_host(state); - const std::filesystem::path path = - resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); - lua_pushinteger(state, static_cast(std::filesystem::remove_all(path))); + HookHost &host = *hook_host(state); + std::uintmax_t removed_count = 0; + if (!remove_impl(host, luaL_checkstring(state, 1), removed_count)) { + return report_hook_failure(state, host); + } + lua_pushinteger(state, static_cast(removed_count)); return 1; } int hook_read_file(lua_State *state) { - const HookHost &host = *hook_host(state); - const std::filesystem::path path = - resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); - std::ifstream input(path, std::ios::binary); - if (!input) { - const std::string path_text = path.string(); - return luaL_error(state, "Could not read file: %s", path_text.c_str()); + HookHost &host = *hook_host(state); + std::string content; + if (!read_file_impl(host, luaL_checkstring(state, 1), content)) { + return report_hook_failure(state, host); } - std::ostringstream stream; - stream << input.rdbuf(); - const std::string content = stream.str(); lua_pushlstring(state, content.c_str(), content.size()); return 1; } int hook_write_file(lua_State *state) { - const HookHost &host = *hook_host(state); - const std::filesystem::path path = - resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); - const std::string content = luaL_checkstring(state, 2); - std::filesystem::create_directories(path.parent_path()); - std::ofstream output(path, std::ios::binary); - if (!output) { - const std::string path_text = path.string(); - return luaL_error(state, "Could not write file: %s", path_text.c_str()); + HookHost &host = *hook_host(state); + std::string written_path; + if (!write_file_impl(host, luaL_checkstring(state, 1), luaL_checkstring(state, 2), written_path)) { + return report_hook_failure(state, host); } - output << content; - const std::string path_text = path.string(); - lua_pushlstring(state, path_text.c_str(), path_text.size()); + lua_pushlstring(state, written_path.c_str(), written_path.size()); return 1; } int hook_list_files(lua_State *state) { - const HookHost &host = *hook_host(state); - const std::filesystem::path path = - resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); + HookHost &host = *hook_host(state); + std::filesystem::path path; + if (!try_resolve_input_path(host, luaL_checkstring(state, 1), path)) { + return report_hook_failure(state, host); + } lua_newtable(state); if (!std::filesystem::is_directory(path)) { return 1; @@ -265,9 +385,11 @@ int hook_list_files(lua_State *state) { } int hook_list_dirs(lua_State *state) { - const HookHost &host = *hook_host(state); - const std::filesystem::path path = - resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); + HookHost &host = *hook_host(state); + std::filesystem::path path; + if (!try_resolve_input_path(host, luaL_checkstring(state, 1), path)) { + return report_hook_failure(state, host); + } lua_newtable(state); if (!std::filesystem::is_directory(path)) { return 1; @@ -285,23 +407,12 @@ int hook_list_dirs(lua_State *state) { } int hook_copy(lua_State *state) { - const HookHost &host = *hook_host(state); - const std::filesystem::path source = - resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); - const std::filesystem::path target = - resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 2))); - - std::filesystem::create_directories(target.parent_path()); - if (std::filesystem::is_directory(source)) { - std::filesystem::copy(source, target, - std::filesystem::copy_options::recursive | - std::filesystem::copy_options::overwrite_existing); - } else { - std::filesystem::copy_file(source, target, std::filesystem::copy_options::overwrite_existing); + HookHost &host = *hook_host(state); + std::string target_path; + if (!copy_impl(host, luaL_checkstring(state, 1), luaL_checkstring(state, 2), target_path)) { + return report_hook_failure(state, host); } - - const std::string target_text = target.string(); - lua_pushlstring(state, target_text.c_str(), target_text.size()); + lua_pushlstring(state, target_path.c_str(), target_path.size()); return 1; } @@ -324,25 +435,17 @@ int hook_process_string(lua_State *state) { } int hook_process_file(lua_State *state) { - const HookHost &host = *hook_host(state); - const std::filesystem::path input = - resolve_input_path(state, host, std::filesystem::path(luaL_checkstring(state, 1))); - const std::filesystem::path output = - resolve_output_path(state, host, std::filesystem::path(luaL_checkstring(state, 2))); - std::map values = host.context->values; + HookHost &host = *hook_host(state); + std::map overrides; if (lua_istable(state, 3) != 0) { - const auto overrides = lua_internal::table_to_string_map(state, 3); - for (const auto &[key, value] : overrides) { - values[key] = value; - } + overrides = lua_internal::table_to_string_map(state, 3); } - std::filesystem::create_directories(output.parent_path()); - prebyte::Prebyte engine; - host.renderer->configure(engine, values, *host.manifest); - host.renderer->render_file(engine, input, output); - const std::string output_text = output.string(); - lua_pushlstring(state, output_text.c_str(), output_text.size()); + std::string output_path; + if (!process_file_impl(host, luaL_checkstring(state, 1), luaL_checkstring(state, 2), overrides, output_path)) { + return report_hook_failure(state, host); + } + lua_pushlstring(state, output_path.c_str(), output_path.size()); return 1; } From 6c418f57b0a4ce3013b5bc604ed4adeb2bfc2920 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 23:41:25 +0200 Subject: [PATCH 16/29] Fixing ci --- .github/workflows/ci.yml | 2 ++ .gitmodules | 6 ++++++ 2 files changed, 8 insertions(+) create mode 100644 .gitmodules diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faf7d07..c29aed2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + submodules: recursive - name: Install Linux prerequisites if: runner.os == 'Linux' diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..40f5e35 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "tempify-registry"] + path = tempify-registry + url = https://github.com/Coditary/tempify-registry.git +[submodule "tempify-templates"] + path = tempify-templates + url = https://github.com/Coditary/tempify-templates.git From 9f8028d329c2a6c83ae081f14609f06b4c5fcb05 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 23:44:42 +0200 Subject: [PATCH 17/29] Try to Fix CI --- .github/workflows/ci.yml | 2 -- .gitmodules | 6 ----- .reqpack-test/core/install-java-xyz.lua | 1 + .../fixtures/catalogs/java_xyz/templates.json | 17 ++++++++++++ .../repos/java_xyz/files/README.md.pbt | 6 +++++ .../fixtures/repos/java_xyz/questions.lua | 27 +++++++++++++++++++ .../fixtures/repos/java_xyz/template.lua | 11 ++++++++ tempify-registry | 1 - tempify-registry/README.md | 9 +++++++ tempify-registry/templates.json | 17 ++++++++++++ tempify-templates | 1 - .../java-xyz/files/README.md.pbt | 6 +++++ tempify-templates/java-xyz/questions.lua | 27 +++++++++++++++++++ tempify-templates/java-xyz/template.lua | 11 ++++++++ 14 files changed, 132 insertions(+), 10 deletions(-) delete mode 100644 .gitmodules create mode 100644 .reqpack-test/fixtures/catalogs/java_xyz/templates.json create mode 100644 .reqpack-test/fixtures/repos/java_xyz/files/README.md.pbt create mode 100644 .reqpack-test/fixtures/repos/java_xyz/questions.lua create mode 100644 .reqpack-test/fixtures/repos/java_xyz/template.lua delete mode 160000 tempify-registry create mode 100644 tempify-registry/README.md create mode 100644 tempify-registry/templates.json delete mode 160000 tempify-templates create mode 100644 tempify-templates/java-xyz/files/README.md.pbt create mode 100644 tempify-templates/java-xyz/questions.lua create mode 100644 tempify-templates/java-xyz/template.lua diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c29aed2..faf7d07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,8 +67,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - submodules: recursive - name: Install Linux prerequisites if: runner.os == 'Linux' diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 40f5e35..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "tempify-registry"] - path = tempify-registry - url = https://github.com/Coditary/tempify-registry.git -[submodule "tempify-templates"] - path = tempify-templates - url = https://github.com/Coditary/tempify-templates.git diff --git a/.reqpack-test/core/install-java-xyz.lua b/.reqpack-test/core/install-java-xyz.lua index 375f24c..61be160 100644 --- a/.reqpack-test/core/install-java-xyz.lua +++ b/.reqpack-test/core/install-java-xyz.lua @@ -5,6 +5,7 @@ return { system = "tempify", flags = { "data-root=@tmp/store", + "repo=tempify-default|0|.reqpack-test/fixtures/catalogs/java_xyz/templates.json", }, packages = { { name = "java-xyz" } diff --git a/.reqpack-test/fixtures/catalogs/java_xyz/templates.json b/.reqpack-test/fixtures/catalogs/java_xyz/templates.json new file mode 100644 index 0000000..b52e1a6 --- /dev/null +++ b/.reqpack-test/fixtures/catalogs/java_xyz/templates.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "templates": [ + { + "id": "java-xyz", + "name": "Java XYZ", + "description": "Small Java starter template", + "version": "0.1.0", + "tags": ["java", "starter"], + "source": { + "type": "git", + "url": "../../repos/java_xyz", + "subdir": "" + } + } + ] +} diff --git a/.reqpack-test/fixtures/repos/java_xyz/files/README.md.pbt b/.reqpack-test/fixtures/repos/java_xyz/files/README.md.pbt new file mode 100644 index 0000000..d3fa4b1 --- /dev/null +++ b/.reqpack-test/fixtures/repos/java_xyz/files/README.md.pbt @@ -0,0 +1,6 @@ +# {{ project_name }} + +Generated from Tempify `java-xyz` starter template. + +- Project slug: `{{ project_slug }}` +- Java package: `{{ package_name }}` diff --git a/.reqpack-test/fixtures/repos/java_xyz/questions.lua b/.reqpack-test/fixtures/repos/java_xyz/questions.lua new file mode 100644 index 0000000..382f38f --- /dev/null +++ b/.reqpack-test/fixtures/repos/java_xyz/questions.lua @@ -0,0 +1,27 @@ +return { + order = { "Project" }, + groups = { + Project = { + { + key = "project_name", + type = "string", + prompt = "Project name", + default = "Java XYZ App", + }, + { + key = "project_slug", + type = "string", + prompt = "Project slug", + default = function(ctx) + return slugify(ctx.values.project_name or "java-xyz-app") + end, + }, + { + key = "package_name", + type = "string", + prompt = "Java package", + default = "com.example.app", + }, + }, + }, +} diff --git a/.reqpack-test/fixtures/repos/java_xyz/template.lua b/.reqpack-test/fixtures/repos/java_xyz/template.lua new file mode 100644 index 0000000..cc9067c --- /dev/null +++ b/.reqpack-test/fixtures/repos/java_xyz/template.lua @@ -0,0 +1,11 @@ +return { + id = "java-xyz", + name = "Java XYZ", + version = "0.1.0", + description = "Small Java starter template", + source_dir = "files", + output = { + path = "{{ project_slug }}", + overwrite = false, + }, +} diff --git a/tempify-registry b/tempify-registry deleted file mode 160000 index 201dc5b..0000000 --- a/tempify-registry +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 201dc5b7ce07e4c68c161345ab87c0366dd1cec6 diff --git a/tempify-registry/README.md b/tempify-registry/README.md new file mode 100644 index 0000000..7b098d3 --- /dev/null +++ b/tempify-registry/README.md @@ -0,0 +1,9 @@ +# Tempify Registry + +Small starter registry for Tempify templates. + +Current catalog file: + +- `templates.json` + +Local development expects `source.url` entries to point at sibling `../tempify-templates` repo. diff --git a/tempify-registry/templates.json b/tempify-registry/templates.json new file mode 100644 index 0000000..6e3527b --- /dev/null +++ b/tempify-registry/templates.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "templates": [ + { + "id": "java-xyz", + "name": "Java XYZ", + "description": "Small Java starter template", + "version": "0.1.0", + "tags": ["java", "starter"], + "source": { + "type": "git", + "url": "../tempify-templates", + "subdir": "java-xyz" + } + } + ] +} diff --git a/tempify-templates b/tempify-templates deleted file mode 160000 index 4fb0b80..0000000 --- a/tempify-templates +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4fb0b809eb9862cba36e872a6bf3467dbc1c6c17 diff --git a/tempify-templates/java-xyz/files/README.md.pbt b/tempify-templates/java-xyz/files/README.md.pbt new file mode 100644 index 0000000..d3fa4b1 --- /dev/null +++ b/tempify-templates/java-xyz/files/README.md.pbt @@ -0,0 +1,6 @@ +# {{ project_name }} + +Generated from Tempify `java-xyz` starter template. + +- Project slug: `{{ project_slug }}` +- Java package: `{{ package_name }}` diff --git a/tempify-templates/java-xyz/questions.lua b/tempify-templates/java-xyz/questions.lua new file mode 100644 index 0000000..382f38f --- /dev/null +++ b/tempify-templates/java-xyz/questions.lua @@ -0,0 +1,27 @@ +return { + order = { "Project" }, + groups = { + Project = { + { + key = "project_name", + type = "string", + prompt = "Project name", + default = "Java XYZ App", + }, + { + key = "project_slug", + type = "string", + prompt = "Project slug", + default = function(ctx) + return slugify(ctx.values.project_name or "java-xyz-app") + end, + }, + { + key = "package_name", + type = "string", + prompt = "Java package", + default = "com.example.app", + }, + }, + }, +} diff --git a/tempify-templates/java-xyz/template.lua b/tempify-templates/java-xyz/template.lua new file mode 100644 index 0000000..cc9067c --- /dev/null +++ b/tempify-templates/java-xyz/template.lua @@ -0,0 +1,11 @@ +return { + id = "java-xyz", + name = "Java XYZ", + version = "0.1.0", + description = "Small Java starter template", + source_dir = "files", + output = { + path = "{{ project_slug }}", + overwrite = false, + }, +} From fd0bd2db01ca926fae8c5f1b32a3b3fea07f5732 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sat, 22 Aug 2026 23:51:21 +0200 Subject: [PATCH 18/29] Try to fix CI for sanitizer --- .github/workflows/ci.yml | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faf7d07..ea44ffc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,8 +189,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 60 env: - CC: clang - CXX: clang++ + CC: clang-18 + CXX: clang++-18 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -198,7 +198,10 @@ jobs: - name: Install Linux prerequisites run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd clang clang++ + sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd clang-18 + sudo ln -sf "$(command -v clang-18)" /usr/local/bin/clang + sudo ln -sf "$(command -v clang++-18)" /usr/local/bin/clang++ + echo "/usr/local/bin" >> "$GITHUB_PATH" - name: Bootstrap vcpkg dependencies run: | @@ -221,8 +224,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 45 env: - CC: clang - CXX: clang++ + CC: clang-18 + CXX: clang++-18 TEMPIFY_FUZZ_MAX_TOTAL_TIME: 30 steps: - name: Checkout repository @@ -231,7 +234,10 @@ jobs: - name: Install Linux prerequisites run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd clang clang++ + sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd clang-18 + sudo ln -sf "$(command -v clang-18)" /usr/local/bin/clang + sudo ln -sf "$(command -v clang++-18)" /usr/local/bin/clang++ + echo "/usr/local/bin" >> "$GITHUB_PATH" - name: Bootstrap vcpkg dependencies run: | @@ -287,8 +293,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 45 env: - CC: clang - CXX: clang++ + CC: clang-18 + CXX: clang++-18 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -296,7 +302,10 @@ jobs: - name: Install Linux prerequisites run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd clang clang++ clang-tidy-18 clang-format-18 + sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd clang-18 clang-tidy-18 clang-format-18 + sudo ln -sf "$(command -v clang-18)" /usr/local/bin/clang + sudo ln -sf "$(command -v clang++-18)" /usr/local/bin/clang++ + echo "/usr/local/bin" >> "$GITHUB_PATH" - name: Bootstrap vcpkg dependencies run: | From 5257f8c0bc5ce0c87d9ca602ba9df6e1ec7069d1 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 00:14:54 +0200 Subject: [PATCH 19/29] Update for more coverage --- tests/support/CliProcess.cpp | 39 +++--- tests/support/CliProcess.h | 3 +- tests/support/E2ETestSupport.h | 2 +- tests/support/TempifyTestSupport.h | 6 +- tests/tempify/AnswerFileTests.cpp | 70 ++++++++-- tests/tempify/BuildExecutorTests.cpp | 68 ++++++++++ .../tempify/CorruptInputIntegrationTests.cpp | 44 +++---- tests/tempify/DirectoryStructureTests.cpp | 56 ++++---- tests/tempify/EnvLoaderTests.cpp | 35 ++--- tests/tempify/GenerationLockTests.cpp | 53 ++++---- tests/tempify/LuaEngineHookSandboxTests.cpp | 54 +++++++- tests/tempify/QuestionFrontendTests.cpp | 122 ++++++++++++++++++ .../SharedTemplateStoreIntegrationTests.cpp | 3 +- tests/tempify/ShellCompletionTests.cpp | 7 +- tests/tempify/SlugTests.cpp | 10 ++ .../TempifyAppConfigPlanIntegrationTests.cpp | 42 +++--- .../TempifyAppReapplyIntegrationTests.cpp | 40 ++++-- tests/tempify/TemplateInspectorTests.cpp | 6 +- 18 files changed, 473 insertions(+), 187 deletions(-) create mode 100644 tests/tempify/QuestionFrontendTests.cpp create mode 100644 tests/tempify/SlugTests.cpp diff --git a/tests/support/CliProcess.cpp b/tests/support/CliProcess.cpp index 3cd0267..25b5675 100644 --- a/tests/support/CliProcess.cpp +++ b/tests/support/CliProcess.cpp @@ -47,8 +47,7 @@ ProcessResult run_cli_posix(const std::filesystem::path &executable, const std:: int stdout_pipe[2]{-1, -1}; int stderr_pipe[2]{-1, -1}; int stdin_pipe[2]{-1, -1}; - if (::pipe(stdout_pipe) != 0 || ::pipe(stderr_pipe) != 0 - || (!stdin_text.empty() && ::pipe(stdin_pipe) != 0)) { + if (::pipe(stdout_pipe) != 0 || ::pipe(stderr_pipe) != 0 || (!stdin_text.empty() && ::pipe(stdin_pipe) != 0)) { throw std::runtime_error("failed to create subprocess pipes"); } @@ -132,8 +131,7 @@ std::wstring to_wide(const std::string &text) { if (text.empty()) { return {}; } - const int required = - ::MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(text.size()), nullptr, 0); + const int required = ::MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(text.size()), nullptr, 0); if (required <= 0) { throw std::runtime_error("failed to convert text to wide string"); } @@ -183,8 +181,7 @@ std::string quote_windows_argument(const std::string &arg) { return quoted; } -std::wstring build_windows_command_line(const std::filesystem::path &executable, - const std::vector &args) { +std::wstring build_windows_command_line(const std::filesystem::path &executable, const std::vector &args) { std::string command = quote_windows_argument(executable.string()); for (const std::string &arg : args) { command.push_back(' '); @@ -206,9 +203,9 @@ ProcessResult run_cli_windows(const std::filesystem::path &executable, const std HANDLE stderr_write = nullptr; HANDLE stdin_read = nullptr; HANDLE stdin_write = nullptr; - if (!::CreatePipe(&stdout_read, &stdout_write, &security_attributes, 0) - || !::CreatePipe(&stderr_read, &stderr_write, &security_attributes, 0) - || (!stdin_text.empty() && !::CreatePipe(&stdin_read, &stdin_write, &security_attributes, 0))) { + if (!::CreatePipe(&stdout_read, &stdout_write, &security_attributes, 0) || + !::CreatePipe(&stderr_read, &stderr_write, &security_attributes, 0) || + (!stdin_text.empty() && !::CreatePipe(&stdin_read, &stdin_write, &security_attributes, 0))) { throw std::runtime_error("failed to create subprocess pipes"); } @@ -239,17 +236,11 @@ ProcessResult run_cli_windows(const std::filesystem::path &executable, const std environment_block.push_back(L'\0'); } - const BOOL created = ::CreateProcessW( - to_wide(executable.string()).c_str(), - mutable_command_line.data(), - nullptr, - nullptr, - TRUE, - 0, - environment_block.empty() ? nullptr : environment_block.data(), - working_directory.empty() ? nullptr : to_wide(working_directory.string()).c_str(), - &startup_info, - &process_info); + const BOOL created = + ::CreateProcessW(to_wide(executable.string()).c_str(), mutable_command_line.data(), nullptr, nullptr, TRUE, 0, + environment_block.empty() ? nullptr : environment_block.data(), + working_directory.empty() ? nullptr : to_wide(working_directory.string()).c_str(), + &startup_info, &process_info); ::CloseHandle(stdout_write); ::CloseHandle(stderr_write); if (!stdin_text.empty()) { @@ -280,16 +271,16 @@ ProcessResult run_cli_windows(const std::filesystem::path &executable, const std std::array buffer{}; while (true) { DWORD bytes_read = 0; - if (!::ReadFile(stdout_read, buffer.data(), static_cast(buffer.size()), &bytes_read, nullptr) - || bytes_read == 0) { + if (!::ReadFile(stdout_read, buffer.data(), static_cast(buffer.size()), &bytes_read, nullptr) || + bytes_read == 0) { break; } result.stdout_text.append(buffer.data(), bytes_read); } while (true) { DWORD bytes_read = 0; - if (!::ReadFile(stderr_read, buffer.data(), static_cast(buffer.size()), &bytes_read, nullptr) - || bytes_read == 0) { + if (!::ReadFile(stderr_read, buffer.data(), static_cast(buffer.size()), &bytes_read, nullptr) || + bytes_read == 0) { break; } result.stderr_text.append(buffer.data(), bytes_read); diff --git a/tests/support/CliProcess.h b/tests/support/CliProcess.h index 1161cd2..025106d 100644 --- a/tests/support/CliProcess.h +++ b/tests/support/CliProcess.h @@ -19,7 +19,6 @@ std::filesystem::path cli_working_directory(); ProcessResult run_cli(const std::vector &args, const std::filesystem::path &working_directory = cli_working_directory(), - const std::map &extra_env = {}, - const std::string &stdin_text = {}); + const std::map &extra_env = {}, const std::string &stdin_text = {}); } // namespace tempify::test diff --git a/tests/support/E2ETestSupport.h b/tests/support/E2ETestSupport.h index 058eb89..a8c7497 100644 --- a/tests/support/E2ETestSupport.h +++ b/tests/support/E2ETestSupport.h @@ -15,7 +15,7 @@ inline std::string slug_to_namespace(const std::string &slug) { } inline std::map isolated_cli_env(const std::filesystem::path &data_home, - const std::filesystem::path &config_home = {}) { + const std::filesystem::path &config_home = {}) { std::map env; env["XDG_DATA_HOME"] = data_home.string(); if (!config_home.empty()) { diff --git a/tests/support/TempifyTestSupport.h b/tests/support/TempifyTestSupport.h index d4769eb..163b6aa 100644 --- a/tests/support/TempifyTestSupport.h +++ b/tests/support/TempifyTestSupport.h @@ -1,11 +1,10 @@ #pragma once #include "TestHarness.h" - #include "tempify/build/GenerationLock.h" -#include #include +#include #include #include #include @@ -487,8 +486,7 @@ inline void inject_stale_managed_file(const std::filesystem::path &target, const if (files_close_pos == std::string::npos) { throw std::runtime_error("managed_files section not found in lock file"); } - lock_text.insert(files_close_pos, - ",\n \"" + json_escape(relative_path) + "\""); + lock_text.insert(files_close_pos, ",\n \"" + json_escape(relative_path) + "\""); const std::string hashes_close = " },\n \"values\""; const std::size_t hashes_close_pos = lock_text.find(hashes_close); diff --git a/tests/tempify/AnswerFileTests.cpp b/tests/tempify/AnswerFileTests.cpp index 0e6c58c..c8e63ce 100644 --- a/tests/tempify/AnswerFileTests.cpp +++ b/tests/tempify/AnswerFileTests.cpp @@ -1,6 +1,7 @@ #include "TempifyTestSupport.h" #include "TestHarness.h" #include "tempify/question/AnswerFile.h" +#include "tempify/support/Errors.h" #include #include @@ -13,8 +14,7 @@ using tempify::test_support::write_text_file; } // namespace TEST_CASE(AnswerFile_roundtrip_preserves_scalar_values) { - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "tempify-answer-file-roundtrip.json"; + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-answer-file-roundtrip.json"; const std::map original = { {"project_name", "Roundtrip App"}, {"include_ci", "true"}, @@ -30,15 +30,67 @@ TEST_CASE(AnswerFile_roundtrip_preserves_scalar_values) { } TEST_CASE(AnswerFile_loads_bool_and_number_scalars) { - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "tempify-answer-file-scalars.json"; - write_text_file(path, - "{\n" - " \"enabled\": false,\n" - " \"retries\": 2\n" - "}\n"); + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-answer-file-scalars.json"; + write_text_file(path, "{\n" + " \"enabled\": false,\n" + " \"retries\": 2\n" + "}\n"); const std::map loaded = tempify::load_answer_file(path, false); REQUIRE_EQ(loaded.at("enabled"), std::string("false")); REQUIRE_EQ(loaded.at("retries"), std::string("2")); } + +TEST_CASE(AnswerFile_roundtrip_escapes_special_characters) { + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-answer-file-escape.json"; + const std::map original = { + {"quoted", "say \"hi\""}, + {"multiline", "line1\nline2"}, + {"tabbed", "a\tb"}, + {"backslash", "path\\to\\file"}, + }; + tempify::write_answer_file(path, original); + + const std::map loaded = tempify::load_answer_file(path, false); + REQUIRE_EQ(loaded.at("quoted"), std::string("say \"hi\"")); + REQUIRE_EQ(loaded.at("multiline"), std::string("line1\nline2")); + REQUIRE_EQ(loaded.at("tabbed"), std::string("a\tb")); + REQUIRE_EQ(loaded.at("backslash"), std::string("path\\to\\file")); + + const std::string serialized = read_text_file(path); + REQUIRE(serialized.find("\\\"hi\\\"") != std::string::npos); + REQUIRE(serialized.find("\\n") != std::string::npos); + REQUIRE(serialized.find("\\t") != std::string::npos); +} + +TEST_CASE(AnswerFile_loads_double_scalar_values) { + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-answer-file-double.json"; + write_text_file(path, "{\n" + " \"ratio\": 1.5\n" + "}\n"); + + const std::map loaded = tempify::load_answer_file(path, false); + REQUIRE_EQ(loaded.at("ratio"), std::string("1.500000")); +} + +TEST_CASE(AnswerFile_missing_file_throws) { + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-answer-file-missing.json"; + REQUIRE_THROWS_AS(tempify::load_answer_file(path, false), tempify::TempifyError); +} + +TEST_CASE(AnswerFile_rejects_invalid_json_and_non_object_payloads) { + const std::filesystem::path invalid_json_path = + std::filesystem::temp_directory_path() / "tempify-answer-file-invalid.json"; + write_text_file(invalid_json_path, "{not-json"); + + const std::filesystem::path array_path = std::filesystem::temp_directory_path() / "tempify-answer-file-array.json"; + write_text_file(array_path, "[]\n"); + + const std::filesystem::path nested_path = + std::filesystem::temp_directory_path() / "tempify-answer-file-nested.json"; + write_text_file(nested_path, "{\n \"nested\": {\"a\": 1}\n}\n"); + + REQUIRE_THROWS_AS(tempify::load_answer_file(invalid_json_path, false), tempify::TempifyError); + REQUIRE_THROWS_AS(tempify::load_answer_file(array_path, false), tempify::TempifyError); + REQUIRE_THROWS_AS(tempify::load_answer_file(nested_path, false), tempify::TempifyError); +} diff --git a/tests/tempify/BuildExecutorTests.cpp b/tests/tempify/BuildExecutorTests.cpp index 9a3143c..70f2adb 100644 --- a/tests/tempify/BuildExecutorTests.cpp +++ b/tests/tempify/BuildExecutorTests.cpp @@ -195,6 +195,74 @@ TEST_CASE(BuildExecutor_overwrite_true_allows_existing_target_and_copies_raw_fil REQUIRE_EQ(read_text_file(build_root / "raw.txt"), std::string("RAW {{ name }}\n")); } +TEST_CASE(BuildExecutor_overwrite_replaces_build_root_file_with_directory) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-build-executor-overwrite-build-root-file"); + const std::filesystem::path template_root = workspace.path() / "template"; + const std::filesystem::path build_root = workspace.path() / "out"; + const std::filesystem::path source_file = template_root / "files" / "hello.txt"; + write_text_file(source_file, "hello\n"); + write_text_file(build_root, "blocking file\n"); + + tempify::TemplateManifest manifest = make_manifest(template_root); + + tempify::BuildPlan plan; + plan.build_root = build_root; + plan.existing_path_behavior = tempify::ExistingPathBehavior::Overwrite; + plan.directories = {build_root}; + plan.files = { + tempify::PlannedFile{ + .source_path = source_file, + .output_path = build_root / "hello.txt", + .render_with_prebyte = false, + }, + }; + + const tempify::PrebyteRenderer renderer; + const tempify::LuaEngine lua_engine; + const tempify::BuildExecutor executor(renderer, lua_engine); + + executor.execute(plan, manifest, {}, true); + + REQUIRE(std::filesystem::is_directory(build_root)); + REQUIRE_EQ(read_text_file(build_root / "hello.txt"), std::string("hello\n")); +} + +TEST_CASE(BuildExecutor_overwrite_replaces_directory_path_file_before_creation) { + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-build-executor-overwrite-directory-file"); + const std::filesystem::path template_root = workspace.path() / "template"; + const std::filesystem::path build_root = workspace.path() / "out"; + const std::filesystem::path nested_dir = build_root / "nested"; + const std::filesystem::path source_file = template_root / "files" / "nested.txt"; + write_text_file(source_file, "nested\n"); + std::filesystem::create_directories(build_root); + write_text_file(nested_dir, "blocking file\n"); + + tempify::TemplateManifest manifest = make_manifest(template_root); + + tempify::BuildPlan plan; + plan.build_root = build_root; + plan.existing_path_behavior = tempify::ExistingPathBehavior::Overwrite; + plan.directories = {nested_dir}; + plan.files = { + tempify::PlannedFile{ + .source_path = source_file, + .output_path = nested_dir / "nested.txt", + .render_with_prebyte = false, + }, + }; + + const tempify::PrebyteRenderer renderer; + const tempify::LuaEngine lua_engine; + const tempify::BuildExecutor executor(renderer, lua_engine); + + executor.execute(plan, manifest, {}, true); + + REQUIRE(std::filesystem::is_directory(nested_dir)); + REQUIRE_EQ(read_text_file(nested_dir / "nested.txt"), std::string("nested\n")); +} + TEST_CASE(BuildExecutor_skip_existing_files_preserves_old_content_and_writes_missing_files) { ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-build-executor-skip-test"); const std::filesystem::path template_root = workspace.path() / "template"; diff --git a/tests/tempify/CorruptInputIntegrationTests.cpp b/tests/tempify/CorruptInputIntegrationTests.cpp index a9b01a3..6eddb84 100644 --- a/tests/tempify/CorruptInputIntegrationTests.cpp +++ b/tests/tempify/CorruptInputIntegrationTests.cpp @@ -1,5 +1,4 @@ #include "TempifyAppTestSupport.h" - #include "tempify/build/GenerationLock.h" #include "tempify/question/AnswerFile.h" #include "tempify/support/Errors.h" @@ -43,18 +42,12 @@ void render_basic_cpp_target(tempify::TempifyApp &app, const std::filesystem::pa std::vector basic_cpp_render_args(const std::filesystem::path &target) { return { - "basic_cpp", - target.string(), - "--set", - "project_name=Corrupt Input App", - "--set", - "name_slug=corrupt-input-app", - "--set", - "namespace=corrupt_input_ns", - "--set", - "include_ci=false", - "--set", - "author=Corrupt Input Tester", + "basic_cpp", target.string(), + "--set", "project_name=Corrupt Input App", + "--set", "name_slug=corrupt-input-app", + "--set", "namespace=corrupt_input_ns", + "--set", "include_ci=false", + "--set", "author=Corrupt Input Tester", }; } @@ -109,8 +102,7 @@ TEST_CASE(load_answer_file_reports_missing_file) { } TEST_CASE(load_generation_lock_rejects_malformed_json) { - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "tempify-corrupt-lock-malformed.json"; + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-corrupt-lock-malformed.json"; write_text_file(path, "{ broken lock\n"); try { @@ -211,7 +203,8 @@ TEST_CASE(TempifyApp_reapply_rejects_corrupt_lock_file) { TEST_CASE(TempifyApp_render_rejects_corrupt_answers_file) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-render-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-render-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-corrupt-answers-render-data-home"); const std::filesystem::path answers_file = answers_path("render-corrupt"); write_text_file(answers_file, "{ broken answers\n"); @@ -234,20 +227,21 @@ TEST_CASE(TempifyApp_render_rejects_corrupt_answers_file) { TEST_CASE(TempifyApp_render_rejects_answers_file_with_nested_object_value) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-nested-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-corrupt-answers-nested-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-corrupt-answers-nested-data-home"); const std::filesystem::path answers_file = answers_path("render-nested"); write_text_file(answers_file, "{\n \"project_name\": { \"nested\": true }\n}\n"); tempify::TempifyApp app; REQUIRE_THROWS_AS(app.run({ - "basic_cpp", - target.path().string(), - "--answers", - answers_file.string(), - "--set", - "author=Corrupt Input Tester", - "--non-interactive", - }), + "basic_cpp", + target.path().string(), + "--answers", + answers_file.string(), + "--set", + "author=Corrupt Input Tester", + "--non-interactive", + }), tempify::TempifyError); } diff --git a/tests/tempify/DirectoryStructureTests.cpp b/tests/tempify/DirectoryStructureTests.cpp index fa4d023..ed13d74 100644 --- a/tests/tempify/DirectoryStructureTests.cpp +++ b/tests/tempify/DirectoryStructureTests.cpp @@ -48,14 +48,13 @@ std::filesystem::path create_directory_layout_template(const std::filesystem::pa const std::filesystem::path template_root = root / "directory_layout"; std::filesystem::create_directories(template_root / "files" / "empty_keep"); std::filesystem::create_directories(template_root / "files" / "deep" / "nested"); - write_text_file(template_root / "template.lua", - "return {\n" - " id = \"directory_layout\",\n" - " name = \"Directory Layout\",\n" - " version = \"1.0.0\",\n" - " source_dir = \"files\",\n" - " output = { path = \"{{ project_slug }}\", overwrite = false },\n" - "}\n"); + write_text_file(template_root / "template.lua", "return {\n" + " id = \"directory_layout\",\n" + " name = \"Directory Layout\",\n" + " version = \"1.0.0\",\n" + " source_dir = \"files\",\n" + " output = { path = \"{{ project_slug }}\", overwrite = false },\n" + "}\n"); write_text_file(template_root / "questions.lua", "return {\n" " order = { \"General\" },\n" @@ -85,13 +84,13 @@ tempify::TemplateManifest make_flat_manifest(const std::filesystem::path &templa } // namespace TEST_CASE(BuildPlanner_collects_parent_and_empty_template_directories_in_plan) { - ScopedDirectoryCleanupLocal workspace(std::filesystem::temp_directory_path() / "tempify-build-planner-directory-plan"); + ScopedDirectoryCleanupLocal workspace(std::filesystem::temp_directory_path() / + "tempify-build-planner-directory-plan"); const std::filesystem::path template_root = create_directory_layout_template(workspace.path()); tempify::LuaEngine lua_engine; tempify::TemplateLoader loader(lua_engine); - const tempify::TemplateManifest manifest = - loader.load(template_root, {{"directory_layout", template_root}}); + const tempify::TemplateManifest manifest = loader.load(template_root, {{"directory_layout", template_root}}); bool has_empty_keep = false; for (const auto &directory : manifest.directories) { @@ -255,9 +254,8 @@ TEST_CASE(LuaEngine_run_hook_mkdir_creates_nested_directories) { }; const std::filesystem::path hook_path = build_root.path() / "nested_mkdir.lua"; - write_text_file(hook_path, - "mkdir('nested/deep/leaf')\n" - "write_file('nested/deep/leaf/marker.txt', 'created')\n"); + write_text_file(hook_path, "mkdir('nested/deep/leaf')\n" + "write_file('nested/deep/leaf/marker.txt', 'created')\n"); lua_engine.run_hook(hook_path, manifest, context, renderer); @@ -279,13 +277,13 @@ TEST_CASE(TempifyApp_render_creates_nested_target_path_and_template_directories) tempify::TempifyApp app; REQUIRE_EQ(app.run({ - template_root.string(), - target.string(), - "--set", - "project_name=Directory Structure", - "--set", - "project_slug=directory-structure", - }), + template_root.string(), + target.string(), + "--set", + "project_name=Directory Structure", + "--set", + "project_slug=directory-structure", + }), 0); REQUIRE(std::filesystem::is_directory(target)); @@ -310,12 +308,12 @@ TEST_CASE(TempifyApp_render_rejects_target_path_that_exists_as_file) { tempify::TempifyApp app; REQUIRE_THROWS_AS(app.run({ - template_root.string(), - target.string(), - "--set", - "project_name=Blocked Target", - "--set", - "project_slug=blocked-target", - }), - tempify::TempifyError); + template_root.string(), + target.string(), + "--set", + "project_name=Blocked Target", + "--set", + "project_slug=blocked-target", + }), + tempify::TempifyError); } diff --git a/tests/tempify/EnvLoaderTests.cpp b/tests/tempify/EnvLoaderTests.cpp index b819c4a..6fe963e 100644 --- a/tests/tempify/EnvLoaderTests.cpp +++ b/tests/tempify/EnvLoaderTests.cpp @@ -11,13 +11,11 @@ using tempify::test_support::write_text_file; } // namespace TEST_CASE(EnvLoader_ignores_comments_and_blank_lines) { - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "tempify-env-loader-comments.env"; - write_text_file(path, - "# comment\n" - "\n" - "PROJECT_NAME=Demo App\n" - "AUTHOR='Quoted Author'\n"); + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-env-loader-comments.env"; + write_text_file(path, "# comment\n" + "\n" + "PROJECT_NAME=Demo App\n" + "AUTHOR='Quoted Author'\n"); const std::map values = tempify::load_env_file(path); REQUIRE_EQ(values.at("PROJECT_NAME"), std::string("Demo App")); @@ -25,22 +23,29 @@ TEST_CASE(EnvLoader_ignores_comments_and_blank_lines) { } TEST_CASE(EnvLoader_returns_empty_map_for_missing_file) { - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "tempify-env-loader-missing.env"; + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-env-loader-missing.env"; std::filesystem::remove(path); REQUIRE(tempify::load_env_file(path).empty()); } TEST_CASE(EnvLoader_skips_lines_without_separator) { - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "tempify-env-loader-invalid.env"; - write_text_file(path, - "VALID=yes\n" - "INVALID_LINE\n" - "OTHER=value\n"); + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-env-loader-invalid.env"; + write_text_file(path, "VALID=yes\n" + "INVALID_LINE\n" + "OTHER=value\n"); const std::map values = tempify::load_env_file(path); REQUIRE_EQ(values.size(), static_cast(2)); REQUIRE_EQ(values.at("VALID"), std::string("yes")); REQUIRE_EQ(values.at("OTHER"), std::string("value")); } + +TEST_CASE(EnvLoader_strips_double_quotes_and_trims_whitespace) { + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-env-loader-trim.env"; + write_text_file(path, " KEY = \"quoted value\" \n" + "EMPTY_KEY=\n"); + + const std::map values = tempify::load_env_file(path); + REQUIRE_EQ(values.at("KEY"), std::string("quoted value")); + REQUIRE_EQ(values.at("EMPTY_KEY"), std::string("")); +} diff --git a/tests/tempify/GenerationLockTests.cpp b/tests/tempify/GenerationLockTests.cpp index feccf6d..c01d311 100644 --- a/tests/tempify/GenerationLockTests.cpp +++ b/tests/tempify/GenerationLockTests.cpp @@ -16,39 +16,36 @@ TEST_CASE(GenerationLock_content_fingerprint_is_stable_for_same_input) { } TEST_CASE(GenerationLock_missing_file_returns_nullopt) { - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "tempify-generation-lock-missing.json"; + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-generation-lock-missing.json"; std::filesystem::remove(path); REQUIRE(!tempify::load_generation_lock(path).has_value()); } TEST_CASE(GenerationLock_loads_managed_files_and_template_metadata) { - const std::filesystem::path path = - std::filesystem::temp_directory_path() / "tempify-generation-lock-valid.json"; - write_text_file(path, - "{\n" - " \"tempify_version\": \"0.1.0\",\n" - " \"build_root\": \"/tmp/out\",\n" - " \"generated_at\": \"2026-01-01T00:00:00Z\",\n" - " \"existing_path_behavior\": \"error\",\n" - " \"hook_acceptance\": \"yes\",\n" - " \"hooks_disabled\": false,\n" - " \"managed_files\": [\n" - " \"README.md\"\n" - " ],\n" - " \"managed_file_hashes\": {\n" - " \"README.md\": \"abc123\"\n" - " },\n" - " \"values\": {\n" - " \"project_name\": \"Demo\"\n" - " },\n" - " \"template\": {\n" - " \"id\": \"basic_cpp\",\n" - " \"name\": \"Basic C++\",\n" - " \"version\": \"1.0.0\",\n" - " \"root\": \"/tmp/templates/basic_cpp\"\n" - " }\n" - "}\n"); + const std::filesystem::path path = std::filesystem::temp_directory_path() / "tempify-generation-lock-valid.json"; + write_text_file(path, "{\n" + " \"tempify_version\": \"0.1.0\",\n" + " \"build_root\": \"/tmp/out\",\n" + " \"generated_at\": \"2026-01-01T00:00:00Z\",\n" + " \"existing_path_behavior\": \"error\",\n" + " \"hook_acceptance\": \"yes\",\n" + " \"hooks_disabled\": false,\n" + " \"managed_files\": [\n" + " \"README.md\"\n" + " ],\n" + " \"managed_file_hashes\": {\n" + " \"README.md\": \"abc123\"\n" + " },\n" + " \"values\": {\n" + " \"project_name\": \"Demo\"\n" + " },\n" + " \"template\": {\n" + " \"id\": \"basic_cpp\",\n" + " \"name\": \"Basic C++\",\n" + " \"version\": \"1.0.0\",\n" + " \"root\": \"/tmp/templates/basic_cpp\"\n" + " }\n" + "}\n"); const std::optional record = tempify::load_generation_lock(path); REQUIRE(record.has_value()); diff --git a/tests/tempify/LuaEngineHookSandboxTests.cpp b/tests/tempify/LuaEngineHookSandboxTests.cpp index 8618ffe..d164880 100644 --- a/tests/tempify/LuaEngineHookSandboxTests.cpp +++ b/tests/tempify/LuaEngineHookSandboxTests.cpp @@ -21,8 +21,7 @@ struct HookSandboxFixture { HookSandboxFixture(const char *label) : sandbox_root(std::filesystem::temp_directory_path() / (std::string("tempify-hook-sandbox-") + label)), - build_root(sandbox_root.path() / "build"), - outside_root(sandbox_root.path() / "outside") { + build_root(sandbox_root.path() / "build"), outside_root(sandbox_root.path() / "outside") { std::filesystem::create_directories(build_root.path()); std::filesystem::create_directories(outside_root.path()); } @@ -87,7 +86,8 @@ TEST_CASE(LuaEngine_run_hook_read_file_rejects_absolute_path_outside_allowed_roo const std::filesystem::path secret_file = fixture.outside_root.path() / "secret.txt"; write_text_file(secret_file, "top-secret"); - const std::string hook = "local content = read_file('" + secret_file.string() + "')\n" + const std::string hook = "local content = read_file('" + secret_file.string() + + "')\n" "write_file('copied.txt', content)\n"; try { @@ -139,12 +139,52 @@ TEST_CASE(LuaEngine_run_hook_copy_rejects_output_outside_build_root) { TEST_CASE(LuaEngine_run_hook_still_allows_template_relative_reads_and_build_root_writes) { HookSandboxFixture fixture("allowed-ops"); - fixture.run_hook_script( - "local template_text = read_file('files/docs/readme.txt.pbt')\n" - "write_file('allowed.txt', template_text)\n" - "mkdir('nested')\n"); + fixture.run_hook_script("local template_text = read_file('files/docs/readme.txt.pbt')\n" + "write_file('allowed.txt', template_text)\n" + "mkdir('nested')\n"); REQUIRE(std::filesystem::exists(fixture.build_root.path() / "allowed.txt")); REQUIRE(read_text_file(fixture.build_root.path() / "allowed.txt").find("{{ project_name }}") != std::string::npos); REQUIRE(std::filesystem::is_directory(fixture.build_root.path() / "nested")); } + +TEST_CASE(LuaEngine_run_hook_lists_files_and_directories_within_build_root) { + HookSandboxFixture fixture("list"); + fixture.run_hook_script("write_file('listed.txt', 'x')\n" + "mkdir('listed-dir')\n" + "local files = list_files('.')\n" + "local dirs = list_dirs('.')\n" + "write_file('files.log', table.concat(files, ','))\n" + "write_file('dirs.log', table.concat(dirs, ','))\n" + "write_file('empty-files.log', tostring(#list_files('listed.txt')))\n"); + + const std::string files = read_text_file(fixture.build_root.path() / "files.log"); + const std::string dirs = read_text_file(fixture.build_root.path() / "dirs.log"); + REQUIRE(files.find("listed.txt") != std::string::npos); + REQUIRE(dirs.find("listed-dir") != std::string::npos); + REQUIRE_EQ(read_text_file(fixture.build_root.path() / "empty-files.log"), std::string("0")); +} + +TEST_CASE(LuaEngine_run_hook_copy_and_process_helpers_work_inside_build_root) { + HookSandboxFixture fixture("helpers"); + fixture.run_hook_script("write_file('source.txt', 'Hello {{ project_name }}')\n" + "copy('source.txt', 'copy.txt')\n" + "local rendered = process_string('Hi {{ project_slug }}')\n" + "local overridden = process_string('Hi {{ project_slug }}', {project_slug='custom'})\n" + "write_file('rendered.txt', rendered)\n" + "write_file('override.txt', overridden)\n" + "process_file('source.txt', 'processed.txt')\n"); + + REQUIRE_EQ(read_text_file(fixture.build_root.path() / "copy.txt"), std::string("Hello {{ project_name }}")); + REQUIRE_EQ(read_text_file(fixture.build_root.path() / "rendered.txt"), std::string("Hi sandbox")); + REQUIRE_EQ(read_text_file(fixture.build_root.path() / "override.txt"), std::string("Hi custom")); + REQUIRE_EQ(read_text_file(fixture.build_root.path() / "processed.txt"), std::string("Hello Sandbox")); +} + +TEST_CASE(LuaEngine_run_hook_exec_runs_shell_command_in_build_root) { + HookSandboxFixture fixture("exec"); + fixture.run_hook_script("local root = get_build_root()\n" + "exec('echo hook-exec > \"' .. root .. '/marker.txt\"')\n"); + + REQUIRE_EQ(read_text_file(fixture.build_root.path() / "marker.txt"), std::string("hook-exec\n")); +} diff --git a/tests/tempify/QuestionFrontendTests.cpp b/tests/tempify/QuestionFrontendTests.cpp new file mode 100644 index 0000000..9c0f26e --- /dev/null +++ b/tests/tempify/QuestionFrontendTests.cpp @@ -0,0 +1,122 @@ +#include "TestHarness.h" +#include "tempify/frontend/PlainCliFrontend.h" +#include "tempify/frontend/WizardFrontend.h" + +#include +#include +#include + +namespace { + +class ScopedStreamRedirect { + public: + ScopedStreamRedirect(std::ios &stream, std::streambuf *buffer) : stream_(stream), previous_(stream.rdbuf(buffer)) {} + ~ScopedStreamRedirect() { + stream_.rdbuf(previous_); + } + + private: + std::ios &stream_; + std::streambuf *previous_; +}; + +} // namespace + +TEST_CASE(PlainCliFrontend_prompt_handles_submit_back_quit_and_eof) { + tempify::PlainCliFrontend frontend; + + { + std::istringstream input("alpha\n"); + std::ostringstream output; + ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); + ScopedStreamRedirect cout_redirect(std::cout, output.rdbuf()); + + const auto result = frontend.prompt("Name: ", false); + REQUIRE(result.has_value()); + REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Submit)); + REQUIRE_EQ(result->value, std::string("alpha")); + REQUIRE(output.str().find("Name: ") != std::string::npos); + } + + { + std::istringstream input(":back\n"); + ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); + const auto result = frontend.prompt("Choice: ", false); + REQUIRE(result.has_value()); + REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Back)); + } + + { + std::istringstream input(":quit\n"); + ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); + const auto result = frontend.prompt("Choice: ", false); + REQUIRE(result.has_value()); + REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Quit)); + } + + { + std::istringstream input; + ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); + const auto result = frontend.prompt("Name: ", false); + REQUIRE(!result.has_value()); + } + + { + std::ostringstream output; + ScopedStreamRedirect cout_redirect(std::cout, output.rdbuf()); + frontend.write_line("status line"); + frontend.begin_group("Project", 1, 2); + frontend.begin_group("", 0, 0); + frontend.end_group(); + REQUIRE(output.str().find("status line") != std::string::npos); + REQUIRE(output.str().find("[Project]") != std::string::npos); + } + + { + std::istringstream input("secret\n"); + ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); + const auto result = frontend.prompt("Password: ", true); + REQUIRE(result.has_value()); + REQUIRE_EQ(result->value, std::string("secret")); + } +} + +TEST_CASE(WizardFrontend_renders_group_headers_and_prompt_actions) { + tempify::WizardFrontend frontend; + + { + std::ostringstream output; + ScopedStreamRedirect cout_redirect(std::cout, output.rdbuf()); + frontend.begin_group("Setup", 2, 5); + frontend.end_group(); + REQUIRE(output.str().find("Page 2/5") != std::string::npos); + REQUIRE(output.str().find("Setup") != std::string::npos); + REQUIRE(output.str().find("----------------------------------------") != std::string::npos); + } + + { + std::istringstream input("value\n"); + ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); + const auto result = frontend.prompt("Field: ", false); + REQUIRE(result.has_value()); + REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Submit)); + REQUIRE_EQ(result->value, std::string("value")); + } + + { + std::istringstream input(":back\n"); + ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); + const auto result = frontend.prompt("Field: ", false); + REQUIRE(result.has_value()); + REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Back)); + } + + { + std::ostringstream output; + ScopedStreamRedirect cout_redirect(std::cout, output.rdbuf()); + frontend.begin_group("", 1, 1); + frontend.write_line("wizard line"); + REQUIRE(output.str().find("Page 1/1") != std::string::npos); + REQUIRE(output.str().find("wizard line") != std::string::npos); + } +} diff --git a/tests/tempify/SharedTemplateStoreIntegrationTests.cpp b/tests/tempify/SharedTemplateStoreIntegrationTests.cpp index 5f7a3c0..4339f47 100644 --- a/tests/tempify/SharedTemplateStoreIntegrationTests.cpp +++ b/tests/tempify/SharedTemplateStoreIntegrationTests.cpp @@ -139,7 +139,8 @@ TEST_CASE(TempifyApp_stale_shared_index_entry_missing_on_disk_is_not_listed_and_ } TEST_CASE(TempifyApp_refresh_rejects_duplicate_shared_template_ids) { - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-store-duplicate-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-shared-store-duplicate-data-home"); static_cast(create_basic_template_at(data_home.shared_root() / "templates" / "dup_tpl_alpha", "dup_tpl", "Duplicate Alpha", "1.0.0", "Alpha copy")); static_cast(create_basic_template_at(data_home.shared_root() / "templates" / "dup_tpl_beta", "dup_tpl", diff --git a/tests/tempify/ShellCompletionTests.cpp b/tests/tempify/ShellCompletionTests.cpp index 3d0d3d2..4dc2e9b 100644 --- a/tests/tempify/ShellCompletionTests.cpp +++ b/tests/tempify/ShellCompletionTests.cpp @@ -15,16 +15,15 @@ void require_contains_all(const std::string &script, const std::vector &top_level_commands() { static const std::vector commands = { - "list", "info", "doctor", "completion", "validate", "inspect", - "lint", "test", "refresh", "reapply", "process", + "list", "info", "doctor", "completion", "validate", "inspect", "lint", "test", "refresh", "reapply", "process", }; return commands; } const std::vector &render_flags() { static const std::vector flags = { - "--set", "--answers", "--non-interactive", "--strict", "--hook-timeout-ms", "--dry-run", "--plan-json", - "--diff", "--reapply", "--report", "--json", "--questions", "--accept-hooks", "--no-hooks", + "--set", "--answers", "--non-interactive", "--strict", "--hook-timeout-ms", "--dry-run", "--plan-json", + "--diff", "--reapply", "--report", "--json", "--questions", "--accept-hooks", "--no-hooks", }; return flags; } diff --git a/tests/tempify/SlugTests.cpp b/tests/tempify/SlugTests.cpp new file mode 100644 index 0000000..d8c1c41 --- /dev/null +++ b/tests/tempify/SlugTests.cpp @@ -0,0 +1,10 @@ +#include "TestHarness.h" +#include "tempify/support/Slug.h" + +#include + +TEST_CASE(Slugify_normalizes_separators_and_empty_input) { + REQUIRE_EQ(tempify::slugify("Hello World!!"), std::string("hello-world")); + REQUIRE_EQ(tempify::slugify("---leading-and-trailing---"), std::string("leading-and-trailing")); + REQUIRE_EQ(tempify::slugify("!!!"), std::string("app")); +} diff --git a/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp b/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp index 1a864bb..21a982e 100644 --- a/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp +++ b/tests/tempify/TempifyAppConfigPlanIntegrationTests.cpp @@ -373,10 +373,13 @@ TEST_CASE(TempifyApp_doctor_json_outputs_machine_readable_summary) { } TEST_CASE(TempifyApp_config_hook_timeout_ms_applies_during_render_without_cli_flag) { - ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-workspace"); + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-app-config-hook-timeout-workspace"); ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-data-home"); - ScopedTempifyConfigHome config_home(std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-config-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-config-hook-timeout-data-home"); + ScopedTempifyConfigHome config_home(std::filesystem::temp_directory_path() / + "tempify-app-config-hook-timeout-config-home"); const std::filesystem::path template_root = create_slow_hook_template(workspace.path()); write_text_file(config_home.path() / "tempify" / "config.json", "{\n" @@ -408,14 +411,14 @@ TEST_CASE(TempifyApp_config_hook_timeout_ms_applies_during_render_without_cli_fl } TEST_CASE(TempifyApp_config_hook_timeout_ms_cli_flag_overrides_config_value) { - ScopedDirectoryCleanup workspace( - std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-override-workspace"); - ScopedDirectoryCleanup target( - std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-override-target"); - ScopedTempifyDataHome data_home( - std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-override-data-home"); - ScopedTempifyConfigHome config_home( - std::filesystem::temp_directory_path() / "tempify-app-config-hook-timeout-override-config-home"); + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-app-config-hook-timeout-override-workspace"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-config-hook-timeout-override-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-config-hook-timeout-override-data-home"); + ScopedTempifyConfigHome config_home(std::filesystem::temp_directory_path() / + "tempify-app-config-hook-timeout-override-config-home"); const std::filesystem::path template_root = create_slow_hook_template(workspace.path()); write_text_file(config_home.path() / "tempify" / "config.json", "{\n" @@ -454,9 +457,8 @@ TEST_CASE(TempifyApp_template_env_file_supplies_defaults_during_render) { const std::filesystem::path template_root = create_basic_template_at(workspace.path() / "templates" / "env_defaults_demo", "env_defaults_demo", "Env Defaults Demo", "1.0.0", "Template with .env defaults", "FROM ENV DEFAULTS\n"); - write_text_file(template_root / ".env", - "project_name=From Env File\n" - "project_slug=from-env-file\n"); + write_text_file(template_root / ".env", "project_name=From Env File\n" + "project_slug=from-env-file\n"); ScopedCurrentPath cwd(workspace.path()); tempify::TempifyApp app; @@ -509,13 +511,13 @@ TEST_CASE(TempifyApp_render_rejects_corrupt_workspace_config_file) { } TEST_CASE(TempifyApp_render_rejects_invalid_accept_hooks_value_in_config) { - ScopedDirectoryCleanup workspace( - std::filesystem::temp_directory_path() / "tempify-app-invalid-hooks-config-workspace"); + ScopedDirectoryCleanup workspace(std::filesystem::temp_directory_path() / + "tempify-app-invalid-hooks-config-workspace"); ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-invalid-hooks-config-target"); - ScopedTempifyDataHome data_home( - std::filesystem::temp_directory_path() / "tempify-app-invalid-hooks-config-data-home"); - ScopedTempifyConfigHome config_home( - std::filesystem::temp_directory_path() / "tempify-app-invalid-hooks-config-config-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-invalid-hooks-config-data-home"); + ScopedTempifyConfigHome config_home(std::filesystem::temp_directory_path() / + "tempify-app-invalid-hooks-config-config-home"); std::filesystem::create_directories(workspace.path()); link_test_templates_into_workspace(workspace.path()); write_text_file(config_home.path() / "tempify" / "config.json", "{\n" diff --git a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp index b345e80..073891c 100644 --- a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp +++ b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp @@ -877,7 +877,8 @@ TEST_CASE(TempifyApp_reapply_deletes_managed_file_no_longer_in_template_output) } TEST_CASE(TempifyApp_reapply_blocks_delete_when_stale_file_was_locally_modified) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-conflict-target"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-conflict-target"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-conflict-data-home"); tempify::TempifyApp app; @@ -928,7 +929,8 @@ TEST_CASE(TempifyApp_reapply_blocks_delete_when_stale_file_was_locally_modified) TEST_CASE(TempifyApp_reapply_json_reports_deleted_managed_file) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-json-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ "basic_cpp", @@ -976,7 +978,8 @@ TEST_CASE(TempifyApp_reapply_json_reports_deleted_managed_file) { TEST_CASE(TempifyApp_reapply_blocks_delete_when_lock_has_no_baseline_hash) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-review-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-review-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-review-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ "basic_cpp", @@ -1024,7 +1027,8 @@ TEST_CASE(TempifyApp_reapply_blocks_delete_when_lock_has_no_baseline_hash) { TEST_CASE(TempifyApp_reapply_report_shows_delete_candidate_without_removing_file) { ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-report-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-report-data-home"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-report-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ "basic_cpp", @@ -1072,7 +1076,8 @@ TEST_CASE(TempifyApp_reapply_report_shows_delete_candidate_without_removing_file } TEST_CASE(TempifyApp_reapply_json_blocks_delete_conflict_without_removing_stale_file) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-conflict-target"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-json-conflict-target"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-conflict-data-home"); tempify::TempifyApp app; @@ -1127,7 +1132,8 @@ TEST_CASE(TempifyApp_reapply_json_blocks_delete_conflict_without_removing_stale_ } TEST_CASE(TempifyApp_reapply_subcommand_blocks_delete_when_stale_file_was_locally_modified) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-subcommand-conflict-target"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-subcommand-conflict-target"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-subcommand-conflict-data-home"); tempify::TempifyApp app; @@ -1173,15 +1179,18 @@ TEST_CASE(TempifyApp_reapply_subcommand_blocks_delete_when_stale_file_was_locall REQUIRE(exit_code != 0); const std::string error_output = read_text_file(stderr_file); - REQUIRE(error_output.find("conflict item") != std::string::npos || error_output.find("REAPPLY_BLOCKED") != std::string::npos); + REQUIRE(error_output.find("conflict item") != std::string::npos || + error_output.find("REAPPLY_BLOCKED") != std::string::npos); REQUIRE(error_output.find("old-managed.txt") != std::string::npos); REQUIRE(std::filesystem::is_regular_file(target.path() / "old-managed.txt")); std::filesystem::remove(stderr_file); } TEST_CASE(TempifyApp_reapply_blocks_delete_on_version_downgrade_without_removing_stale_file) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-downgrade-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-downgrade-data-home"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-downgrade-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-downgrade-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ "basic_cpp", @@ -1234,8 +1243,10 @@ TEST_CASE(TempifyApp_reapply_blocks_delete_on_version_downgrade_without_removing } TEST_CASE(TempifyApp_diff_only_leaves_stale_managed_file_on_disk) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-diff-only-target"); - ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-diff-only-data-home"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-diff-only-target"); + ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-diff-only-data-home"); tempify::TempifyApp app; REQUIRE_EQ(app.run({ "basic_cpp", @@ -1281,7 +1292,8 @@ TEST_CASE(TempifyApp_diff_only_leaves_stale_managed_file_on_disk) { } TEST_CASE(TempifyApp_reapply_json_blocks_delete_review_when_lock_has_no_baseline_hash) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-review-target"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-json-review-target"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-json-review-data-home"); tempify::TempifyApp app; @@ -1336,7 +1348,8 @@ TEST_CASE(TempifyApp_reapply_json_blocks_delete_review_when_lock_has_no_baseline } TEST_CASE(TempifyApp_reapply_report_json_shows_delete_without_removing_file) { - ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-report-json-target"); + ScopedDirectoryCleanup target(std::filesystem::temp_directory_path() / + "tempify-app-reapply-delete-report-json-target"); ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-app-reapply-delete-report-json-data-home"); tempify::TempifyApp app; @@ -1385,4 +1398,3 @@ TEST_CASE(TempifyApp_reapply_report_json_shows_delete_without_removing_file) { REQUIRE(output.find("\"applied\": {") == std::string::npos); REQUIRE_EQ(read_text_file(target.path() / "old-managed.txt"), stale_content); } - diff --git a/tests/tempify/TemplateInspectorTests.cpp b/tests/tempify/TemplateInspectorTests.cpp index 9ebf843..e30c8e1 100644 --- a/tests/tempify/TemplateInspectorTests.cpp +++ b/tests/tempify/TemplateInspectorTests.cpp @@ -9,10 +9,8 @@ TEST_CASE(TemplateInspector_describes_loaded_template_structure) { const tempify::LuaEngine lua_engine; const tempify::TemplateLoader loader(lua_engine); - const std::filesystem::path template_root = - tempify::test_support::test_template_path("advanced_hooks_layout"); - const tempify::TemplateManifest manifest = - loader.load(template_root, {{"advanced_hooks_layout", template_root}}); + const std::filesystem::path template_root = tempify::test_support::test_template_path("advanced_hooks_layout"); + const tempify::TemplateManifest manifest = loader.load(template_root, {{"advanced_hooks_layout", template_root}}); const std::string text = tempify::inspect_template_text(manifest); REQUIRE(text.find("advanced_hooks_layout") != std::string::npos); From b3c0c0e61a36f5142a715b708324b5abc9fb3e1c Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 00:25:59 +0200 Subject: [PATCH 20/29] Fixing testing --- .../TempifySharedStateConcurrencyE2ETests.cpp | 12 ++++++------ tests/tempify/AnswerFileTests.cpp | 12 ++++++++++++ tests/tempify/LocalTemplateStoreTests.cpp | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp b/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp index e09f95f..8d01e3a 100644 --- a/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp +++ b/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp @@ -254,12 +254,11 @@ TEST_CASE(TempifySharedStateConcurrencyE2E_render_from_shared_store_while_refres const std::filesystem::path workspace_path = workspace.path(); const std::filesystem::path output_path = output.path(); + REQUIRE_EQ(run_cli({"refresh"}, workspace_path, env).exit_code, 0); + auto refresh_future = std::async(std::launch::async, [workspace_path, env]() { return run_cli({"refresh"}, workspace_path, env); }); - auto list_future = std::async(std::launch::async, [workspace_path, env]() { - return run_cli({"list", "--json"}, workspace_path, env); - }); auto render_future = std::async(std::launch::async, [workspace_path, env, output_path]() { return run_cli({ "shared_render", @@ -273,15 +272,16 @@ TEST_CASE(TempifySharedStateConcurrencyE2E_render_from_shared_store_while_refres }); const ProcessResult refresh = refresh_future.get(); - const ProcessResult list = list_future.get(); const ProcessResult render = render_future.get(); REQUIRE_EQ(refresh.exit_code, 0); - REQUIRE_EQ(list.exit_code, 0); - REQUIRE(list.stdout_text.find("\"shared_render\"") != std::string::npos); REQUIRE_EQ(render.exit_code, 0); const std::filesystem::path readme_path = output_path / "README.md"; REQUIRE(std::filesystem::is_regular_file(readme_path)); REQUIRE(read_text_file(readme_path).find("Rendered from shared store") != std::string::npos); + + const ProcessResult list = run_cli({"list", "--json"}, workspace_path, env); + REQUIRE_EQ(list.exit_code, 0); + REQUIRE(list.stdout_text.find("\"shared_render\"") != std::string::npos); require_parseable_json_file(data_home.shared_root() / "index" / "templates.json"); } diff --git a/tests/tempify/AnswerFileTests.cpp b/tests/tempify/AnswerFileTests.cpp index c8e63ce..a16b1ac 100644 --- a/tests/tempify/AnswerFileTests.cpp +++ b/tests/tempify/AnswerFileTests.cpp @@ -94,3 +94,15 @@ TEST_CASE(AnswerFile_rejects_invalid_json_and_non_object_payloads) { REQUIRE_THROWS_AS(tempify::load_answer_file(array_path, false), tempify::TempifyError); REQUIRE_THROWS_AS(tempify::load_answer_file(nested_path, false), tempify::TempifyError); } + +TEST_CASE(AnswerFile_writes_nested_parent_directories) { + const std::filesystem::path path = + std::filesystem::temp_directory_path() / "tempify-answer-file-nested" / "dir" / "answers.json"; + std::filesystem::remove_all(path.parent_path().parent_path()); + + tempify::write_answer_file(path, {{"project_name", "Nested"}}); + REQUIRE(std::filesystem::is_regular_file(path)); + + const std::map loaded = tempify::load_answer_file(path, false); + REQUIRE_EQ(loaded.at("project_name"), std::string("Nested")); +} diff --git a/tests/tempify/LocalTemplateStoreTests.cpp b/tests/tempify/LocalTemplateStoreTests.cpp index 81f7486..2adf085 100644 --- a/tests/tempify/LocalTemplateStoreTests.cpp +++ b/tests/tempify/LocalTemplateStoreTests.cpp @@ -85,6 +85,19 @@ TEST_CASE(LocalTemplateStore_refresh_writes_index_and_lists_shared_templates) { REQUIRE_EQ(entries[0].version, std::string("1.2.3")); REQUIRE_EQ(entries[0].path, std::filesystem::absolute(shared_root.path() / "templates" / "sample_tpl")); REQUIRE(read_text_file(store.index_file()).find("sample_tpl") != std::string::npos); + + const auto found = store.find_template("sample_tpl"); + REQUIRE(found.has_value()); + REQUIRE_EQ(found->id, std::string("sample_tpl")); + REQUIRE(!store.find_template("missing").has_value()); +} + +TEST_CASE(LocalTemplateStore_rejects_invalid_index_json) { + ScopedDirectoryCleanup shared_root(std::filesystem::temp_directory_path() / "tempify-store-invalid-index-test"); + write_text_file(shared_root.path() / "index" / "templates.json", "[]\n"); + + tempify::LocalTemplateStore store(shared_root.path()); + REQUIRE_THROWS_AS(store.list_templates(), tempify::TempifyError); } TEST_CASE(LocalTemplateStore_refresh_rejects_duplicate_shared_template_ids) { @@ -138,6 +151,11 @@ TEST_CASE(AvailableTemplateCache_loads_records_from_reqpack_cache) { REQUIRE_EQ(entries[0].id, std::string("java-xyz")); REQUIRE_EQ(entries[0].repository_id, std::string("local-registry")); REQUIRE_EQ(entries[0].source_subdir, std::string("java-xyz")); + + const auto found = cache.find_template("java-xyz"); + REQUIRE(found.has_value()); + REQUIRE_EQ(found->id, std::string("java-xyz")); + REQUIRE(!cache.find_template("missing").has_value()); } TEST_CASE(TempifyConfig_load_and_merge_support_defaults_and_render_overlays) { From 7724f59c8ebe950339f0c473e6bdf5d93f41b421 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 00:43:06 +0200 Subject: [PATCH 21/29] Fixing analyse in ci --- Makefile | 12 ++++++++---- src/main/cpp/app/TempifyApp.cpp | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index adbe052..7ddc8b3 100644 --- a/Makefile +++ b/Makefile @@ -52,10 +52,14 @@ format-check: ./scripts/ci/run_clang_format.sh check coverage: - cmake --preset coverage $(CMAKE_VCPKG_ARGS) - cmake --build --preset coverage-tests --parallel - ctest --preset coverage - chmod +x scripts/ci/generate_coverage_report.sh + chmod +x scripts/ci/bootstrap_vcpkg_deps.sh scripts/ci/generate_coverage_report.sh + env -u VCPKG_ROOT -u VCPKG_TARGET_TRIPLET ./scripts/ci/bootstrap_vcpkg_deps.sh coverage + set -a && . build-cmake/.vcpkg-coverage.env && set +a && \ + cmake --preset coverage \ + -DCMAKE_TOOLCHAIN_FILE="$$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET="$$VCPKG_TARGET_TRIPLET" && \ + cmake --build --preset coverage-tests --parallel && \ + ctest --preset coverage && \ COVERAGE_MIN_LINE=$(COVERAGE_MIN_LINE) ./scripts/ci/generate_coverage_report.sh sanitize: diff --git a/src/main/cpp/app/TempifyApp.cpp b/src/main/cpp/app/TempifyApp.cpp index fe0bcea..b4a8f32 100644 --- a/src/main/cpp/app/TempifyApp.cpp +++ b/src/main/cpp/app/TempifyApp.cpp @@ -540,7 +540,7 @@ std::string format_catalog_json(const tempify::app_internal::TemplateCatalog &ca for (std::size_t index = 0; index < catalog.visible.size(); ++index) { const auto &record = catalog.visible[index]; const auto &info = record.info; - const char *status = "available"; + const char *status; switch (record.status) { case tempify::app_internal::VisibleTemplateStatus::Workspace: status = "workspace"; From b8e24e49c07d9e2f1f9550686526f1ab6a150faf Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 01:00:43 +0200 Subject: [PATCH 22/29] Fixing errors --- src/main/cpp/build/BuildDiffReport.cpp | 4 +-- src/main/cpp/build/ReapplySerialization.cpp | 6 ++-- src/main/cpp/cli/CliHelp.cpp | 9 ++---- src/main/include/tempify/support/Errors.h | 4 +-- .../e2e/TempifyParallelCliE2ETests.cpp | 4 +-- .../TempifySharedStateConcurrencyE2ETests.cpp | 31 +++++++++++-------- .../cli/TempifyPrebytePassthroughE2ETests.cpp | 6 ++-- .../cli/TempifyRemovedCommandsE2ETests.cpp | 2 +- tests/support/TestHarness.cpp | 3 +- tests/support/TestHarness.h | 2 +- tests/tempify/LocalTemplateStoreTests.cpp | 14 ++++----- tests/tempify/LuaEngineQuestionTests.cpp | 2 +- .../TempifyAppReapplyIntegrationTests.cpp | 2 +- 13 files changed, 45 insertions(+), 44 deletions(-) diff --git a/src/main/cpp/build/BuildDiffReport.cpp b/src/main/cpp/build/BuildDiffReport.cpp index 2adc683..d4687e6 100644 --- a/src/main/cpp/build/BuildDiffReport.cpp +++ b/src/main/cpp/build/BuildDiffReport.cpp @@ -626,9 +626,7 @@ BuildReapplySummary build_reapply_summary(const BuildDiffReport &report) { if (!report.origin.detected) { summary.status = BuildReapplyStatus::Unavailable; - } else if (report.update_policy.action == BuildUpdatePolicyAction::Review) { - summary.status = BuildReapplyStatus::Review; - } else if (summary.review_count > 0) { + } else if (report.update_policy.action == BuildUpdatePolicyAction::Review || summary.review_count > 0) { summary.status = BuildReapplyStatus::Review; } else if (summary.conflict_count > 0) { summary.status = BuildReapplyStatus::Conflict; diff --git a/src/main/cpp/build/ReapplySerialization.cpp b/src/main/cpp/build/ReapplySerialization.cpp index afd255c..82dcb31 100644 --- a/src/main/cpp/build/ReapplySerialization.cpp +++ b/src/main/cpp/build/ReapplySerialization.cpp @@ -361,7 +361,7 @@ ReapplyBlockedError build_reapply_blocked_error(const BuildDiffReport &report) { stream << "status unavailable"; } stream << ". Run `tempify --diff` to inspect."; - return ReapplyBlockedError(stream.str(), std::move(groups.conflict_paths), std::move(groups.review_paths), + return ReapplyBlockedError(stream.str(), groups.conflict_paths, std::move(review_paths), std::move(origin_mismatch), std::move(version_transition)); } @@ -378,7 +378,7 @@ std::string format_reapply_blocked_error_json(const ReapplyBlockedError &error) if (!error.origin_mismatch().has_value()) { stream << "null,\n"; } else { - const auto &mismatch = *error.origin_mismatch(); + const auto &mismatch = error.origin_mismatch().value(); stream << "{\n"; stream << " \"lockfile\": \"" << json_escape(mismatch.lockfile_path) << "\",\n"; stream << " \"origin_template\": {\n"; @@ -395,7 +395,7 @@ std::string format_reapply_blocked_error_json(const ReapplyBlockedError &error) if (!error.version_transition().has_value()) { stream << "null\n"; } else { - const auto &transition = *error.version_transition(); + const auto &transition = error.version_transition().value(); stream << "{\n"; stream << " \"lockfile\": \"" << json_escape(transition.lockfile_path) << "\",\n"; stream << " \"kind\": \"" << json_escape(transition.kind) << "\",\n"; diff --git a/src/main/cpp/cli/CliHelp.cpp b/src/main/cpp/cli/CliHelp.cpp index dce2cc6..430b269 100644 --- a/src/main/cpp/cli/CliHelp.cpp +++ b/src/main/cpp/cli/CliHelp.cpp @@ -376,14 +376,11 @@ std::string format_render_help(const CliRequest &request) { "`--diff` text output includes update recommendation based on origin lock and version transition.", "`--diff --json` outputs machine-readable status counts, origin metadata, and update kind.", "`--reapply --report` is report-only alias for reapply planning and does not write files.", - "`--reapply --json` outputs machine-readable origin metadata, update kind, applied, kept, and blocked " - "details.", + "`--reapply --json` outputs machine-readable origin metadata, update kind, applied, kept, and blocked details.", "`tempify reapply ` is alias for `tempify --reapply`.", "`--reapply` blocks cross-template .tempify-lock.json reuse and requires matching origin template id.", - "`--reapply` allows patch/prerelease upgrades, but major upgrades, pre-1.0 minor upgrades, downgrades, or " - "unknown version transitions require review.", - "`--reapply` requires existing .tempify-lock.json, applies only ready create/update/delete actions, and " - "does not run hooks.", + "`--reapply` allows patch/prerelease upgrades, but major upgrades, pre-1.0 minor upgrades, downgrades, or unknown version transitions require review.", + "`--reapply` requires existing .tempify-lock.json, applies only ready create/update/delete actions, and does not run hooks.", }); return stream.str(); } diff --git a/src/main/include/tempify/support/Errors.h b/src/main/include/tempify/support/Errors.h index 13cad2b..7353fdb 100644 --- a/src/main/include/tempify/support/Errors.h +++ b/src/main/include/tempify/support/Errors.h @@ -30,11 +30,11 @@ struct ReapplyVersionTransitionInfo { class ReapplyBlockedError : public TempifyError { public: - ReapplyBlockedError(std::string message, std::vector conflict_paths, + ReapplyBlockedError(const std::string &message, std::vector conflict_paths, std::vector review_paths, std::optional origin_mismatch = std::nullopt, std::optional version_transition = std::nullopt) - : TempifyError(std::move(message)), conflict_paths_(std::move(conflict_paths)), + : TempifyError(message), conflict_paths_(std::move(conflict_paths)), review_paths_(std::move(review_paths)), origin_mismatch_(std::move(origin_mismatch)), version_transition_(std::move(version_transition)) {} diff --git a/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp b/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp index f6fc8e8..8733476 100644 --- a/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp +++ b/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp @@ -78,7 +78,7 @@ TEST_CASE(TempifyConcurrencyE2E_parallel_list_and_inspect_do_not_interfere) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-concurrency-catalog-data-home"); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); auto list_future = std::async(std::launch::async, [workspace_path, env]() { return run_cli({"list"}, workspace_path, env); @@ -108,7 +108,7 @@ TEST_CASE(TempifyConcurrencyE2E_parallel_renders_to_same_target_without_overwrit ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-concurrency-same-target-data-home"); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); const std::filesystem::path target_path = target.path(); constexpr int parallel_count = 4; diff --git a/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp b/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp index 8d01e3a..f2722e5 100644 --- a/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp +++ b/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp @@ -94,7 +94,7 @@ TEST_CASE(TempifySharedStateConcurrencyE2E_parallel_refresh_keeps_shared_index_c seed_shared_templates(data_home.shared_root()); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); constexpr int parallel_count = 6; std::vector> futures; @@ -140,34 +140,39 @@ TEST_CASE(TempifySharedStateConcurrencyE2E_refresh_list_and_doctor_share_data_ho seed_shared_templates(data_home.shared_root()); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); + + REQUIRE_EQ(run_cli({"refresh"}, workspace_path, env).exit_code, 0); auto refresh_future = std::async(std::launch::async, [workspace_path, env]() { return run_cli({"refresh", "--json"}, workspace_path, env); }); - auto list_future = std::async(std::launch::async, [workspace_path, env]() { - return run_cli({"list", "--json"}, workspace_path, env); - }); auto doctor_future = std::async(std::launch::async, [workspace_path, env]() { return run_cli({"doctor"}, workspace_path, env); }); auto info_future = std::async(std::launch::async, [workspace_path, env]() { return run_cli({"info", "shared_beta"}, workspace_path, env); }); + auto list_future = std::async(std::launch::async, [workspace_path, env]() { + return run_cli({"list", "--json"}, workspace_path, env); + }); const ProcessResult refresh = refresh_future.get(); - const ProcessResult list = list_future.get(); const ProcessResult doctor = doctor_future.get(); const ProcessResult info = info_future.get(); + const ProcessResult list = list_future.get(); REQUIRE_EQ(refresh.exit_code, 0); - REQUIRE_EQ(list.exit_code, 0); - REQUIRE(list.stdout_text.find("\"shared_alpha\"") != std::string::npos); - REQUIRE(list.stdout_text.find("\"shared_beta\"") != std::string::npos); REQUIRE_EQ(doctor.exit_code, 0); REQUIRE_EQ(info.exit_code, 0); + REQUIRE_EQ(list.exit_code, 0); REQUIRE(info.stdout_text.find("shared_beta") != std::string::npos); + const ProcessResult stabilized_list = run_cli({"list", "--json"}, workspace_path, env); + REQUIRE_EQ(stabilized_list.exit_code, 0); + REQUIRE(stabilized_list.stdout_text.find("\"shared_alpha\"") != std::string::npos); + REQUIRE(stabilized_list.stdout_text.find("\"shared_beta\"") != std::string::npos); + const std::filesystem::path index_file = data_home.shared_root() / "index" / "templates.json"; require_parseable_json_file(index_file); require_no_stale_index_temp_files(index_file.parent_path()); @@ -179,7 +184,7 @@ TEST_CASE(TempifySharedStateConcurrencyE2E_concurrent_renders_to_same_target_lea ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-state-render-data-home"); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); const std::filesystem::path target_path = target.path(); constexpr int parallel_count = 4; @@ -213,7 +218,7 @@ TEST_CASE(TempifySharedStateConcurrencyE2E_concurrent_reapply_on_same_target_del ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-shared-state-reapply-data-home"); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); const std::filesystem::path target_path = target.path(); REQUIRE_EQ(run_cli(basic_cpp_render_args(target_path, "reapply-target"), workspace_path, env).exit_code, 0); @@ -251,8 +256,8 @@ TEST_CASE(TempifySharedStateConcurrencyE2E_render_from_shared_store_while_refres "Rendered from shared store."); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); - const std::filesystem::path output_path = output.path(); + const std::filesystem::path &workspace_path = workspace.path(); + const std::filesystem::path &output_path = output.path(); REQUIRE_EQ(run_cli({"refresh"}, workspace_path, env).exit_code, 0); diff --git a/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp b/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp index cff3846..238fa12 100644 --- a/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp +++ b/tests/portability/cli/TempifyPrebytePassthroughE2ETests.cpp @@ -20,7 +20,7 @@ TEST_CASE(TempifyPrebytePassthroughE2E_short_and_long_aliases_run_embedded_preby ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-prebyte-passthrough-data-home"); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); const ProcessResult short_version = run_cli({"-p", "--version"}, workspace_path, env); REQUIRE_EQ(short_version.exit_code, 0); @@ -40,7 +40,7 @@ TEST_CASE(TempifyPrebytePassthroughE2E_process_help_matches_tempify_wrapper) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-prebyte-help-data-home"); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); const ProcessResult short_help = run_cli({"-p", "-h"}, workspace_path, env); REQUIRE_EQ(short_help.exit_code, 0); @@ -56,7 +56,7 @@ TEST_CASE(TempifyPrebytePassthroughE2E_invalid_prebyte_args_fail_in_subprocess) ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-prebyte-invalid-data-home"); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); const ProcessResult short_form = run_cli({"-p", "definitely-not-a-prebyte-input-file"}, workspace_path, env); diff --git a/tests/portability/cli/TempifyRemovedCommandsE2ETests.cpp b/tests/portability/cli/TempifyRemovedCommandsE2ETests.cpp index 4773a32..efb123f 100644 --- a/tests/portability/cli/TempifyRemovedCommandsE2ETests.cpp +++ b/tests/portability/cli/TempifyRemovedCommandsE2ETests.cpp @@ -32,7 +32,7 @@ TEST_CASE(TempifyRemovedCommandsE2E_subprocess_reports_migration_errors) { ScopedTempifyDataHome data_home(std::filesystem::temp_directory_path() / "tempify-removed-commands-data-home"); prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); - const std::filesystem::path workspace_path = workspace.path(); + const std::filesystem::path &workspace_path = workspace.path(); require_removed_command_subprocess({"template", "list"}, "template", "tempify list", workspace_path, env); require_removed_command_subprocess({"prebyte", "render"}, "prebyte", "tempify -p", workspace_path, env); diff --git a/tests/support/TestHarness.cpp b/tests/support/TestHarness.cpp index 9d13ab8..8ca8f74 100644 --- a/tests/support/TestHarness.cpp +++ b/tests/support/TestHarness.cpp @@ -55,7 +55,8 @@ TestRegistrar::TestRegistrar(std::string name, TestFunction function) { AssertionFailure::AssertionFailure(const std::string &message) : std::runtime_error(message) {} -ScopedEnvironmentVariable::ScopedEnvironmentVariable(std::string name, std::string value) : name_(std::move(name)) { +ScopedEnvironmentVariable::ScopedEnvironmentVariable(std::string name, const std::string &value) + : name_(std::move(name)) { if (const char *current = std::getenv(name_.c_str())) { previous_value_ = current; } diff --git a/tests/support/TestHarness.h b/tests/support/TestHarness.h index c0575e8..f178232 100644 --- a/tests/support/TestHarness.h +++ b/tests/support/TestHarness.h @@ -85,7 +85,7 @@ void assert_throws(Function &&function, const char *expr, const char *file, int #define TEST_CASE(name) \ static void name(); \ - static ::prebyte::test::TestRegistrar name##_registrar(#name, &name); \ + static ::prebyte::test::TestRegistrar name##_registrar(#name, &(name)); \ static void name() #define REQUIRE(expr) ::prebyte::test::assert_true((expr), #expr, __FILE__, __LINE__) diff --git a/tests/tempify/LocalTemplateStoreTests.cpp b/tests/tempify/LocalTemplateStoreTests.cpp index 2adf085..fd091c4 100644 --- a/tests/tempify/LocalTemplateStoreTests.cpp +++ b/tests/tempify/LocalTemplateStoreTests.cpp @@ -43,8 +43,8 @@ TEST_CASE(Paths_resolve_tempify_data_root_prefers_xdg_and_workspace_templates_ro const auto config_file = tempify::find_workspace_config_file(workspace.path() / "nested" / "deep"); REQUIRE(templates_root.has_value()); REQUIRE(config_file.has_value()); - REQUIRE_EQ(*templates_root, workspace.path() / "templates"); - REQUIRE_EQ(*config_file, workspace.path() / ".tempify" / "config.json"); + REQUIRE_EQ(templates_root.value(), workspace.path() / "templates"); + REQUIRE_EQ(config_file.value(), workspace.path() / ".tempify" / "config.json"); } TEST_CASE(LocalTemplateStore_missing_index_returns_empty) { @@ -88,7 +88,7 @@ TEST_CASE(LocalTemplateStore_refresh_writes_index_and_lists_shared_templates) { const auto found = store.find_template("sample_tpl"); REQUIRE(found.has_value()); - REQUIRE_EQ(found->id, std::string("sample_tpl")); + REQUIRE_EQ(found.value().id, std::string("sample_tpl")); REQUIRE(!store.find_template("missing").has_value()); } @@ -154,7 +154,7 @@ TEST_CASE(AvailableTemplateCache_loads_records_from_reqpack_cache) { const auto found = cache.find_template("java-xyz"); REQUIRE(found.has_value()); - REQUIRE_EQ(found->id, std::string("java-xyz")); + REQUIRE_EQ(found.value().id, std::string("java-xyz")); REQUIRE(!cache.find_template("missing").has_value()); } @@ -192,11 +192,11 @@ TEST_CASE(TempifyConfig_load_and_merge_support_defaults_and_render_overlays) { REQUIRE_EQ(merged.defaults.at("include_ci"), std::string("false")); REQUIRE_EQ(merged.defaults.at("author"), std::string("Workspace Author")); REQUIRE(merged.hook_acceptance.has_value()); - REQUIRE(*merged.hook_acceptance == tempify::HookAcceptance::Yes); + REQUIRE(merged.hook_acceptance.value() == tempify::HookAcceptance::Yes); REQUIRE(merged.hook_timeout_ms.has_value()); - REQUIRE_EQ(*merged.hook_timeout_ms, 1234); + REQUIRE_EQ(merged.hook_timeout_ms.value(), 1234); REQUIRE(merged.existing_path_behavior.has_value()); - REQUIRE(*merged.existing_path_behavior == tempify::ExistingPathBehavior::Skip); + REQUIRE(merged.existing_path_behavior.value() == tempify::ExistingPathBehavior::Skip); } TEST_CASE(TempifyConfig_unknown_keys_throw) { diff --git a/tests/tempify/LuaEngineQuestionTests.cpp b/tests/tempify/LuaEngineQuestionTests.cpp index 697cd11..5e38ee6 100644 --- a/tests/tempify/LuaEngineQuestionTests.cpp +++ b/tests/tempify/LuaEngineQuestionTests.cpp @@ -40,7 +40,7 @@ TEST_CASE(LuaEngine_evaluate_default_condition_and_validate_for_questions) { const auto slug_value = lua_engine.evaluate_default(slug, values); REQUIRE(slug_value.has_value()); - REQUIRE_EQ(*slug_value, std::string("stone-app")); + REQUIRE_EQ(slug_value.value(), std::string("stone-app")); REQUIRE(lua_engine.evaluate_condition(provider, values)); values["include_ci"] = "false"; diff --git a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp index 073891c..54c840d 100644 --- a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp +++ b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp @@ -8,7 +8,7 @@ namespace { -std::string shell_quote(std::string value) { +std::string shell_quote(const std::string &value) { std::string quoted = "\""; for (const char ch : value) { if (ch == '"') { From 0d25da63fa7d5a00d7536b63d1686d36d98a3017 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 01:03:23 +0200 Subject: [PATCH 23/29] Fixing failed text --- tests/support/TestHarness.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/support/TestHarness.h b/tests/support/TestHarness.h index f178232..7856d5b 100644 --- a/tests/support/TestHarness.h +++ b/tests/support/TestHarness.h @@ -31,7 +31,7 @@ class AssertionFailure : public std::runtime_error { class ScopedEnvironmentVariable { public: - ScopedEnvironmentVariable(std::string name, std::string value); + ScopedEnvironmentVariable(std::string name, const std::string &value); ScopedEnvironmentVariable(const ScopedEnvironmentVariable &) = delete; ScopedEnvironmentVariable &operator=(const ScopedEnvironmentVariable &) = delete; ~ScopedEnvironmentVariable(); From ea11fab109d5242ad3a9553a4daf6d99ab02acea Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 01:07:09 +0200 Subject: [PATCH 24/29] Fuu***** fixing --- tests/tempify/TempifyAppReapplyIntegrationTests.cpp | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp index 54c840d..5ec1ba7 100644 --- a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp +++ b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp @@ -107,8 +107,7 @@ TEST_CASE(TempifyApp_reapply_blocks_version_downgrade_with_structured_details) { REQUIRE_EQ(error.version_transition()->reason, std::string("backward_version_change")); REQUIRE_EQ(error.version_transition()->from_version, std::string("0.2.0")); REQUIRE_EQ(error.version_transition()->to_version, std::string("0.1.0")); - REQUIRE(std::find(error.review_paths().begin(), error.review_paths().end(), ".tempify-lock.json") != - error.review_paths().end()); + REQUIRE_EQ(error.version_transition()->lockfile_path, std::string(".tempify-lock.json")); } } @@ -165,8 +164,7 @@ TEST_CASE(TempifyApp_reapply_blocks_pre_1_0_minor_upgrade_with_structured_detail REQUIRE_EQ(error.version_transition()->reason, std::string("pre_1_0_minor_upgrade")); REQUIRE_EQ(error.version_transition()->from_version, std::string("0.0.9")); REQUIRE_EQ(error.version_transition()->to_version, std::string("0.1.0")); - REQUIRE(std::find(error.review_paths().begin(), error.review_paths().end(), ".tempify-lock.json") != - error.review_paths().end()); + REQUIRE_EQ(error.version_transition()->lockfile_path, std::string(".tempify-lock.json")); } } @@ -645,8 +643,6 @@ TEST_CASE(TempifyApp_reapply_blocks_origin_template_mismatch) { REQUIRE_EQ(error.origin_mismatch()->origin_template_id, std::string("layered_cpp_product")); REQUIRE_EQ(error.origin_mismatch()->requested_template_id, std::string("basic_cpp")); REQUIRE(!error.version_transition().has_value()); - REQUIRE(std::find(error.review_paths().begin(), error.review_paths().end(), ".tempify-lock.json") != - error.review_paths().end()); } REQUIRE_EQ(read_text_file(target.path() / ".tempify-lock.json"), original_lock); From 4824cc343b8b25fb53cbee848a31ab1099ddbc63 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 01:11:23 +0200 Subject: [PATCH 25/29] Fixing Format --- src/main/cpp/build/ReapplySerialization.cpp | 4 ++-- src/main/cpp/cli/CliHelp.cpp | 15 ++++++++++++--- src/main/include/tempify/support/Errors.h | 5 ++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/main/cpp/build/ReapplySerialization.cpp b/src/main/cpp/build/ReapplySerialization.cpp index 82dcb31..b59948e 100644 --- a/src/main/cpp/build/ReapplySerialization.cpp +++ b/src/main/cpp/build/ReapplySerialization.cpp @@ -361,8 +361,8 @@ ReapplyBlockedError build_reapply_blocked_error(const BuildDiffReport &report) { stream << "status unavailable"; } stream << ". Run `tempify --diff` to inspect."; - return ReapplyBlockedError(stream.str(), groups.conflict_paths, std::move(review_paths), - std::move(origin_mismatch), std::move(version_transition)); + return ReapplyBlockedError(stream.str(), groups.conflict_paths, std::move(review_paths), std::move(origin_mismatch), + std::move(version_transition)); } std::string format_reapply_blocked_error_json(const ReapplyBlockedError &error) { diff --git a/src/main/cpp/cli/CliHelp.cpp b/src/main/cpp/cli/CliHelp.cpp index 430b269..93c7716 100644 --- a/src/main/cpp/cli/CliHelp.cpp +++ b/src/main/cpp/cli/CliHelp.cpp @@ -365,6 +365,15 @@ std::string format_render_help(const CliRequest &request) { "tempify path/to/template out-dir --accept-hooks ask", "tempify reapply basic_cpp existing-dir --json", }); + const char *reapply_json_note = + "`--reapply --json` outputs machine-readable origin metadata, update kind, applied, kept, and " + "blocked details."; + const char *reapply_upgrade_policy_note = + "`--reapply` allows patch/prerelease upgrades, but major upgrades, pre-1.0 minor upgrades, downgrades, " + "or unknown version transitions require review."; + const char *reapply_lockfile_note = + "`--reapply` requires existing .tempify-lock.json, applies only ready create/update/delete actions, and " + "does not run hooks."; append_section( stream, "Notes", { @@ -376,11 +385,11 @@ std::string format_render_help(const CliRequest &request) { "`--diff` text output includes update recommendation based on origin lock and version transition.", "`--diff --json` outputs machine-readable status counts, origin metadata, and update kind.", "`--reapply --report` is report-only alias for reapply planning and does not write files.", - "`--reapply --json` outputs machine-readable origin metadata, update kind, applied, kept, and blocked details.", + reapply_json_note, "`tempify reapply ` is alias for `tempify --reapply`.", "`--reapply` blocks cross-template .tempify-lock.json reuse and requires matching origin template id.", - "`--reapply` allows patch/prerelease upgrades, but major upgrades, pre-1.0 minor upgrades, downgrades, or unknown version transitions require review.", - "`--reapply` requires existing .tempify-lock.json, applies only ready create/update/delete actions, and does not run hooks.", + reapply_upgrade_policy_note, + reapply_lockfile_note, }); return stream.str(); } diff --git a/src/main/include/tempify/support/Errors.h b/src/main/include/tempify/support/Errors.h index 7353fdb..ef42169 100644 --- a/src/main/include/tempify/support/Errors.h +++ b/src/main/include/tempify/support/Errors.h @@ -34,9 +34,8 @@ class ReapplyBlockedError : public TempifyError { std::vector review_paths, std::optional origin_mismatch = std::nullopt, std::optional version_transition = std::nullopt) - : TempifyError(message), conflict_paths_(std::move(conflict_paths)), - review_paths_(std::move(review_paths)), origin_mismatch_(std::move(origin_mismatch)), - version_transition_(std::move(version_transition)) {} + : TempifyError(message), conflict_paths_(std::move(conflict_paths)), review_paths_(std::move(review_paths)), + origin_mismatch_(std::move(origin_mismatch)), version_transition_(std::move(version_transition)) {} const std::vector &conflict_paths() const noexcept { return conflict_paths_; From f5768f581d55cc3e24897a5444df73b030a5c055 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 14:50:12 +0200 Subject: [PATCH 26/29] Fixing stuff --- src/main/cpp/build/ReapplySerialization.cpp | 10 ++- src/main/cpp/question/QuestionProcessor.cpp | 5 +- src/main/cpp/store/AvailableTemplateCache.cpp | 4 +- src/main/cpp/store/LocalTemplateStore.cpp | 6 +- .../tempify/store/AvailableTemplateCache.h | 2 +- .../tempify/store/LocalTemplateStore.h | 2 +- .../e2e/TempifyParallelCliE2ETests.cpp | 2 +- .../TempifySharedStateConcurrencyE2ETests.cpp | 4 +- tests/support/CliProcess.cpp | 8 +- tests/support/TestHarness.cpp | 8 +- tests/support/TestHarness.h | 13 ++- tests/tempify/CliParserTests.cpp | 21 ++--- tests/tempify/GenerationLockTests.cpp | 16 ++-- tests/tempify/LocalTemplateStoreTests.cpp | 19 ++--- tests/tempify/LuaEngineQuestionTests.cpp | 3 +- tests/tempify/QuestionFrontendTests.cpp | 28 +++--- tests/tempify/ReapplySerializationTests.cpp | 85 +++++++++++++++++++ 17 files changed, 165 insertions(+), 71 deletions(-) diff --git a/src/main/cpp/build/ReapplySerialization.cpp b/src/main/cpp/build/ReapplySerialization.cpp index b59948e..980c8ae 100644 --- a/src/main/cpp/build/ReapplySerialization.cpp +++ b/src/main/cpp/build/ReapplySerialization.cpp @@ -375,10 +375,11 @@ std::string format_reapply_blocked_error_json(const ReapplyBlockedError &error) append_json_string_array(stream, "conflict", error.conflict_paths(), true, 4); append_json_string_array(stream, "review", error.review_paths(), true, 4); stream << " \"origin_mismatch\": "; - if (!error.origin_mismatch().has_value()) { + const std::optional &origin_mismatch = error.origin_mismatch(); + if (!origin_mismatch.has_value()) { stream << "null,\n"; } else { - const auto &mismatch = error.origin_mismatch().value(); + const ReapplyOriginMismatchInfo &mismatch = *origin_mismatch; stream << "{\n"; stream << " \"lockfile\": \"" << json_escape(mismatch.lockfile_path) << "\",\n"; stream << " \"origin_template\": {\n"; @@ -392,10 +393,11 @@ std::string format_reapply_blocked_error_json(const ReapplyBlockedError &error) stream << " },\n"; } stream << " \"version_transition\": "; - if (!error.version_transition().has_value()) { + const std::optional &version_transition = error.version_transition(); + if (!version_transition.has_value()) { stream << "null\n"; } else { - const auto &transition = error.version_transition().value(); + const ReapplyVersionTransitionInfo &transition = *version_transition; stream << "{\n"; stream << " \"lockfile\": \"" << json_escape(transition.lockfile_path) << "\",\n"; stream << " \"kind\": \"" << json_escape(transition.kind) << "\",\n"; diff --git a/src/main/cpp/question/QuestionProcessor.cpp b/src/main/cpp/question/QuestionProcessor.cpp index 0e6fbfb..f23e642 100644 --- a/src/main/cpp/question/QuestionProcessor.cpp +++ b/src/main/cpp/question/QuestionProcessor.cpp @@ -147,8 +147,7 @@ std::map QuestionProcessor::collect(const TemplateMani if (strict) { for (const auto &[key, value] : explicit_imported) { static_cast(value); - if (!question_internal::is_question_key(manifest.questions, key) && - manifest.env_defaults.find(key) == manifest.env_defaults.end()) { + if (!question_internal::is_question_key(manifest.questions, key) && !manifest.env_defaults.contains(key)) { throw TempifyError("Unknown key '" + key + "' in answer file"); } } @@ -326,7 +325,7 @@ std::map QuestionProcessor::collect(const TemplateMani if (strict) { for (const auto &[key, value] : explicit_imported) { static_cast(value); - if (!question_internal::is_question_key(manifest.questions, key) && values.find(key) == values.end()) { + if (!question_internal::is_question_key(manifest.questions, key) && !values.contains(key)) { throw TempifyError("Unused key '" + key + "' in answer file"); } } diff --git a/src/main/cpp/store/AvailableTemplateCache.cpp b/src/main/cpp/store/AvailableTemplateCache.cpp index dc4a11e..48718a2 100644 --- a/src/main/cpp/store/AvailableTemplateCache.cpp +++ b/src/main/cpp/store/AvailableTemplateCache.cpp @@ -93,8 +93,8 @@ std::vector load_cache(const std::filesystem::path &pat } // namespace -AvailableTemplateCache::AvailableTemplateCache(std::filesystem::path shared_root) - : root_(std::filesystem::absolute(std::move(shared_root))), file_(root_ / "index" / "reqpack-available.json") {} +AvailableTemplateCache::AvailableTemplateCache(const std::filesystem::path &shared_root) + : root_(std::filesystem::absolute(shared_root)), file_(root_ / "index" / "reqpack-available.json") {} const std::filesystem::path &AvailableTemplateCache::file() const noexcept { return file_; diff --git a/src/main/cpp/store/LocalTemplateStore.cpp b/src/main/cpp/store/LocalTemplateStore.cpp index 87cb05c..3b48aed 100644 --- a/src/main/cpp/store/LocalTemplateStore.cpp +++ b/src/main/cpp/store/LocalTemplateStore.cpp @@ -119,9 +119,9 @@ void save_index(const std::filesystem::path &path, const std::vector> futures; diff --git a/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp b/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp index f2722e5..72dd2e0 100644 --- a/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp +++ b/tests/concurrency/e2e/TempifySharedStateConcurrencyE2ETests.cpp @@ -185,7 +185,7 @@ TEST_CASE(TempifySharedStateConcurrencyE2E_concurrent_renders_to_same_target_lea prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); const std::filesystem::path &workspace_path = workspace.path(); - const std::filesystem::path target_path = target.path(); + const std::filesystem::path &target_path = target.path(); constexpr int parallel_count = 4; std::vector> futures; @@ -219,7 +219,7 @@ TEST_CASE(TempifySharedStateConcurrencyE2E_concurrent_reapply_on_same_target_del prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); const std::filesystem::path &workspace_path = workspace.path(); - const std::filesystem::path target_path = target.path(); + const std::filesystem::path &target_path = target.path(); REQUIRE_EQ(run_cli(basic_cpp_render_args(target_path, "reapply-target"), workspace_path, env).exit_code, 0); inject_stale_managed_file(target_path, "old-managed.txt", "removed-by-template-update\n"); diff --git a/tests/support/CliProcess.cpp b/tests/support/CliProcess.cpp index 25b5675..5a8fd9b 100644 --- a/tests/support/CliProcess.cpp +++ b/tests/support/CliProcess.cpp @@ -47,9 +47,12 @@ ProcessResult run_cli_posix(const std::filesystem::path &executable, const std:: int stdout_pipe[2]{-1, -1}; int stderr_pipe[2]{-1, -1}; int stdin_pipe[2]{-1, -1}; - if (::pipe(stdout_pipe) != 0 || ::pipe(stderr_pipe) != 0 || (!stdin_text.empty() && ::pipe(stdin_pipe) != 0)) { + if (::pipe(stdout_pipe) != 0 || ::pipe(stderr_pipe) != 0) { throw std::runtime_error("failed to create subprocess pipes"); } + if (!stdin_text.empty() && ::pipe(stdin_pipe) != 0) { + throw std::runtime_error("failed to create subprocess stdin pipe"); + } const pid_t child_pid = ::fork(); if (child_pid < 0) { @@ -66,6 +69,9 @@ ProcessResult run_cli_posix(const std::filesystem::path &executable, const std:: ::dup2(stdout_pipe[1], STDOUT_FILENO); ::dup2(stderr_pipe[1], STDERR_FILENO); if (!stdin_text.empty()) { + if (stdin_pipe[0] < 0) { + _exit(127); + } ::dup2(stdin_pipe[0], STDIN_FILENO); } ::close(stdout_pipe[0]); diff --git a/tests/support/TestHarness.cpp b/tests/support/TestHarness.cpp index 8ca8f74..3976adc 100644 --- a/tests/support/TestHarness.cpp +++ b/tests/support/TestHarness.cpp @@ -49,8 +49,12 @@ std::vector test_names() { return names; } -TestRegistrar::TestRegistrar(std::string name, TestFunction function) { - registry().push_back(TestCase{std::move(name), function}); +TestRegistrar::TestRegistrar(const char *name, TestFunction function) noexcept { + try { + registry().push_back(TestCase{std::string(name), function}); + } catch (...) { + std::abort(); + } } AssertionFailure::AssertionFailure(const std::string &message) : std::runtime_error(message) {} diff --git a/tests/support/TestHarness.h b/tests/support/TestHarness.h index 7856d5b..c1886f5 100644 --- a/tests/support/TestHarness.h +++ b/tests/support/TestHarness.h @@ -21,7 +21,7 @@ const TestCase *find_test(std::string_view name); std::vector test_names(); struct TestRegistrar { - TestRegistrar(std::string name, TestFunction function); + TestRegistrar(const char *name, TestFunction function) noexcept; }; class AssertionFailure : public std::runtime_error { @@ -63,6 +63,16 @@ inline void assert_true(bool value, const char *expr, const char *file, int line } } +template +const T &require_value(const std::optional &value, const char *expr, const char *file, int line) { + if (!value.has_value()) { + std::ostringstream stream; + stream << file << ':' << line << " expected optional value: " << expr; + throw AssertionFailure(stream.str()); + } + return *value; +} + template void assert_throws(Function &&function, const char *expr, const char *file, int line) { try { @@ -89,6 +99,7 @@ void assert_throws(Function &&function, const char *expr, const char *file, int static void name() #define REQUIRE(expr) ::prebyte::test::assert_true((expr), #expr, __FILE__, __LINE__) +#define REQUIRE_VALUE(value) ::prebyte::test::require_value((value), #value, __FILE__, __LINE__) #define REQUIRE_EQ(left, right) ::prebyte::test::assert_equal((left), (right), #left, #right, __FILE__, __LINE__) #define REQUIRE_THROWS_AS(expr, exception_type) \ ::prebyte::test::assert_throws([&]() { (void)(expr); }, #expr, __FILE__, __LINE__) diff --git a/tests/tempify/CliParserTests.cpp b/tests/tempify/CliParserTests.cpp index a921e7c..21bd927 100644 --- a/tests/tempify/CliParserTests.cpp +++ b/tests/tempify/CliParserTests.cpp @@ -55,8 +55,7 @@ TEST_CASE(CliParser_parses_q_refresh_prebyte_info_doctor_validate_inspect_lint_t const tempify::CliRequest completion = parser.parse({"completion", "bash"}); REQUIRE(completion.mode == tempify::CliMode::Completion); - REQUIRE(completion.completion_shell.has_value()); - REQUIRE_EQ(*completion.completion_shell, std::string("bash")); + REQUIRE_EQ(REQUIRE_VALUE(completion.completion_shell), std::string("bash")); const tempify::CliRequest validate = parser.parse({"validate", "basic_cpp"}); REQUIRE(validate.mode == tempify::CliMode::TemplateValidate); @@ -88,8 +87,7 @@ TEST_CASE(CliParser_parses_q_refresh_prebyte_info_doctor_validate_inspect_lint_t const tempify::CliRequest test = parser.parse({"test", "basic_cpp", "--fixture", "ci_enabled", "--json"}); REQUIRE(test.mode == tempify::CliMode::TemplateTest); REQUIRE_EQ(test.template_ref, std::string("basic_cpp")); - REQUIRE(test.test_fixture_name.has_value()); - REQUIRE_EQ(*test.test_fixture_name, std::string("ci_enabled")); + REQUIRE_EQ(REQUIRE_VALUE(test.test_fixture_name), std::string("ci_enabled")); REQUIRE(!test.test_list_fixtures); REQUIRE(test.test_json); REQUIRE(!test.test_update_snapshots); @@ -143,15 +141,13 @@ TEST_CASE(CliParser_parses_q_refresh_prebyte_info_doctor_validate_inspect_lint_t }); REQUIRE(render.mode == tempify::CliMode::TemplateRender); REQUIRE_EQ(render.template_ref, std::string("basic_cpp")); - REQUIRE(render.target_dir.has_value()); - REQUIRE_EQ(render.target_dir->string(), std::string("out-dir")); + REQUIRE_EQ(REQUIRE_VALUE(render.target_dir).string(), std::string("out-dir")); REQUIRE_EQ(render.variables.at("project_name"), std::string("App")); REQUIRE_EQ(render.variables.at("namespace"), std::string("core")); REQUIRE(render.existing_path_behavior_override.has_value()); REQUIRE(render.existing_path_behavior_override == tempify::ExistingPathBehavior::Overwrite); REQUIRE(render.hook_acceptance == tempify::HookAcceptance::Ask); - REQUIRE(render.answers_file.has_value()); - REQUIRE_EQ(render.answers_file->string(), std::string("answers.json")); + REQUIRE_EQ(REQUIRE_VALUE(render.answers_file).string(), std::string("answers.json")); REQUIRE(!render.write_answers_file.has_value()); REQUIRE(render.non_interactive); REQUIRE(render.strict); @@ -199,8 +195,7 @@ TEST_CASE(CliParser_parses_q_refresh_prebyte_info_doctor_validate_inspect_lint_t }); REQUIRE(reapply_subcommand.mode == tempify::CliMode::TemplateRender); REQUIRE_EQ(reapply_subcommand.template_ref, std::string("basic_cpp")); - REQUIRE(reapply_subcommand.target_dir.has_value()); - REQUIRE_EQ(reapply_subcommand.target_dir->string(), std::string("out-dir")); + REQUIRE_EQ(REQUIRE_VALUE(reapply_subcommand.target_dir).string(), std::string("out-dir")); REQUIRE(reapply_subcommand.reapply); REQUIRE(reapply_subcommand.diff_json); REQUIRE_EQ(reapply_subcommand.variables.at("project_name"), std::string("App")); @@ -281,8 +276,7 @@ TEST_CASE(CliParser_routes_all_help_topics) { REQUIRE(reapply_with_args.help_topic == tempify::HelpTopic::Render); REQUIRE(reapply_with_args.reapply); REQUIRE_EQ(reapply_with_args.template_ref, std::string("basic_cpp")); - REQUIRE(reapply_with_args.target_dir.has_value()); - REQUIRE_EQ(reapply_with_args.target_dir->string(), std::string("out-dir")); + REQUIRE_EQ(REQUIRE_VALUE(reapply_with_args.target_dir).string(), std::string("out-dir")); const tempify::CliRequest questions = parser.parse({"basic_cpp", "-q", "--help"}); REQUIRE(questions.mode == tempify::CliMode::Help); @@ -306,8 +300,7 @@ TEST_CASE(CliParser_routes_all_help_topics) { REQUIRE(render.mode == tempify::CliMode::Help); REQUIRE(render.help_topic == tempify::HelpTopic::Render); REQUIRE_EQ(render.template_ref, std::string("basic_cpp")); - REQUIRE(render.target_dir.has_value()); - REQUIRE_EQ(render.target_dir->string(), std::string("out-dir")); + REQUIRE_EQ(REQUIRE_VALUE(render.target_dir).string(), std::string("out-dir")); } TEST_CASE(CliParser_rejects_invalid_assignments_unknown_options_and_extra_targets) { diff --git a/tests/tempify/GenerationLockTests.cpp b/tests/tempify/GenerationLockTests.cpp index c01d311..1f694f0 100644 --- a/tests/tempify/GenerationLockTests.cpp +++ b/tests/tempify/GenerationLockTests.cpp @@ -47,12 +47,12 @@ TEST_CASE(GenerationLock_loads_managed_files_and_template_metadata) { " }\n" "}\n"); - const std::optional record = tempify::load_generation_lock(path); - REQUIRE(record.has_value()); - REQUIRE_EQ(record->template_info.id, std::string("basic_cpp")); - REQUIRE_EQ(record->template_info.version, std::string("1.0.0")); - REQUIRE_EQ(record->build_root, std::string("/tmp/out")); - REQUIRE(record->managed_files.contains("README.md")); - REQUIRE_EQ(record->managed_file_hashes.at("README.md"), std::string("abc123")); - REQUIRE_EQ(record->values.at("project_name"), std::string("Demo")); + const std::optional loaded = tempify::load_generation_lock(path); + const auto &record = REQUIRE_VALUE(loaded); + REQUIRE_EQ(record.template_info.id, std::string("basic_cpp")); + REQUIRE_EQ(record.template_info.version, std::string("1.0.0")); + REQUIRE_EQ(record.build_root, std::string("/tmp/out")); + REQUIRE(record.managed_files.contains("README.md")); + REQUIRE_EQ(record.managed_file_hashes.at("README.md"), std::string("abc123")); + REQUIRE_EQ(record.values.at("project_name"), std::string("Demo")); } diff --git a/tests/tempify/LocalTemplateStoreTests.cpp b/tests/tempify/LocalTemplateStoreTests.cpp index fd091c4..50fb13d 100644 --- a/tests/tempify/LocalTemplateStoreTests.cpp +++ b/tests/tempify/LocalTemplateStoreTests.cpp @@ -41,10 +41,8 @@ TEST_CASE(Paths_resolve_tempify_data_root_prefers_xdg_and_workspace_templates_ro write_text_file(workspace.path() / ".tempify" / "config.json", "{}\n"); const auto templates_root = tempify::find_workspace_templates_root(workspace.path() / "nested" / "deep"); const auto config_file = tempify::find_workspace_config_file(workspace.path() / "nested" / "deep"); - REQUIRE(templates_root.has_value()); - REQUIRE(config_file.has_value()); - REQUIRE_EQ(templates_root.value(), workspace.path() / "templates"); - REQUIRE_EQ(config_file.value(), workspace.path() / ".tempify" / "config.json"); + REQUIRE_EQ(REQUIRE_VALUE(templates_root), workspace.path() / "templates"); + REQUIRE_EQ(REQUIRE_VALUE(config_file), workspace.path() / ".tempify" / "config.json"); } TEST_CASE(LocalTemplateStore_missing_index_returns_empty) { @@ -87,8 +85,7 @@ TEST_CASE(LocalTemplateStore_refresh_writes_index_and_lists_shared_templates) { REQUIRE(read_text_file(store.index_file()).find("sample_tpl") != std::string::npos); const auto found = store.find_template("sample_tpl"); - REQUIRE(found.has_value()); - REQUIRE_EQ(found.value().id, std::string("sample_tpl")); + REQUIRE_EQ(REQUIRE_VALUE(found).id, std::string("sample_tpl")); REQUIRE(!store.find_template("missing").has_value()); } @@ -153,8 +150,7 @@ TEST_CASE(AvailableTemplateCache_loads_records_from_reqpack_cache) { REQUIRE_EQ(entries[0].source_subdir, std::string("java-xyz")); const auto found = cache.find_template("java-xyz"); - REQUIRE(found.has_value()); - REQUIRE_EQ(found.value().id, std::string("java-xyz")); + REQUIRE_EQ(REQUIRE_VALUE(found).id, std::string("java-xyz")); REQUIRE(!cache.find_template("missing").has_value()); } @@ -191,12 +187,11 @@ TEST_CASE(TempifyConfig_load_and_merge_support_defaults_and_render_overlays) { REQUIRE_EQ(merged.defaults.at("project_name"), std::string("Workspace App")); REQUIRE_EQ(merged.defaults.at("include_ci"), std::string("false")); REQUIRE_EQ(merged.defaults.at("author"), std::string("Workspace Author")); - REQUIRE(merged.hook_acceptance.has_value()); - REQUIRE(merged.hook_acceptance.value() == tempify::HookAcceptance::Yes); + REQUIRE(REQUIRE_VALUE(merged.hook_acceptance) == tempify::HookAcceptance::Yes); REQUIRE(merged.hook_timeout_ms.has_value()); - REQUIRE_EQ(merged.hook_timeout_ms.value(), 1234); + REQUIRE_EQ(REQUIRE_VALUE(merged.hook_timeout_ms), 1234); REQUIRE(merged.existing_path_behavior.has_value()); - REQUIRE(merged.existing_path_behavior.value() == tempify::ExistingPathBehavior::Skip); + REQUIRE(REQUIRE_VALUE(merged.existing_path_behavior) == tempify::ExistingPathBehavior::Skip); } TEST_CASE(TempifyConfig_unknown_keys_throw) { diff --git a/tests/tempify/LuaEngineQuestionTests.cpp b/tests/tempify/LuaEngineQuestionTests.cpp index 5e38ee6..d79b02b 100644 --- a/tests/tempify/LuaEngineQuestionTests.cpp +++ b/tests/tempify/LuaEngineQuestionTests.cpp @@ -39,8 +39,7 @@ TEST_CASE(LuaEngine_evaluate_default_condition_and_validate_for_questions) { std::map values{{"project_name", "Stone App"}, {"include_ci", "true"}}; const auto slug_value = lua_engine.evaluate_default(slug, values); - REQUIRE(slug_value.has_value()); - REQUIRE_EQ(slug_value.value(), std::string("stone-app")); + REQUIRE_EQ(REQUIRE_VALUE(slug_value), std::string("stone-app")); REQUIRE(lua_engine.evaluate_condition(provider, values)); values["include_ci"] = "false"; diff --git a/tests/tempify/QuestionFrontendTests.cpp b/tests/tempify/QuestionFrontendTests.cpp index 9c0f26e..9920e8d 100644 --- a/tests/tempify/QuestionFrontendTests.cpp +++ b/tests/tempify/QuestionFrontendTests.cpp @@ -32,9 +32,9 @@ TEST_CASE(PlainCliFrontend_prompt_handles_submit_back_quit_and_eof) { ScopedStreamRedirect cout_redirect(std::cout, output.rdbuf()); const auto result = frontend.prompt("Name: ", false); - REQUIRE(result.has_value()); - REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Submit)); - REQUIRE_EQ(result->value, std::string("alpha")); + const auto &prompt_result = REQUIRE_VALUE(result); + REQUIRE_EQ(static_cast(prompt_result.action), static_cast(tempify::FrontendAction::Submit)); + REQUIRE_EQ(prompt_result.value, std::string("alpha")); REQUIRE(output.str().find("Name: ") != std::string::npos); } @@ -42,16 +42,16 @@ TEST_CASE(PlainCliFrontend_prompt_handles_submit_back_quit_and_eof) { std::istringstream input(":back\n"); ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); const auto result = frontend.prompt("Choice: ", false); - REQUIRE(result.has_value()); - REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Back)); + const auto &prompt_result = REQUIRE_VALUE(result); + REQUIRE_EQ(static_cast(prompt_result.action), static_cast(tempify::FrontendAction::Back)); } { std::istringstream input(":quit\n"); ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); const auto result = frontend.prompt("Choice: ", false); - REQUIRE(result.has_value()); - REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Quit)); + const auto &prompt_result = REQUIRE_VALUE(result); + REQUIRE_EQ(static_cast(prompt_result.action), static_cast(tempify::FrontendAction::Quit)); } { @@ -76,8 +76,8 @@ TEST_CASE(PlainCliFrontend_prompt_handles_submit_back_quit_and_eof) { std::istringstream input("secret\n"); ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); const auto result = frontend.prompt("Password: ", true); - REQUIRE(result.has_value()); - REQUIRE_EQ(result->value, std::string("secret")); + const auto &prompt_result = REQUIRE_VALUE(result); + REQUIRE_EQ(prompt_result.value, std::string("secret")); } } @@ -98,17 +98,17 @@ TEST_CASE(WizardFrontend_renders_group_headers_and_prompt_actions) { std::istringstream input("value\n"); ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); const auto result = frontend.prompt("Field: ", false); - REQUIRE(result.has_value()); - REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Submit)); - REQUIRE_EQ(result->value, std::string("value")); + const auto &prompt_result = REQUIRE_VALUE(result); + REQUIRE_EQ(static_cast(prompt_result.action), static_cast(tempify::FrontendAction::Submit)); + REQUIRE_EQ(prompt_result.value, std::string("value")); } { std::istringstream input(":back\n"); ScopedStreamRedirect cin_redirect(std::cin, input.rdbuf()); const auto result = frontend.prompt("Field: ", false); - REQUIRE(result.has_value()); - REQUIRE_EQ(static_cast(result->action), static_cast(tempify::FrontendAction::Back)); + const auto &prompt_result = REQUIRE_VALUE(result); + REQUIRE_EQ(static_cast(prompt_result.action), static_cast(tempify::FrontendAction::Back)); } { diff --git a/tests/tempify/ReapplySerializationTests.cpp b/tests/tempify/ReapplySerializationTests.cpp index c559046..6fef2bf 100644 --- a/tests/tempify/ReapplySerializationTests.cpp +++ b/tests/tempify/ReapplySerializationTests.cpp @@ -56,6 +56,60 @@ tempify::BuildDiffReport make_success_report() { return report; } +tempify::BuildDiffReport make_template_mismatch_report() { + tempify::BuildDiffReport report; + report.build_root = std::filesystem::path("/tmp/reapply-target"); + report.template_id = "basic_cpp"; + report.template_version = "1.0.0"; + report.origin.detected = true; + report.origin.matches_requested_template = false; + report.origin.matches_requested_version = false; + report.origin.lockfile_path = report.build_root / ".tempify-lock.json"; + report.origin.template_id = "layered_cpp_product"; + report.origin.template_version = "2.0.0"; + report.update.kind = tempify::BuildUpdateKind::TemplateMismatch; + report.update.from_version = "2.0.0"; + report.update.to_version = "1.0.0"; + report.update_policy.action = tempify::BuildUpdatePolicyAction::Review; + report.update_policy.reason = "template_mismatch"; + report.reapply.status = tempify::BuildReapplyStatus::Review; + report.reapply.review_count = 1; + report.entries.push_back({ + .relative_path = ".tempify-lock.json", + .status = tempify::BuildDiffStatus::Change, + .reason = tempify::BuildDiffReason::Conflict, + .reapply_action = tempify::BuildReapplyAction::Review, + }); + return report; +} + +tempify::BuildDiffReport make_version_transition_report() { + tempify::BuildDiffReport report; + report.build_root = std::filesystem::path("/tmp/reapply-target"); + report.template_id = "basic_cpp"; + report.template_version = "1.0.0"; + report.origin.detected = true; + report.origin.matches_requested_template = true; + report.origin.matches_requested_version = false; + report.origin.lockfile_path = report.build_root / ".tempify-lock.json"; + report.origin.template_id = "basic_cpp"; + report.origin.template_version = "0.0.9"; + report.update.kind = tempify::BuildUpdateKind::Upgrade; + report.update.from_version = "0.0.9"; + report.update.to_version = "1.0.0"; + report.update_policy.action = tempify::BuildUpdatePolicyAction::Review; + report.update_policy.reason = "pre_1_0_minor_upgrade"; + report.reapply.status = tempify::BuildReapplyStatus::Review; + report.reapply.review_count = 1; + report.entries.push_back({ + .relative_path = ".tempify-lock.json", + .status = tempify::BuildDiffStatus::Change, + .reason = tempify::BuildDiffReason::TemplateUpdate, + .reapply_action = tempify::BuildReapplyAction::Review, + }); + return report; +} + } // namespace TEST_CASE(ReapplySerialization_formats_successful_reapply_text_and_json) { @@ -83,3 +137,34 @@ TEST_CASE(ReapplySerialization_builds_blocked_error_for_conflicts) { REQUIRE(json.find("\"code\": \"REAPPLY_BLOCKED\"") != std::string::npos); REQUIRE(json.find("\"README.md\"") != std::string::npos); } + +TEST_CASE(ReapplySerialization_builds_blocked_error_for_origin_template_mismatch) { + const tempify::BuildDiffReport report = make_template_mismatch_report(); + const tempify::ReapplyBlockedError error = tempify::build_reapply_blocked_error(report); + + REQUIRE(std::string(error.what()).find("origin template mismatch") != std::string::npos); + REQUIRE(std::string(error.what()).find("layered_cpp_product") != std::string::npos); + REQUIRE(error.origin_mismatch().has_value()); + REQUIRE_EQ(error.origin_mismatch()->requested_template_id, std::string("basic_cpp")); + REQUIRE(!error.version_transition().has_value()); + + const std::string json = tempify::format_reapply_blocked_error_json(error); + REQUIRE(json.find("\"origin_mismatch\": {") != std::string::npos); + REQUIRE(json.find("\"version_transition\": null") != std::string::npos); + REQUIRE(json.find("\"layered_cpp_product\"") != std::string::npos); +} + +TEST_CASE(ReapplySerialization_builds_blocked_error_for_version_transition_review) { + const tempify::BuildDiffReport report = make_version_transition_report(); + const tempify::ReapplyBlockedError error = tempify::build_reapply_blocked_error(report); + + REQUIRE(std::string(error.what()).find("pre-1.0 minor upgrade") != std::string::npos); + REQUIRE(error.version_transition().has_value()); + REQUIRE_EQ(error.version_transition()->reason, std::string("pre_1_0_minor_upgrade")); + REQUIRE(!error.origin_mismatch().has_value()); + + const std::string json = tempify::format_reapply_blocked_error_json(error); + REQUIRE(json.find("\"origin_mismatch\": null") != std::string::npos); + REQUIRE(json.find("\"version_transition\": {") != std::string::npos); + REQUIRE(json.find("\"pre_1_0_minor_upgrade\"") != std::string::npos); +} From 62b1bc9da11807ea8adf8629d00678f8484bffd0 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 15:10:22 +0200 Subject: [PATCH 27/29] Fixing ci --- .github/workflows/ci.yml | 3 +- Makefile | 2 +- tests/tempify/ReapplySerializationTests.cpp | 8 ++--- .../TempifyAppReapplyIntegrationTests.cpp | 32 +++++++++---------- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea44ffc..2b1a477 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: ci on: push: branches: - - "**" + - main tags: - "v*" pull_request: @@ -280,7 +280,6 @@ jobs: - name: Run coverage tests run: make coverage COVERAGE_MIN_LINE=85 - - name: Upload coverage report uses: actions/upload-artifact@v4 with: diff --git a/Makefile b/Makefile index 7ddc8b3..9c79758 100644 --- a/Makefile +++ b/Makefile @@ -59,7 +59,7 @@ coverage: -DCMAKE_TOOLCHAIN_FILE="$$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ -DVCPKG_TARGET_TRIPLET="$$VCPKG_TARGET_TRIPLET" && \ cmake --build --preset coverage-tests --parallel && \ - ctest --preset coverage && \ + ctest --preset coverage -j1 && \ COVERAGE_MIN_LINE=$(COVERAGE_MIN_LINE) ./scripts/ci/generate_coverage_report.sh sanitize: diff --git a/tests/tempify/ReapplySerializationTests.cpp b/tests/tempify/ReapplySerializationTests.cpp index 6fef2bf..de602e4 100644 --- a/tests/tempify/ReapplySerializationTests.cpp +++ b/tests/tempify/ReapplySerializationTests.cpp @@ -144,8 +144,8 @@ TEST_CASE(ReapplySerialization_builds_blocked_error_for_origin_template_mismatch REQUIRE(std::string(error.what()).find("origin template mismatch") != std::string::npos); REQUIRE(std::string(error.what()).find("layered_cpp_product") != std::string::npos); - REQUIRE(error.origin_mismatch().has_value()); - REQUIRE_EQ(error.origin_mismatch()->requested_template_id, std::string("basic_cpp")); + const auto &origin_mismatch = REQUIRE_VALUE(error.origin_mismatch()); + REQUIRE_EQ(origin_mismatch.requested_template_id, std::string("basic_cpp")); REQUIRE(!error.version_transition().has_value()); const std::string json = tempify::format_reapply_blocked_error_json(error); @@ -159,8 +159,8 @@ TEST_CASE(ReapplySerialization_builds_blocked_error_for_version_transition_revie const tempify::ReapplyBlockedError error = tempify::build_reapply_blocked_error(report); REQUIRE(std::string(error.what()).find("pre-1.0 minor upgrade") != std::string::npos); - REQUIRE(error.version_transition().has_value()); - REQUIRE_EQ(error.version_transition()->reason, std::string("pre_1_0_minor_upgrade")); + const auto &version_transition = REQUIRE_VALUE(error.version_transition()); + REQUIRE_EQ(version_transition.reason, std::string("pre_1_0_minor_upgrade")); REQUIRE(!error.origin_mismatch().has_value()); const std::string json = tempify::format_reapply_blocked_error_json(error); diff --git a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp index 5ec1ba7..32e7c32 100644 --- a/tests/tempify/TempifyAppReapplyIntegrationTests.cpp +++ b/tests/tempify/TempifyAppReapplyIntegrationTests.cpp @@ -102,12 +102,12 @@ TEST_CASE(TempifyApp_reapply_blocks_version_downgrade_with_structured_details) { REQUIRE(false); } catch (const tempify::ReapplyBlockedError &error) { REQUIRE(std::string(error.what()).find("version downgrade") != std::string::npos); - REQUIRE(error.version_transition().has_value()); - REQUIRE_EQ(error.version_transition()->kind, std::string("downgrade")); - REQUIRE_EQ(error.version_transition()->reason, std::string("backward_version_change")); - REQUIRE_EQ(error.version_transition()->from_version, std::string("0.2.0")); - REQUIRE_EQ(error.version_transition()->to_version, std::string("0.1.0")); - REQUIRE_EQ(error.version_transition()->lockfile_path, std::string(".tempify-lock.json")); + const auto &version_transition = REQUIRE_VALUE(error.version_transition()); + REQUIRE_EQ(version_transition.kind, std::string("downgrade")); + REQUIRE_EQ(version_transition.reason, std::string("backward_version_change")); + REQUIRE_EQ(version_transition.from_version, std::string("0.2.0")); + REQUIRE_EQ(version_transition.to_version, std::string("0.1.0")); + REQUIRE_EQ(version_transition.lockfile_path, std::string(".tempify-lock.json")); } } @@ -159,12 +159,12 @@ TEST_CASE(TempifyApp_reapply_blocks_pre_1_0_minor_upgrade_with_structured_detail REQUIRE(false); } catch (const tempify::ReapplyBlockedError &error) { REQUIRE(std::string(error.what()).find("pre-1.0 minor upgrade") != std::string::npos); - REQUIRE(error.version_transition().has_value()); - REQUIRE_EQ(error.version_transition()->kind, std::string("upgrade")); - REQUIRE_EQ(error.version_transition()->reason, std::string("pre_1_0_minor_upgrade")); - REQUIRE_EQ(error.version_transition()->from_version, std::string("0.0.9")); - REQUIRE_EQ(error.version_transition()->to_version, std::string("0.1.0")); - REQUIRE_EQ(error.version_transition()->lockfile_path, std::string(".tempify-lock.json")); + const auto &version_transition = REQUIRE_VALUE(error.version_transition()); + REQUIRE_EQ(version_transition.kind, std::string("upgrade")); + REQUIRE_EQ(version_transition.reason, std::string("pre_1_0_minor_upgrade")); + REQUIRE_EQ(version_transition.from_version, std::string("0.0.9")); + REQUIRE_EQ(version_transition.to_version, std::string("0.1.0")); + REQUIRE_EQ(version_transition.lockfile_path, std::string(".tempify-lock.json")); } } @@ -638,10 +638,10 @@ TEST_CASE(TempifyApp_reapply_blocks_origin_template_mismatch) { } catch (const tempify::ReapplyBlockedError &error) { REQUIRE(std::string(error.what()).find("origin template mismatch") != std::string::npos); REQUIRE(std::string(error.what()).find("layered_cpp_product") != std::string::npos); - REQUIRE(error.origin_mismatch().has_value()); - REQUIRE_EQ(error.origin_mismatch()->lockfile_path, std::string(".tempify-lock.json")); - REQUIRE_EQ(error.origin_mismatch()->origin_template_id, std::string("layered_cpp_product")); - REQUIRE_EQ(error.origin_mismatch()->requested_template_id, std::string("basic_cpp")); + const auto &origin_mismatch = REQUIRE_VALUE(error.origin_mismatch()); + REQUIRE_EQ(origin_mismatch.lockfile_path, std::string(".tempify-lock.json")); + REQUIRE_EQ(origin_mismatch.origin_template_id, std::string("layered_cpp_product")); + REQUIRE_EQ(origin_mismatch.requested_template_id, std::string("basic_cpp")); REQUIRE(!error.version_transition().has_value()); } From 610391a5be4fcf3a806ae3b7e56dcd04d642e3da Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 15:29:14 +0200 Subject: [PATCH 28/29] Fixing --- .github/workflows/ci.yml | 3 +- tests/tempify/LocalTemplateStoreTests.cpp | 36 +++++++++++++++++++ tests/tempify/QuestionProcessorTests.cpp | 38 +++++++++++++++++++++ tests/tempify/ReapplySerializationTests.cpp | 13 +++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b1a477..6f61250 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,7 +266,8 @@ jobs: - name: Install Linux prerequisites run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd g++ gcovr + sudo apt-get install -y --no-install-recommends ninja-build pkg-config zstd g++ python3-pip + python3 -m pip install --break-system-packages 'gcovr>=8.6' - name: Bootstrap vcpkg dependencies run: | diff --git a/tests/tempify/LocalTemplateStoreTests.cpp b/tests/tempify/LocalTemplateStoreTests.cpp index 50fb13d..ce85901 100644 --- a/tests/tempify/LocalTemplateStoreTests.cpp +++ b/tests/tempify/LocalTemplateStoreTests.cpp @@ -218,3 +218,39 @@ TEST_CASE(HookTrustStore_persists_trusted_template_roots) { REQUIRE(trust_store_text.find("trusted_templates") != std::string::npos); REQUIRE(trust_store_text.find("trusted") != std::string::npos); } + +TEST_CASE(HookTrustStore_rejects_invalid_and_malformed_store_files) { + ScopedDirectoryCleanup data_home(std::filesystem::temp_directory_path() / "tempify-hook-trust-store-invalid-test"); + const std::filesystem::path store_path = tempify::default_hook_trust_store_path(data_home.path()); + const std::filesystem::path trusted_root = data_home.path() / "templates" / "trusted"; + std::filesystem::create_directories(trusted_root); + tempify::HookTrustStore trust_store(store_path); + + write_text_file(store_path, "{ invalid json\n"); + REQUIRE_THROWS_AS(static_cast(trust_store.is_trusted(trusted_root)), tempify::TempifyError); + + write_text_file(store_path, "[]\n"); + REQUIRE_THROWS_AS(static_cast(trust_store.is_trusted(trusted_root)), tempify::TempifyError); +} + +TEST_CASE(HookTrustStore_ignores_invalid_entries_and_persists_multiple_roots) { + ScopedDirectoryCleanup data_home(std::filesystem::temp_directory_path() / "tempify-hook-trust-store-multi-test"); + const std::filesystem::path store_path = tempify::default_hook_trust_store_path(data_home.path()); + const std::filesystem::path first_root = data_home.path() / "templates" / "first"; + const std::filesystem::path second_root = data_home.path() / "templates" / "second"; + std::filesystem::create_directories(first_root); + std::filesystem::create_directories(second_root); + tempify::HookTrustStore trust_store(store_path); + + write_text_file(store_path, "{\n \"trusted_templates\": [42, true]\n}\n"); + REQUIRE(!trust_store.is_trusted(first_root)); + + trust_store.trust(first_root); + trust_store.trust(second_root); + REQUIRE(trust_store.is_trusted(first_root)); + REQUIRE(trust_store.is_trusted(second_root)); + + const std::string trust_store_text = read_text_file(store_path); + REQUIRE(trust_store_text.find("templates/first") != std::string::npos); + REQUIRE(trust_store_text.find("templates/second") != std::string::npos); +} diff --git a/tests/tempify/QuestionProcessorTests.cpp b/tests/tempify/QuestionProcessorTests.cpp index 42fa33a..b5c9631 100644 --- a/tests/tempify/QuestionProcessorTests.cpp +++ b/tests/tempify/QuestionProcessorTests.cpp @@ -256,6 +256,44 @@ TEST_CASE(QuestionProcessor_group_navigation_back_reasks_previous_page) { REQUIRE(frontend.groups.size() >= static_cast(3)); } +TEST_CASE(QuestionProcessor_derives_group_order_from_unordered_questions) { + tempify::TemplateManifest manifest; + manifest.questions = { + tempify::QuestionDefinition{ + .key = "beta_value", + .type = "string", + .prompt = "Beta value", + .group = "Beta", + }, + tempify::QuestionDefinition{ + .key = "alpha_value", + .type = "string", + .prompt = "Alpha value", + .group = "Alpha", + }, + tempify::QuestionDefinition{ + .key = "general_value", + .type = "string", + .prompt = "General value", + }, + }; + + tempify::LuaEngine lua_engine; + StubFrontend frontend({ + "beta-1", + "alpha-1", + "general-1", + }); + + tempify::QuestionProcessor processor(lua_engine, frontend); + const std::map values = processor.collect(manifest, {}); + + REQUIRE_EQ(values.at("beta_value"), std::string("beta-1")); + REQUIRE_EQ(values.at("alpha_value"), std::string("alpha-1")); + REQUIRE_EQ(values.at("general_value"), std::string("general-1")); + REQUIRE_EQ(frontend.groups.size(), static_cast(3)); +} + TEST_CASE(QuestionProcessor_review_stage_can_go_back_and_confirm) { const tempify::TemplateManifest manifest = load_basic_cpp_manifest(); tempify::LuaEngine lua_engine; diff --git a/tests/tempify/ReapplySerializationTests.cpp b/tests/tempify/ReapplySerializationTests.cpp index de602e4..97df10a 100644 --- a/tests/tempify/ReapplySerializationTests.cpp +++ b/tests/tempify/ReapplySerializationTests.cpp @@ -168,3 +168,16 @@ TEST_CASE(ReapplySerialization_builds_blocked_error_for_version_transition_revie REQUIRE(json.find("\"version_transition\": {") != std::string::npos); REQUIRE(json.find("\"pre_1_0_minor_upgrade\"") != std::string::npos); } + +TEST_CASE(ReapplySerialization_escapes_special_characters_in_blocked_error_json) { + tempify::BuildDiffReport report = make_conflict_report(); + report.build_root = std::filesystem::path("/tmp/reapply\"target\n"); + report.entries.front().relative_path = std::filesystem::path("conflict\\file\t.txt"); + const tempify::ReapplyBlockedError error = tempify::build_reapply_blocked_error(report); + + const std::string json = tempify::format_reapply_blocked_error_json(error); + REQUIRE(json.find("\\\"") != std::string::npos); + REQUIRE(json.find("\\n") != std::string::npos); + REQUIRE(json.find("\\t") != std::string::npos); + REQUIRE(json.find("\\\\") != std::string::npos); +} From 72b7c392b5073dad7c480e5708529ee82b3b5763 Mon Sep 17 00:00:00 2001 From: Leonard Ramminger Date: Sun, 23 Aug 2026 15:52:18 +0200 Subject: [PATCH 29/29] Fixing Security --- .../e2e/TempifyParallelCliE2ETests.cpp | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp b/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp index 5e80dcf..960b5e4 100644 --- a/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp +++ b/tests/concurrency/e2e/TempifyParallelCliE2ETests.cpp @@ -2,7 +2,6 @@ #include "E2ETestSupport.h" #include "TestHarness.h" -#include #include #include #include @@ -43,31 +42,32 @@ TEST_CASE(TempifyConcurrencyE2E_parallel_renders_succeed_in_subprocesses) { prepare_template_workspace(workspace.path()); const auto env = isolated_cli_env(data_home.path()); + ScopedDirectoryCleanup target0(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-0"); + ScopedDirectoryCleanup target1(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-1"); + ScopedDirectoryCleanup target2(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-2"); + ScopedDirectoryCleanup target3(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-3"); constexpr int parallel_count = 4; - std::array target_paths; - std::array targets{{ - ScopedDirectoryCleanup(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-0"), - ScopedDirectoryCleanup(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-1"), - ScopedDirectoryCleanup(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-2"), - ScopedDirectoryCleanup(std::filesystem::temp_directory_path() / "tempify-concurrency-e2e-target-3"), - }}; - for (int index = 0; index < parallel_count; ++index) { - target_paths[static_cast(index)] = targets[static_cast(index)].path(); - } + const std::vector target_paths = { + target0.path(), + target1.path(), + target2.path(), + target3.path(), + }; std::vector> futures; futures.reserve(parallel_count); for (int index = 0; index < parallel_count; ++index) { const std::string slug = "parallel-" + std::to_string(index); - futures.push_back(std::async(std::launch::async, [workspace_path = workspace.path(), env, args = parallel_render_args(target_paths[static_cast(index)], slug)]() { + futures.push_back(std::async(std::launch::async, [workspace_path = workspace.path(), env, + args = parallel_render_args(target_paths.at(static_cast(index)), slug)]() { return run_cli(args, workspace_path, env); })); } for (int index = 0; index < parallel_count; ++index) { - const ProcessResult result = futures[static_cast(index)].get(); + const ProcessResult result = futures.at(static_cast(index)).get(); REQUIRE_EQ(result.exit_code, 0); - const std::filesystem::path target_path = target_paths[static_cast(index)]; + const std::filesystem::path &target_path = target_paths.at(static_cast(index)); REQUIRE(std::filesystem::is_regular_file(target_path / "README.md")); REQUIRE(read_text_file(target_path / "README.md").find("# Parallel parallel-") != std::string::npos); } @@ -124,7 +124,7 @@ TEST_CASE(TempifyConcurrencyE2E_parallel_renders_to_same_target_without_overwrit std::size_t success_count = 0; std::size_t failure_count = 0; for (int index = 0; index < parallel_count; ++index) { - const ProcessResult result = futures[static_cast(index)].get(); + const ProcessResult result = futures.at(static_cast(index)).get(); if (result.exit_code == 0) { ++success_count; } else {