From f6dddbf5a36c7aa6aca9e686ef7fbf4aef33787b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 15:16:55 -0400 Subject: [PATCH] feat(version): single-source semver, ext-state stamp, show-version action Version "0.9.01" sourced once from CMake via configure_file'd header; pure app_version module (parse/compare/classify) with CTest. Stamp rides the persist save seam under "reasampler"/"version"; absent stamp reads as pre-versioning. On-demand "show version" action, no startup print. --- CMakeLists.txt | 42 ++++++++++- src/app_version.cpp | 76 ++++++++++++++++++++ src/app_version.h | 73 ++++++++++++++++++++ src/main.cpp | 31 +++++++++ src/persist.cpp | 18 +++++ src/persist.h | 24 +++++++ src/version_generated.h.in | 9 +++ tests/test_app_version.cpp | 138 +++++++++++++++++++++++++++++++++++++ 8 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 src/app_version.cpp create mode 100644 src/app_version.h create mode 100644 src/version_generated.h.in create mode 100644 tests/test_app_version.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c565887..a1b5497 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,10 +1,30 @@ cmake_minimum_required(VERSION 3.19) -project(reaper_reasampler LANGUAGES CXX) + +# --------------------------------------------------------------------------- +# Version — SINGLE SOURCE OF TRUTH (Phase V, V1). Edit REASAMPLER_VERSION here and +# nowhere else: it flows to the binary constant, the ext-state writing-version stamp, +# and the "show version" action via a configure_file'd header (below). The string is +# authoritative verbatim — leading zero preserved (Daniel-fixed: exactly "0.9.01", +# two-digit zero-padded patch). We deliberately do NOT reconstruct the display string +# from project(VERSION)'s numeric components, since CMake may normalize a numeric patch +# field; the string variable is what renders. project(VERSION ...) is still set (with a +# normalized 0.9.1 triple) for CMake hygiene / any downstream numeric use, but it is NOT +# the rendered source of truth. +set(REASAMPLER_VERSION "0.9.01") + +project(reaper_reasampler VERSION 0.9.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) +# Generate version_generated.h from the one REASAMPLER_VERSION variable. Regenerated at +# configure time whenever the variable changes; the exact string is substituted verbatim. +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/src/version_generated.h.in + ${CMAKE_CURRENT_BINARY_DIR}/generated/version_generated.h + @ONLY) + # --------------------------------------------------------------------------- # Vendored dependencies — add as git submodules (see README): # git submodule add https://github.com/justinfrankel/reaper-sdk vendor/reaper-sdk @@ -195,6 +215,19 @@ add_library(wav_trim STATIC src/wav_trim.cpp) target_include_directories(wav_trim PUBLIC src) target_link_libraries(wav_trim PUBLIC peaks) +# --------------------------------------------------------------------------- +# 2i) Pure app_version library — NO REAPER, NO SWELL. The Phase V (V1) version-identity +# core: re-exports the ONE CMake-sourced version string (via the configure_file'd +# version_generated.h) and owns the pure parse/compare/classify logic — the semver +# ordering a within-channel forward migration needs, and the three-way writing-version +# classification (PreVersioning / Unknown / Stamped) persist reads back from ext state. +# Split out so the exact-string fidelity + parse/compare are unit-tested outside the +# DAW; the ext-state write/read (persist) and the show-version action (main) are shell. +# Depends on the generated header in the build tree (PUBLIC so every consumer sees it). +# --------------------------------------------------------------------------- +add_library(app_version STATIC src/app_version.cpp) +target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -267,6 +300,10 @@ add_executable(owned_manifest_tests tests/test_owned_manifest.cpp) target_link_libraries(owned_manifest_tests PRIVATE owned_manifest) add_test(NAME owned_manifest_tests COMMAND owned_manifest_tests) +add_executable(app_version_tests tests/test_app_version.cpp) +target_link_libraries(app_version_tests PRIVATE app_version) +add_test(NAME app_version_tests COMMAND app_version_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -303,8 +340,9 @@ add_library(reaper_reasampler MODULE src/actions.cpp src/bank_book.cpp src/owned_manifest.cpp + src/app_version.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest app_version) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") diff --git a/src/app_version.cpp b/src/app_version.cpp new file mode 100644 index 0000000..9a995c3 --- /dev/null +++ b/src/app_version.cpp @@ -0,0 +1,76 @@ +// app_version.cpp — implementation of the pure version-identity core (Phase V, V1). +// See app_version.h for the contract. The version STRING itself comes from +// version_generated.h (produced by CMake configure_file from the one REASAMPLER_VERSION +// variable) — this TU just re-exports it and owns the pure parse/compare/classify logic. + +#include "app_version.h" + +#include + +#include "version_generated.h" // REASAMPLER_VERSION_STRING — configure_file'd from CMake + +namespace reasampler { + +const std::string& appVersion() { + // Function-local static: initialized once from the compile-time string, returned by + // const ref so callers share the one authoritative instance. The macro is the exact + // CMake value, leading zero and all. + static const std::string kVersion = REASAMPLER_VERSION_STRING; + return kVersion; +} + +std::optional parseVersion(const std::string& s) { + // Split on '.' into exactly three non-empty all-digit components. No sign, no + // whitespace, no trailing garbage. Leading zeros are allowed (0.9.01 parses to + // {0,9,1}) — the display fidelity of the zero is the string's job, not this parse's. + Version v; + int component = 0; // 0=major, 1=minor, 2=patch + long long acc = 0; // current component accumulator (long long guards overflow) + bool digitsInComponent = false; + int* const out[3] = {&v.major, &v.minor, &v.patch}; + + for (char c : s) { + if (c == '.') { + if (!digitsInComponent) return std::nullopt; // empty component (".", "1..2") + if (component >= 2) return std::nullopt; // too many dots + *out[component] = static_cast(acc); + ++component; + acc = 0; + digitsInComponent = false; + continue; + } + if (c < '0' || c > '9') return std::nullopt; // non-digit (sign, letter, ws) + acc = acc * 10 + (c - '0'); + if (acc > 1'000'000'000LL) return std::nullopt; // absurdly large -> reject + digitsInComponent = true; + } + if (component != 2 || !digitsInComponent) return std::nullopt; // too few components + *out[2] = static_cast(acc); + return v; +} + +bool versionLess(const Version& a, const Version& b) { + if (a.major != b.major) return a.major < b.major; + if (a.minor != b.minor) return a.minor < b.minor; + return a.patch < b.patch; +} + +WritingVersion classifyWritingVersion(const std::string& rawStamp) { + WritingVersion wv; + if (rawStamp.empty()) { + wv.kind = WritingVersion::Kind::PreVersioning; // no stamp -> pre-versioning + return wv; + } + std::optional parsed = parseVersion(rawStamp); + if (!parsed) { + wv.kind = WritingVersion::Kind::Unknown; // present but malformed -> ignored + wv.raw = rawStamp; + return wv; + } + wv.kind = WritingVersion::Kind::Stamped; + wv.raw = rawStamp; + wv.parsed = *parsed; + return wv; +} + +} // namespace reasampler diff --git a/src/app_version.h b/src/app_version.h new file mode 100644 index 0000000..98ee101 --- /dev/null +++ b/src/app_version.h @@ -0,0 +1,73 @@ +#pragma once +// app_version — the REAPER-free version-identity core (Phase V, V1). The single +// source of truth for the version STRING lives in CMake (a `REASAMPLER_VERSION` +// variable threaded in via configure_file -> version_generated.h); this module +// re-exports it as the canonical constant and owns every pure operation on it: the +// exact-string render, the parse/compare arithmetic a within-channel forward +// migration will lean on, and the "which version wrote this project" result that +// persist reads back from ext state (absent stamp = pre-versioning, never an error). +// +// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library +// only. Builds and unit-tests without REAPER (mirror of bank_model / tail_control). +// +// Leading-zero fidelity (V1, Daniel-fixed): the displayed/stamped string is EXACTLY +// "0.9.01" — two-digit zero-padded patch. That exactness is why the version STRING is +// the authoritative artifact (sourced verbatim from the one CMake variable), not a +// reconstruction from numeric components — CMake's `project(VERSION)` may normalize a +// numeric patch field, so we never round-trip the string through integers to render it. + +#include +#include + +namespace reasampler { + +// The canonical version string — EXACTLY the value of the CMake `REASAMPLER_VERSION` +// variable (see version_generated.h, produced by configure_file). One edit point: +// changing that variable changes this constant, the ext-state stamp, and the +// show-version action output with no other edits. Leading zero preserved verbatim. +const std::string& appVersion(); + +// A parsed semver triple. Kept minimal — major.minor.patch as integers, for ORDERING +// only. It deliberately does NOT round-trip back to the display string (the leading +// zero is a rendering concern owned by the authoritative string, not reconstructable +// from the integer patch). parseVersion returns nullopt on malformed input. +struct Version { + int major = 0; + int minor = 0; + int patch = 0; +}; + +// Parse "x.y.z" (each component a non-negative integer, leading zeros allowed) into a +// Version. Returns nullopt on anything malformed: wrong component count, non-digits, a +// leading `-`, empty components, or trailing garbage. Used for ordering two stamps and +// for validating a stored stamp before comparing. +std::optional parseVersion(const std::string& s); + +// Numeric ordering by (major, minor, patch). a < b iff a precedes b. So +// 0.9.01 < 0.9.02 < 0.10.01 (numeric compare, NOT lexicographic — 10 > 9). +bool versionLess(const Version& a, const Version& b); + +// The writing-version a project was last saved with, recovered from its ext-state +// stamp. A project saved before this feature shipped has NO stamp — that is the +// explicit `preVersioning` case (kind), not an error and not a warning. A present-but- +// malformed value is `unknown` (also silent — a corrupt stamp is ignored, never +// throws). A well-formed value is `stamped` and carries the exact stored string plus +// its parsed triple for comparison. +struct WritingVersion { + enum class Kind { + PreVersioning, // no stamp stored — a project written before versioning shipped + Unknown, // a stamp was stored but is not parseable — ignored, not an error + Stamped, // a well-formed stamp + }; + Kind kind = Kind::PreVersioning; + std::string raw; // the exact stored string (empty for PreVersioning) + Version parsed; // meaningful only when kind == Stamped +}; + +// Classify a raw stored stamp value (exactly what GetProjExtState returned for the +// version key). Empty -> PreVersioning; non-empty but unparseable -> Unknown; parseable +// -> Stamped. Pure so the three-way classification is test-pinned; persist calls this +// with the raw ext-state read and never has to reason about the cases itself. +WritingVersion classifyWritingVersion(const std::string& rawStamp); + +} // namespace reasampler diff --git a/src/main.cpp b/src/main.cpp index 26c18a9..691b01f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -24,6 +24,7 @@ #include #include "actions.h" +#include "app_version.h" #include "bank_model.h" #include "bank_panel.h" #include "capture.h" @@ -109,6 +110,13 @@ static int g_cmdCaptureTrackRealtime = 0; // the transport-stop. No-op (with a note) when nothing is in flight. static int g_cmdCancelRealtime = 0; +// Command id for the Phase V "show version" action. FOREVER-STABLE string. On demand +// ONLY — prints the CMake-sourced version string to the console when fired. This is the +// SOLE new console output the versioning wave adds; there is no unconditional startup +// version print (routine console chatter was deliberately removed — it pops the console +// window). The user copies this line into a bug report. +static int g_cmdShowVersion = 0; + // The persistence session (M4): owns the in-memory BankIndex and bridges it to // project ext state. A timer tick drives g_session.poll() to detect project // load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to @@ -771,6 +779,12 @@ static bool OnHookCommand(int command, int /*flag*/) if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; } if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; } + if (command == g_cmdShowVersion) + { + // On-demand version readout — the ONLY version output on any path. + ShowConsoleMsg(("ReaSampler " + reasampler::appVersion() + "\n").c_str()); + return true; + } // Design View action family (D4). Claims only its own ids; returns false for the // rest so this hook keeps looking (per the contract). if (reasampler::designViewHandleCommand(command)) return true; @@ -795,6 +809,7 @@ static gaccel_register_t g_accelInsertSelected{}; static gaccel_register_t g_accelInsertSelectedConform{}; static gaccel_register_t g_accelCaptureTrackRealtime{}; static gaccel_register_t g_accelCancelRealtime{}; +static gaccel_register_t g_accelShowVersion{}; extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec) @@ -826,6 +841,9 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( reasampler::designViewUnregisterActions(g_rec); // Tear down the multi-bank action family (B3) — same mirror-unregister. reasampler::bankUnregisterActions(g_rec); + g_rec->Register("-gaccel", (void*)&g_accelShowVersion); + g_rec->Register("-command_id", + (void*)(REASAMPLER_ACTION_PREFIX "SHOW_VERSION")); g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime); g_rec->Register("-command_id", (void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE")); @@ -969,6 +987,19 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( rec->Register("gaccel", (void*)&g_accelCancelRealtime); } + // Register the Phase V "show version" action (command_id -> gaccel -> hookcommand). + // On-demand only — prints the CMake-sourced version to the console when fired; no + // startup print. FOREVER-STABLE id string. + g_cmdShowVersion = rec->Register( + "command_id", + (void*)(REASAMPLER_ACTION_PREFIX "SHOW_VERSION")); + if (g_cmdShowVersion) + { + g_accelShowVersion.accel.cmd = g_cmdShowVersion; + g_accelShowVersion.desc = "ReaSampler: show version"; + rec->Register("gaccel", (void*)&g_accelShowVersion); + } + // Register the Design View action family (D4): toggle/activate mode, tag/untag/ // show-both selected tracks. Each mints its own command_id + gaccel; the single // hookcommand below routes them via designViewHandleCommand. Registered before diff --git a/src/persist.cpp b/src/persist.cpp index 3f49a08..b1d56cf 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -79,6 +79,7 @@ #include #include +#include "app_version.h" #include "capture_paths.h" #define REAPERAPI_MINIMAL @@ -226,6 +227,13 @@ bool ReaSamplerSession::saveToActiveProject() { SetProjExtState(static_cast(proj), kProjExtNamespace, kProjExtOwnedKey, ownedJson.c_str()); + // Phase V (V1): stamp the WRITING version — the build producing this save — under the + // version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty + // stay paired (no drifting ad-hoc SetProjExtState). appVersion() is the one CMake- + // sourced constant; every save records the exact current build into the .rpp. + SetProjExtState(static_cast(proj), kProjExtNamespace, + kProjExtVersionKey, appVersion().c_str()); + MarkProjectDirty(static_cast(proj)); return true; } @@ -315,6 +323,16 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // (R-B) must re-read the restored manifest so it matches the rolled-back bank state. owned_ = loadOwnedManifest(static_cast(proj)); + // Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry + // with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed + // one as Unknown — both silent, no console warning (a pre-versioning project is not + // an error). getProjExtStateString returns "" for an absent key, which is exactly the + // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default. + writingVersion_ = classifyWritingVersion( + proj ? getProjExtStateString(static_cast(proj), kProjExtNamespace, + kProjExtVersionKey) + : std::string{}); + if (!proj) { book_ = BankBook{}; return; diff --git a/src/persist.h b/src/persist.h index 75252fa..b61d03d 100644 --- a/src/persist.h +++ b/src/persist.h @@ -19,6 +19,7 @@ #include +#include "app_version.h" #include "bank_book.h" #include "bank_model.h" #include "owned_manifest.h" @@ -70,6 +71,14 @@ inline constexpr const char* kProjExtTailKey = "tail_setting"; // graceful, but the attribution safety net is lost until the next capture rebuilds it). inline constexpr const char* kProjExtOwnedKey = "owned_files"; +// The ext-state key holding the ReaSampler version that last WROTE this project +// (Phase V, V1). Written on every save alongside the banks/view/tail keys, so every +// saved .rpp records which build produced its state — the seam a future within-channel +// forward migration keys off ("this was written by 0.9.01, I am 0.9.05"). An absent +// key is the explicit pre-versioning case (a project saved before this shipped), read +// silently, never an error. FOREVER-STABLE key string once shipped. +inline constexpr const char* kProjExtVersionKey = "version"; + // The ext-state key holding a GUID we mint per project to establish CONTENT-BASED // project identity (REAPER exposes no stable per-project GUID). poll() uses it to // tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch @@ -143,6 +152,15 @@ public: OwnedFileManifest& owned() { return owned_; } const OwnedFileManifest& owned() const { return owned_; } + // The ReaSampler version that last WROTE the active project, recovered from its + // ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no + // stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the + // exact stored string otherwise — all silent, never an error. Replaced on every load + // path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved + // or never-loaded session. Exposed so a future migration step (or diagnostics) can + // reason about the origin build without re-reading ext state. + const WritingVersion& writingVersion() const { return writingVersion_; } + // Serialize the current book (under the `banks` key), view model, and tail setting // to the active project's ext state (namespace "reasampler"), and clear the retired // legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys. @@ -208,6 +226,12 @@ private: // the in-memory set matches disk. Absent key -> empty is graceful (older project). OwnedFileManifest owned_; + // The writing-version stamp recovered on load (Phase V). Default PreVersioning; + // loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so + // switching to a pre-versioning project reports PreVersioning rather than inheriting + // the previous project's stamp. Read-only to consumers via writingVersion(). + WritingVersion writingVersion_; + // The project identity last observed by poll(), used to detect load/Save-As. // The GUID is the PRIMARY signal (a different stored GUID = a different project // of record = Load, immune to pointer recycling). The pointer disambiguates the diff --git a/src/version_generated.h.in b/src/version_generated.h.in new file mode 100644 index 0000000..b024847 --- /dev/null +++ b/src/version_generated.h.in @@ -0,0 +1,9 @@ +#pragma once +// version_generated.h.in — configure_file TEMPLATE. CMake substitutes @REASAMPLER_VERSION@ +// (the ONE source-of-truth variable in CMakeLists.txt) and writes the result to the build +// tree as version_generated.h. DO NOT edit the generated header — edit REASAMPLER_VERSION +// in CMakeLists.txt; that single edit re-generates this and re-threads the exact string +// (leading zero preserved verbatim) into the binary constant, the ext-state stamp, and the +// show-version action. See app_version.h / .cpp. + +#define REASAMPLER_VERSION_STRING "@REASAMPLER_VERSION@" diff --git a/tests/test_app_version.cpp b/tests/test_app_version.cpp new file mode 100644 index 0000000..97d4c20 --- /dev/null +++ b/tests/test_app_version.cpp @@ -0,0 +1,138 @@ +// Standalone tests for reasampler::app_version — no REAPER, no framework. Covers the +// Phase V (V1) pure version-identity core: the exact-string fidelity of the CMake-sourced +// constant (leading-zero preserved), the semver parse/compare arithmetic a within-channel +// forward migration leans on, and the three-way writing-version classification persist reads +// back from ext state (PreVersioning / Unknown / Stamped). The ext-state I/O (persist) and +// the show-version action (main) are DAW-verified shell. + +#include "../src/app_version.h" + +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- appVersion: exact-string fidelity, leading zero preserved ---------------- + +static void testVersionConstantRendersExactString() { + // The Daniel-fixed string: EXACTLY "0.9.01", two-digit zero-padded patch. This is + // the whole point of sourcing the STRING (not reconstructing from numeric components): + // a normalized "0.9.1" here would be a leading-zero-fidelity regression, and this + // assertion fails against the actual CMake-configured value, not a re-derivation. + CHECK(appVersion() == "0.9.01"); +} + +// --- parseVersion: well-formed, leading zeros, and rejection ------------------ + +static void testParseWellFormed() { + auto v = parseVersion("0.9.01"); + CHECK(v.has_value()); + CHECK(v && v->major == 0 && v->minor == 9 && v->patch == 1); // "01" -> 1 + auto w = parseVersion("1.10.2"); + CHECK(w.has_value()); + CHECK(w && w->major == 1 && w->minor == 10 && w->patch == 2); +} + +static void testParseRejectsMalformed() { + CHECK(!parseVersion("").has_value()); // empty + CHECK(!parseVersion("1.2").has_value()); // too few components + CHECK(!parseVersion("1.2.3.4").has_value()); // too many components + CHECK(!parseVersion("1..2").has_value()); // empty middle component + CHECK(!parseVersion(".1.2").has_value()); // empty leading component + CHECK(!parseVersion("1.2.").has_value()); // empty trailing component + CHECK(!parseVersion("1.2.x").has_value()); // non-digit + CHECK(!parseVersion("-1.2.3").has_value()); // sign + CHECK(!parseVersion("1.2.3-beta").has_value()); // trailing garbage + CHECK(!parseVersion("v1.2.3").has_value()); // prefix + CHECK(!parseVersion(" 1.2.3").has_value()); // leading whitespace +} + +// --- versionLess: NUMERIC ordering (10 > 9, not lexicographic) ---------------- + +static void testOrderingByPatch() { + // 0.9.02 > 0.9.01 (the per-test-build patch increment the spec calls out). + auto a = parseVersion("0.9.01"); + auto b = parseVersion("0.9.02"); + CHECK(a && b); + CHECK(a && b && versionLess(*a, *b)); + CHECK(a && b && !versionLess(*b, *a)); +} + +static void testOrderingIsNumericNotLexicographic() { + // 0.10.01 > 0.9.02 — the case a string compare would get WRONG ("10" < "9" + // lexicographically). This assertion fails if compare regressed to string order. + auto a = parseVersion("0.9.02"); + auto b = parseVersion("0.10.01"); + CHECK(a && b); + CHECK(a && b && versionLess(*a, *b)); + // And a major bump outranks a large minor: 1.0.0 > 0.99.99. + auto c = parseVersion("0.99.99"); + auto d = parseVersion("1.0.0"); + CHECK(c && d && versionLess(*c, *d)); +} + +static void testOrderingIrreflexive() { + // Equal versions are not less-than either way. + auto a = parseVersion("0.9.01"); + auto b = parseVersion("0.9.01"); + CHECK(a && b && !versionLess(*a, *b)); + CHECK(a && b && !versionLess(*b, *a)); +} + +// --- classifyWritingVersion: the three-way stamp classification ---------------- + +static void testAbsentStampIsPreVersioning() { + // An absent key -> getProjExtStateString returns "" -> PreVersioning (a project + // saved before this feature). Silent, not an error, no throw. + WritingVersion wv = classifyWritingVersion(""); + CHECK(wv.kind == WritingVersion::Kind::PreVersioning); + CHECK(wv.raw.empty()); +} + +static void testMalformedStampIsUnknown() { + // A present-but-unparseable stamp -> Unknown (ignored, never a throw). Keeps the raw + // value for diagnostics but does not pretend it is a version. + WritingVersion wv = classifyWritingVersion("garbage-not-a-version"); + CHECK(wv.kind == WritingVersion::Kind::Unknown); + CHECK(wv.raw == "garbage-not-a-version"); +} + +static void testWellFormedStampIsStamped() { + // A well-formed stamp -> Stamped, carrying the EXACT stored string plus the parsed + // triple for comparison. Round-trips the current version through classify. + WritingVersion wv = classifyWritingVersion("0.9.01"); + CHECK(wv.kind == WritingVersion::Kind::Stamped); + CHECK(wv.raw == "0.9.01"); + CHECK(wv.parsed.major == 0 && wv.parsed.minor == 9 && wv.parsed.patch == 1); +} + +static void testStampedStampIsOrderableAgainstCurrent() { + // The migration use case: a stamp read back is comparable to the running build. An + // older stamp (0.9.01) precedes a newer one (0.9.05) numerically. + WritingVersion older = classifyWritingVersion("0.9.01"); + WritingVersion newer = classifyWritingVersion("0.9.05"); + CHECK(older.kind == WritingVersion::Kind::Stamped); + CHECK(newer.kind == WritingVersion::Kind::Stamped); + CHECK(versionLess(older.parsed, newer.parsed)); +} + +int main() { + testVersionConstantRendersExactString(); + testParseWellFormed(); + testParseRejectsMalformed(); + testOrderingByPatch(); + testOrderingIsNumericNotLexicographic(); + testOrderingIrreflexive(); + testAbsentStampIsPreVersioning(); + testMalformedStampIsUnknown(); + testWellFormedStampIsStamped(); + testStampedStampIsOrderableAgainstCurrent(); + + if (g_fail == 0) std::printf("app_version: all tests passed\n"); + else std::printf("app_version: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}