From da504bac0810377cfaa818cd52fb3cc0f1d0fe12 Mon Sep 17 00:00:00 2001 From: angelobarbu Date: Fri, 7 Aug 2026 15:55:02 +0300 Subject: [PATCH 1/9] Increment 1 - Step 1: Style & Hygiene --- .clang-format | 48 +++++++++++++++++++++++++++++++++++++++++++++++ .clang-tidy | 38 +++++++++++++++++++++++++++++++++++++ .env.example | 18 ++++++++++++++++++ .gitignore | 7 +++++++ scripts/format.sh | 29 ++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+) create mode 100644 .clang-format create mode 100644 .clang-tidy create mode 100644 .env.example create mode 100755 scripts/format.sh diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..25abe11 --- /dev/null +++ b/.clang-format @@ -0,0 +1,48 @@ +# Modulo C++ formatting rules. +# Applied by scripts/format.sh using clang-format from /opt/homebrew/opt/llvm/bin. +--- +Language: Cpp +BasedOnStyle: LLVM +Standard: Latest + +# Layout +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 120 +AccessModifierOffset: -4 +IndentPPDirectives: BeforeHash +NamespaceIndentation: None +FixNamespaceComments: true +InsertNewlineAtEOF: true + +# Pointers & references bind to the type: `int* p`, `const QString& s`. +PointerAlignment: Left +ReferenceAlignment: Left +DerivePointerAlignment: false + +# Declarations +BreakTemplateDeclarations: Yes +AllowShortFunctionsOnASingleLine: InlineOnly +AllowShortLambdasOnASingleLine: All +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +SeparateDefinitionBlocks: Always + +# Include ordering: main header first (clang-format default), then local "quoted", +# then project , then Qt, then third-party, then the C++ standard library. +SortIncludes: CaseSensitive +IncludeBlocks: Regroup +IncludeCategories: + - Regex: '^"' + Priority: 1 + - Regex: '^$' + Priority: 5 + - Regex: '.*' + Priority: 4 diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..bf596e3 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,38 @@ +# Modulo static-analysis rules. +# Run via the `dev-tidy` CMake preset (clang-tidy from /opt/homebrew/opt/llvm/bin). +# +# Check families: bugprone, performance, modernize, readability. +# Suppressions (kept deliberately short): +# - bugprone-easily-swappable-parameters: too noisy for small DTO/ctor signatures. +# - modernize-use-trailing-return-type: we use classic return-type style. +# - readability-identifier-length: short names (id, tx, db) are idiomatic here. +# - readability-magic-numbers: config defaults and test literals would drown the signal. +--- +Checks: > + bugprone-*, + performance-*, + modernize-*, + readability-*, + -bugprone-easily-swappable-parameters, + -modernize-use-trailing-return-type, + -readability-identifier-length, + -readability-magic-numbers + +WarningsAsErrors: '' +HeaderFilterRegex: '.*/(include|src)/modulo/.*|.*/(libs|server|client)/.*\.h$' +FormatStyle: file + +CheckOptions: + # Naming: CamelCase types, camelBack functions/variables, snake_case namespaces, + # trailing underscore for private members, UPPER_CASE macros. + readability-identifier-naming.NamespaceCase: lower_case + readability-identifier-naming.ClassCase: CamelCase + readability-identifier-naming.StructCase: CamelCase + readability-identifier-naming.EnumCase: CamelCase + readability-identifier-naming.EnumConstantCase: CamelCase + readability-identifier-naming.FunctionCase: camelBack + readability-identifier-naming.VariableCase: camelBack + readability-identifier-naming.ParameterCase: camelBack + readability-identifier-naming.PrivateMemberSuffix: '_' + readability-identifier-naming.MacroDefinitionCase: UPPER_CASE + readability-function-cognitive-complexity.IgnoreMacros: true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a6369fb --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Modulo environment configuration. +# Copy to `.env` and adjust; `.env` is gitignored — NEVER commit real values. + +# PostgreSQL connection for the server and the migration runner. +# Port 5433 is deliberate: the dockerized Postgres 16 maps there to avoid the +# local Homebrew PostgreSQL 14 already listening on 5432. +MODULO_DB_URL=postgresql://modulo:modulo@localhost:5433/modulo_dev + +# Test database used by integration tests (`ctest --preset integration`). +# When unset, integration tests SKIP cleanly instead of failing. +MODULO_TEST_DB_URL=postgresql://modulo:modulo@localhost:5433/modulo_test + +# Port the REST API listens on (localhost only during development). +MODULO_HTTP_PORT=8080 + +# Root directory for server-managed files (uploaded documents live under +# documents/). Gitignored; becomes a mounted volume when containerized. +MODULO_DATA_DIR=./var/data diff --git a/.gitignore b/.gitignore index f417ad0..34d29a7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,10 @@ compile_commands.json *creator.user* *_qmlcache.qrc + +# Modulo +build*/ +var/ +.env +.cache/ +.DS_Store diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..b0b455a --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Format all first-party C++ sources with clang-format (in place). +# +# Usage: +# scripts/format.sh # rewrite files +# scripts/format.sh --check # verify only; exit non-zero if formatting differs (CI mode) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Homebrew LLVM is keg-only, so its tools are not on PATH by default. +CLANG_FORMAT="${CLANG_FORMAT:-/opt/homebrew/opt/llvm/bin/clang-format}" + +if [[ ! -x "${CLANG_FORMAT}" ]]; then + echo "error: clang-format not found at ${CLANG_FORMAT} (brew install llvm)" >&2 + exit 1 +fi + +MODE_ARGS=(-i) +if [[ "${1:-}" == "--check" ]]; then + MODE_ARGS=(--dry-run --Werror) +fi + +# First-party source directories only — never third-party or generated code. +find "${REPO_ROOT}/libs" "${REPO_ROOT}/server" "${REPO_ROOT}/client" \ + -type f \( -name '*.cpp' -o -name '*.h' \) -print0 2>/dev/null | + xargs -0 -r "${CLANG_FORMAT}" --style=file "${MODE_ARGS[@]}" + +echo "format.sh: done" From f6de90630fb4446d774f8dc790dd9ba76436ff3e Mon Sep 17 00:00:00 2001 From: angelobarbu Date: Fri, 7 Aug 2026 16:11:32 +0300 Subject: [PATCH 2/9] Increment 1 - Step 1: Fixed scripts/format.sh --- scripts/format.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/format.sh b/scripts/format.sh index b0b455a..8807109 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -22,8 +22,14 @@ if [[ "${1:-}" == "--check" ]]; then fi # First-party source directories only — never third-party or generated code. -find "${REPO_ROOT}/libs" "${REPO_ROOT}/server" "${REPO_ROOT}/client" \ - -type f \( -name '*.cpp' -o -name '*.h' \) -print0 2>/dev/null | - xargs -0 -r "${CLANG_FORMAT}" --style=file "${MODE_ARGS[@]}" +SEARCH_DIRS=() +for dir in libs server client; do + [[ -d "${REPO_ROOT}/${dir}" ]] && SEARCH_DIRS+=("${REPO_ROOT}/${dir}") +done +if [[ ${#SEARCH_DIRS[@]} -gt 0 ]]; then + find "${SEARCH_DIRS[@]}" \ + -type f \( -name '*.cpp' -o -name '*.h' \) -print0 | + xargs -0 -r "${CLANG_FORMAT}" --style=file "${MODE_ARGS[@]}" +fi echo "format.sh: done" From 1e5bc259e82fcb0271246c6524fd491e6b099bb2 Mon Sep 17 00:00:00 2001 From: angelobarbu Date: Fri, 7 Aug 2026 17:30:44 +0300 Subject: [PATCH 3/9] Increment 1 - Step 2: CMake superstructure --- CMakeLists.txt | 39 + CMakePresets.json | 80 ++ cmake/CPM.cmake | 1363 ++++++++++++++++++++++++++++++++++ cmake/CompilerWarnings.cmake | 29 + cmake/Dependencies.cmake | 60 ++ cmake/ModuloTargets.cmake | 134 ++++ cmake/Sanitizers.cmake | 20 + cmake/StaticAnalysis.cmake | 29 + 8 files changed, 1754 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json create mode 100644 cmake/CPM.cmake create mode 100644 cmake/CompilerWarnings.cmake create mode 100644 cmake/Dependencies.cmake create mode 100644 cmake/ModuloTargets.cmake create mode 100644 cmake/Sanitizers.cmake create mode 100644 cmake/StaticAnalysis.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b6adf0f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,39 @@ +# Modulo — personal investment tracker. +# +# This file stays deliberately thin: options, toolkit includes, dependency +# resolution, and subdirectory wiring. All build logic lives as modulo_* +# functions in cmake/ (see cmake/ModuloTargets.cmake). + +cmake_minimum_required(VERSION 3.28) + +project( + Modulo + VERSION 0.1.0 + DESCRIPTION "Personal investment tracker" + LANGUAGES CXX) + +list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") + +# --- Options (set via CMakePresets.json) ------------------------------------- +option(MODULO_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) +option(MODULO_BUILD_TESTS "Build the test suites" ON) +option(MODULO_CLANG_TIDY "Run clang-tidy during compilation" OFF) +set(MODULO_SANITIZERS + "" + CACHE STRING "Comma-separated -fsanitize= list, e.g. 'address,undefined'") + +# --- Toolkit & dependencies -------------------------------------------------- +include(ModuloTargets) +include(Dependencies) +modulo_find_dependencies() + +qt_standard_project_setup(REQUIRES 6.8) + +enable_testing() + +# --- Project targets --------------------------------------------------------- +# Subdirectories are appended as the increments introduce them: +# add_subdirectory(libs/core) # Increment 1, Step 5 +# add_subdirectory(libs/api) # Increment 1, Step 5 +# add_subdirectory(server) # Increment 1, Steps 4-5 +# add_subdirectory(client) # Increment 1, Step 5 diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..0ec70fb --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,80 @@ +{ + "version": 6, + "cmakeMinimumRequired": { "major": 3, "minor": 28, "patch": 0 }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_PREFIX_PATH": "/opt/homebrew/opt/qt", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "dev", + "displayName": "Development (Debug, warnings-as-errors)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "MODULO_WARNINGS_AS_ERRORS": "ON" + } + }, + { + "name": "dev-asan", + "displayName": "Development + address/UB sanitizers", + "inherits": "dev", + "cacheVariables": { + "MODULO_SANITIZERS": "address,undefined" + } + }, + { + "name": "dev-tidy", + "displayName": "Development + clang-tidy on every compile", + "inherits": "dev", + "cacheVariables": { + "MODULO_CLANG_TIDY": "ON" + } + }, + { + "name": "release", + "displayName": "Release (RelWithDebInfo)", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + } + } + ], + "buildPresets": [ + { "name": "dev", "configurePreset": "dev" }, + { "name": "dev-asan", "configurePreset": "dev-asan" }, + { "name": "dev-tidy", "configurePreset": "dev-tidy" }, + { "name": "release", "configurePreset": "release" } + ], + "testPresets": [ + { + "name": "unit", + "configurePreset": "dev", + "filter": { "include": { "label": "^unit$" } }, + "output": { "outputOnFailure": true } + }, + { + "name": "integration", + "configurePreset": "dev", + "filter": { "include": { "label": "^integration$" } }, + "output": { "outputOnFailure": true } + }, + { + "name": "ui", + "configurePreset": "dev", + "filter": { "include": { "label": "^ui$" } }, + "output": { "outputOnFailure": true } + }, + { + "name": "all", + "configurePreset": "dev", + "output": { "outputOnFailure": true } + } + ] +} diff --git a/cmake/CPM.cmake b/cmake/CPM.cmake new file mode 100644 index 0000000..3636ee5 --- /dev/null +++ b/cmake/CPM.cmake @@ -0,0 +1,1363 @@ +# CPM.cmake - CMake's missing package manager +# =========================================== +# See https://github.com/cpm-cmake/CPM.cmake for usage and update instructions. +# +# MIT License +# ----------- +#[[ + Copyright (c) 2019-2023 Lars Melchior and contributors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +]] + +cmake_minimum_required(VERSION 3.14 FATAL_ERROR) + +# Initialize logging prefix +if(NOT CPM_INDENT) + set(CPM_INDENT + "CPM:" + CACHE INTERNAL "" + ) +endif() + +if(NOT COMMAND cpm_message) + function(cpm_message) + message(${ARGV}) + endfunction() +endif() + +if(DEFINED EXTRACTED_CPM_VERSION) + set(CURRENT_CPM_VERSION "${EXTRACTED_CPM_VERSION}${CPM_DEVELOPMENT}") +else() + set(CURRENT_CPM_VERSION 0.42.0) +endif() + +get_filename_component(CPM_CURRENT_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" REALPATH) +if(CPM_DIRECTORY) + if(NOT CPM_DIRECTORY STREQUAL CPM_CURRENT_DIRECTORY) + if(CPM_VERSION VERSION_LESS CURRENT_CPM_VERSION) + message( + AUTHOR_WARNING + "${CPM_INDENT} \ +A dependency is using a more recent CPM version (${CURRENT_CPM_VERSION}) than the current project (${CPM_VERSION}). \ +It is recommended to upgrade CPM to the most recent version. \ +See https://github.com/cpm-cmake/CPM.cmake for more information." + ) + endif() + if(${CMAKE_VERSION} VERSION_LESS "3.17.0") + include(FetchContent) + endif() + return() + endif() + + get_property( + CPM_INITIALIZED GLOBAL "" + PROPERTY CPM_INITIALIZED + SET + ) + if(CPM_INITIALIZED) + return() + endif() +endif() + +if(CURRENT_CPM_VERSION MATCHES "development-version") + message( + WARNING "${CPM_INDENT} Your project is using an unstable development version of CPM.cmake. \ +Please update to a recent release if possible. \ +See https://github.com/cpm-cmake/CPM.cmake for details." + ) +endif() + +set_property(GLOBAL PROPERTY CPM_INITIALIZED true) + +macro(cpm_set_policies) + # the policy allows us to change options without caching + cmake_policy(SET CMP0077 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) + + # the policy allows us to change set(CACHE) without caching + if(POLICY CMP0126) + cmake_policy(SET CMP0126 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0126 NEW) + endif() + + # The policy uses the download time for timestamp, instead of the timestamp in the archive. This + # allows for proper rebuilds when a projects url changes + if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0135 NEW) + endif() + + # treat relative git repository paths as being relative to the parent project's remote + if(POLICY CMP0150) + cmake_policy(SET CMP0150 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0150 NEW) + endif() +endmacro() +cpm_set_policies() + +option(CPM_USE_LOCAL_PACKAGES "Always try to use `find_package` to get dependencies" + $ENV{CPM_USE_LOCAL_PACKAGES} +) +option(CPM_LOCAL_PACKAGES_ONLY "Only use `find_package` to get dependencies" + $ENV{CPM_LOCAL_PACKAGES_ONLY} +) +option(CPM_DOWNLOAD_ALL "Always download dependencies from source" $ENV{CPM_DOWNLOAD_ALL}) +option(CPM_DONT_UPDATE_MODULE_PATH "Don't update the module path to allow using find_package" + $ENV{CPM_DONT_UPDATE_MODULE_PATH} +) +option(CPM_DONT_CREATE_PACKAGE_LOCK "Don't create a package lock file in the binary path" + $ENV{CPM_DONT_CREATE_PACKAGE_LOCK} +) +option(CPM_INCLUDE_ALL_IN_PACKAGE_LOCK + "Add all packages added through CPM.cmake to the package lock" + $ENV{CPM_INCLUDE_ALL_IN_PACKAGE_LOCK} +) +option(CPM_USE_NAMED_CACHE_DIRECTORIES + "Use additional directory of package name in cache on the most nested level." + $ENV{CPM_USE_NAMED_CACHE_DIRECTORIES} +) + +set(CPM_VERSION + ${CURRENT_CPM_VERSION} + CACHE INTERNAL "" +) +set(CPM_DIRECTORY + ${CPM_CURRENT_DIRECTORY} + CACHE INTERNAL "" +) +set(CPM_FILE + ${CMAKE_CURRENT_LIST_FILE} + CACHE INTERNAL "" +) +set(CPM_PACKAGES + "" + CACHE INTERNAL "" +) +set(CPM_DRY_RUN + OFF + CACHE INTERNAL "Don't download or configure dependencies (for testing)" +) + +if(DEFINED ENV{CPM_SOURCE_CACHE}) + set(CPM_SOURCE_CACHE_DEFAULT $ENV{CPM_SOURCE_CACHE}) +else() + set(CPM_SOURCE_CACHE_DEFAULT OFF) +endif() + +set(CPM_SOURCE_CACHE + ${CPM_SOURCE_CACHE_DEFAULT} + CACHE PATH "Directory to download CPM dependencies" +) + +if(NOT CPM_DONT_UPDATE_MODULE_PATH AND NOT DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) + set(CPM_MODULE_PATH + "${CMAKE_BINARY_DIR}/CPM_modules" + CACHE INTERNAL "" + ) + # remove old modules + file(REMOVE_RECURSE ${CPM_MODULE_PATH}) + file(MAKE_DIRECTORY ${CPM_MODULE_PATH}) + # locally added CPM modules should override global packages + set(CMAKE_MODULE_PATH "${CPM_MODULE_PATH};${CMAKE_MODULE_PATH}") +endif() + +if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + set(CPM_PACKAGE_LOCK_FILE + "${CMAKE_BINARY_DIR}/cpm-package-lock.cmake" + CACHE INTERNAL "" + ) + file(WRITE ${CPM_PACKAGE_LOCK_FILE} + "# CPM Package Lock\n# This file should be committed to version control\n\n" + ) +endif() + +include(FetchContent) + +# Try to infer package name from git repository uri (path or url) +function(cpm_package_name_from_git_uri URI RESULT) + if("${URI}" MATCHES "([^/:]+)/?.git/?$") + set(${RESULT} + ${CMAKE_MATCH_1} + PARENT_SCOPE + ) + else() + unset(${RESULT} PARENT_SCOPE) + endif() +endfunction() + +# Find the shortest hash that can be used eg, if origin_hash is +# cccb77ae9609d2768ed80dd42cec54f77b1f1455 the following files will be checked, until one is found +# that is either empty (allowing us to assign origin_hash), or whose contents matches ${origin_hash} +# +# * .../cccb.hash +# * .../cccb77ae.hash +# * .../cccb77ae9609.hash +# * .../cccb77ae9609d276.hash +# * etc +# +# We will be able to use a shorter path with very high probability, but in the (rare) event that the +# first couple characters collide, we will check longer and longer substrings. +function(cpm_get_shortest_hash source_cache_dir origin_hash short_hash_output_var) + # for compatibility with caches populated by a previous version of CPM, check if a directory using + # the full hash already exists + if(EXISTS "${source_cache_dir}/${origin_hash}") + set(${short_hash_output_var} + "${origin_hash}" + PARENT_SCOPE + ) + return() + endif() + + foreach(len RANGE 4 40 4) + string(SUBSTRING "${origin_hash}" 0 ${len} short_hash) + set(hash_lock ${source_cache_dir}/${short_hash}.lock) + set(hash_fp ${source_cache_dir}/${short_hash}.hash) + # Take a lock, so we don't have a race condition with another instance of cmake. We will release + # this lock when we can, however, if there is an error, we want to ensure it gets released on + # it's own on exit from the function. + file(LOCK ${hash_lock} GUARD FUNCTION) + + # Load the contents of .../${short_hash}.hash + file(TOUCH ${hash_fp}) + file(READ ${hash_fp} hash_fp_contents) + + if(hash_fp_contents STREQUAL "") + # Write the origin hash + file(WRITE ${hash_fp} ${origin_hash}) + file(LOCK ${hash_lock} RELEASE) + break() + elseif(hash_fp_contents STREQUAL origin_hash) + file(LOCK ${hash_lock} RELEASE) + break() + else() + file(LOCK ${hash_lock} RELEASE) + endif() + endforeach() + set(${short_hash_output_var} + "${short_hash}" + PARENT_SCOPE + ) +endfunction() + +# Try to infer package name and version from a url +function(cpm_package_name_and_ver_from_url url outName outVer) + if(url MATCHES "[/\\?]([a-zA-Z0-9_\\.-]+)\\.(tar|tar\\.gz|tar\\.bz2|zip|ZIP)(\\?|/|$)") + # We matched an archive + set(filename "${CMAKE_MATCH_1}") + + if(filename MATCHES "([a-zA-Z0-9_\\.-]+)[_-]v?(([0-9]+\\.)*[0-9]+[a-zA-Z0-9]*)") + # We matched - (ie foo-1.2.3) + set(${outName} + "${CMAKE_MATCH_1}" + PARENT_SCOPE + ) + set(${outVer} + "${CMAKE_MATCH_2}" + PARENT_SCOPE + ) + elseif(filename MATCHES "(([0-9]+\\.)+[0-9]+[a-zA-Z0-9]*)") + # We couldn't find a name, but we found a version + # + # In many cases (which we don't handle here) the url would look something like + # `irrelevant/ACTUAL_PACKAGE_NAME/irrelevant/1.2.3.zip`. In such a case we can't possibly + # distinguish the package name from the irrelevant bits. Moreover if we try to match the + # package name from the filename, we'd get bogus at best. + unset(${outName} PARENT_SCOPE) + set(${outVer} + "${CMAKE_MATCH_1}" + PARENT_SCOPE + ) + else() + # Boldly assume that the file name is the package name. + # + # Yes, something like `irrelevant/ACTUAL_NAME/irrelevant/download.zip` will ruin our day, but + # such cases should be quite rare. No popular service does this... we think. + set(${outName} + "${filename}" + PARENT_SCOPE + ) + unset(${outVer} PARENT_SCOPE) + endif() + else() + # No ideas yet what to do with non-archives + unset(${outName} PARENT_SCOPE) + unset(${outVer} PARENT_SCOPE) + endif() +endfunction() + +function(cpm_find_package NAME VERSION) + string(REPLACE " " ";" EXTRA_ARGS "${ARGN}") + find_package(${NAME} ${VERSION} ${EXTRA_ARGS} QUIET) + if(${CPM_ARGS_NAME}_FOUND) + if(DEFINED ${CPM_ARGS_NAME}_VERSION) + set(VERSION ${${CPM_ARGS_NAME}_VERSION}) + endif() + cpm_message(STATUS "${CPM_INDENT} Using local package ${CPM_ARGS_NAME}@${VERSION}") + CPMRegisterPackage(${CPM_ARGS_NAME} "${VERSION}") + set(CPM_PACKAGE_FOUND + YES + PARENT_SCOPE + ) + else() + set(CPM_PACKAGE_FOUND + NO + PARENT_SCOPE + ) + endif() +endfunction() + +# Create a custom FindXXX.cmake module for a CPM package This prevents `find_package(NAME)` from +# finding the system library +function(cpm_create_module_file Name) + if(NOT CPM_DONT_UPDATE_MODULE_PATH) + if(DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) + # Redirect find_package calls to the CPM package. This is what FetchContent does when you set + # OVERRIDE_FIND_PACKAGE. The CMAKE_FIND_PACKAGE_REDIRECTS_DIR works for find_package in CONFIG + # mode, unlike the Find${Name}.cmake fallback. CMAKE_FIND_PACKAGE_REDIRECTS_DIR is not defined + # in script mode, or in CMake < 3.24. + # https://cmake.org/cmake/help/latest/module/FetchContent.html#fetchcontent-find-package-integration-examples + string(TOLOWER ${Name} NameLower) + file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config.cmake + "include(\"\${CMAKE_CURRENT_LIST_DIR}/${NameLower}-extra.cmake\" OPTIONAL)\n" + "include(\"\${CMAKE_CURRENT_LIST_DIR}/${Name}Extra.cmake\" OPTIONAL)\n" + ) + file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config-version.cmake + "set(PACKAGE_VERSION_COMPATIBLE TRUE)\n" "set(PACKAGE_VERSION_EXACT TRUE)\n" + ) + else() + file(WRITE ${CPM_MODULE_PATH}/Find${Name}.cmake + "include(\"${CPM_FILE}\")\n${ARGN}\nset(${Name}_FOUND TRUE)" + ) + endif() + endif() +endfunction() + +# Find a package locally or fallback to CPMAddPackage +function(CPMFindPackage) + set(oneValueArgs NAME VERSION GIT_TAG FIND_PACKAGE_ARGUMENTS) + + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "" ${ARGN}) + + if(NOT DEFINED CPM_ARGS_VERSION) + if(DEFINED CPM_ARGS_GIT_TAG) + cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) + endif() + endif() + + set(downloadPackage ${CPM_DOWNLOAD_ALL}) + if(DEFINED CPM_DOWNLOAD_${CPM_ARGS_NAME}) + set(downloadPackage ${CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + elseif(DEFINED ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + set(downloadPackage $ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + endif() + if(downloadPackage) + CPMAddPackage(${ARGN}) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) + + if(NOT CPM_PACKAGE_FOUND) + CPMAddPackage(${ARGN}) + cpm_export_variables(${CPM_ARGS_NAME}) + endif() + +endfunction() + +# checks if a package has been added before +function(cpm_check_if_package_already_added CPM_ARGS_NAME CPM_ARGS_VERSION) + if("${CPM_ARGS_NAME}" IN_LIST CPM_PACKAGES) + CPMGetPackageVersion(${CPM_ARGS_NAME} CPM_PACKAGE_VERSION) + if("${CPM_PACKAGE_VERSION}" VERSION_LESS "${CPM_ARGS_VERSION}") + message( + WARNING + "${CPM_INDENT} Requires a newer version of ${CPM_ARGS_NAME} (${CPM_ARGS_VERSION}) than currently included (${CPM_PACKAGE_VERSION})." + ) + endif() + cpm_get_fetch_properties(${CPM_ARGS_NAME}) + set(${CPM_ARGS_NAME}_ADDED NO) + set(CPM_PACKAGE_ALREADY_ADDED + YES + PARENT_SCOPE + ) + cpm_export_variables(${CPM_ARGS_NAME}) + else() + set(CPM_PACKAGE_ALREADY_ADDED + NO + PARENT_SCOPE + ) + endif() +endfunction() + +# Parse the argument of CPMAddPackage in case a single one was provided and convert it to a list of +# arguments which can then be parsed idiomatically. For example gh:foo/bar@1.2.3 will be converted +# to: GITHUB_REPOSITORY;foo/bar;VERSION;1.2.3 +function(cpm_parse_add_package_single_arg arg outArgs) + # Look for a scheme + if("${arg}" MATCHES "^([a-zA-Z]+):(.+)$") + string(TOLOWER "${CMAKE_MATCH_1}" scheme) + set(uri "${CMAKE_MATCH_2}") + + # Check for CPM-specific schemes + if(scheme STREQUAL "gh") + set(out "GITHUB_REPOSITORY;${uri}") + set(packageType "git") + elseif(scheme STREQUAL "gl") + set(out "GITLAB_REPOSITORY;${uri}") + set(packageType "git") + elseif(scheme STREQUAL "bb") + set(out "BITBUCKET_REPOSITORY;${uri}") + set(packageType "git") + # A CPM-specific scheme was not found. Looks like this is a generic URL so try to determine + # type + elseif(arg MATCHES ".git/?(@|#|$)") + set(out "GIT_REPOSITORY;${arg}") + set(packageType "git") + else() + # Fall back to a URL + set(out "URL;${arg}") + set(packageType "archive") + + # We could also check for SVN since FetchContent supports it, but SVN is so rare these days. + # We just won't bother with the additional complexity it will induce in this function. SVN is + # done by multi-arg + endif() + else() + if(arg MATCHES ".git/?(@|#|$)") + set(out "GIT_REPOSITORY;${arg}") + set(packageType "git") + else() + # Give up + message(FATAL_ERROR "${CPM_INDENT} Can't determine package type of '${arg}'") + endif() + endif() + + # For all packages we interpret @... as version. Only replace the last occurrence. Thus URIs + # containing '@' can be used + string(REGEX REPLACE "@([^@]+)$" ";VERSION;\\1" out "${out}") + + # Parse the rest according to package type + if(packageType STREQUAL "git") + # For git repos we interpret #... as a tag or branch or commit hash + string(REGEX REPLACE "#([^#]+)$" ";GIT_TAG;\\1" out "${out}") + elseif(packageType STREQUAL "archive") + # For archives we interpret #... as a URL hash. + string(REGEX REPLACE "#([^#]+)$" ";URL_HASH;\\1" out "${out}") + # We don't try to parse the version if it's not provided explicitly. cpm_get_version_from_url + # should do this at a later point + else() + # We should never get here. This is an assertion and hitting it means there's a problem with the + # code above. A packageType was set, but not handled by this if-else. + message(FATAL_ERROR "${CPM_INDENT} Unsupported package type '${packageType}' of '${arg}'") + endif() + + set(${outArgs} + ${out} + PARENT_SCOPE + ) +endfunction() + +# Check that the working directory for a git repo is clean +function(cpm_check_git_working_dir_is_clean repoPath gitTag isClean) + + find_package(Git REQUIRED) + + if(NOT GIT_EXECUTABLE) + # No git executable, assume directory is clean + set(${isClean} + TRUE + PARENT_SCOPE + ) + return() + endif() + + # check for uncommitted changes + execute_process( + COMMAND ${GIT_EXECUTABLE} status --porcelain + RESULT_VARIABLE resultGitStatus + OUTPUT_VARIABLE repoStatus + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET + WORKING_DIRECTORY ${repoPath} + ) + if(resultGitStatus) + # not supposed to happen, assume clean anyway + message(WARNING "${CPM_INDENT} Calling git status on folder ${repoPath} failed") + set(${isClean} + TRUE + PARENT_SCOPE + ) + return() + endif() + + if(NOT "${repoStatus}" STREQUAL "") + set(${isClean} + FALSE + PARENT_SCOPE + ) + return() + endif() + + # check for committed changes + execute_process( + COMMAND ${GIT_EXECUTABLE} diff -s --exit-code ${gitTag} + RESULT_VARIABLE resultGitDiff + OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_QUIET + WORKING_DIRECTORY ${repoPath} + ) + + if(${resultGitDiff} EQUAL 0) + set(${isClean} + TRUE + PARENT_SCOPE + ) + else() + set(${isClean} + FALSE + PARENT_SCOPE + ) + endif() + +endfunction() + +# Add PATCH_COMMAND to CPM_ARGS_UNPARSED_ARGUMENTS. This method consumes a list of files in ARGN +# then generates a `PATCH_COMMAND` appropriate for `ExternalProject_Add()`. This command is appended +# to the parent scope's `CPM_ARGS_UNPARSED_ARGUMENTS`. +function(cpm_add_patches) + # Return if no patch files are supplied. + if(NOT ARGN) + return() + endif() + + # Find the patch program. + find_program(PATCH_EXECUTABLE patch) + if(CMAKE_HOST_WIN32 AND NOT PATCH_EXECUTABLE) + # The Windows git executable is distributed with patch.exe. Find the path to the executable, if + # it exists, then search `../usr/bin` and `../../usr/bin` for patch.exe. + find_package(Git QUIET) + if(GIT_EXECUTABLE) + get_filename_component(extra_search_path ${GIT_EXECUTABLE} DIRECTORY) + get_filename_component(extra_search_path_1up ${extra_search_path} DIRECTORY) + get_filename_component(extra_search_path_2up ${extra_search_path_1up} DIRECTORY) + find_program( + PATCH_EXECUTABLE patch HINTS "${extra_search_path_1up}/usr/bin" + "${extra_search_path_2up}/usr/bin" + ) + endif() + endif() + if(NOT PATCH_EXECUTABLE) + message(FATAL_ERROR "Couldn't find `patch` executable to use with PATCHES keyword.") + endif() + + # Create a temporary + set(temp_list ${CPM_ARGS_UNPARSED_ARGUMENTS}) + + # Ensure each file exists (or error out) and add it to the list. + set(first_item True) + foreach(PATCH_FILE ${ARGN}) + # Make sure the patch file exists, if we can't find it, try again in the current directory. + if(NOT EXISTS "${PATCH_FILE}") + if(NOT EXISTS "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") + message(FATAL_ERROR "Couldn't find patch file: '${PATCH_FILE}'") + endif() + set(PATCH_FILE "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") + endif() + + # Convert to absolute path for use with patch file command. + get_filename_component(PATCH_FILE "${PATCH_FILE}" ABSOLUTE) + + # The first patch entry must be preceded by "PATCH_COMMAND" while the following items are + # preceded by "&&". + if(first_item) + set(first_item False) + list(APPEND temp_list "PATCH_COMMAND") + else() + list(APPEND temp_list "&&") + endif() + # Add the patch command to the list + list(APPEND temp_list "${PATCH_EXECUTABLE}" "-p1" "<" "${PATCH_FILE}") + endforeach() + + # Move temp out into parent scope. + set(CPM_ARGS_UNPARSED_ARGUMENTS + ${temp_list} + PARENT_SCOPE + ) + +endfunction() + +# method to overwrite internal FetchContent properties, to allow using CPM.cmake to overload +# FetchContent calls. As these are internal cmake properties, this method should be used carefully +# and may need modification in future CMake versions. Source: +# https://github.com/Kitware/CMake/blob/dc3d0b5a0a7d26d43d6cfeb511e224533b5d188f/Modules/FetchContent.cmake#L1152 +function(cpm_override_fetchcontent contentName) + cmake_parse_arguments(PARSE_ARGV 1 arg "" "SOURCE_DIR;BINARY_DIR" "") + if(NOT "${arg_UNPARSED_ARGUMENTS}" STREQUAL "") + message(FATAL_ERROR "${CPM_INDENT} Unsupported arguments: ${arg_UNPARSED_ARGUMENTS}") + endif() + + string(TOLOWER ${contentName} contentNameLower) + set(prefix "_FetchContent_${contentNameLower}") + + set(propertyName "${prefix}_sourceDir") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} "${arg_SOURCE_DIR}") + + set(propertyName "${prefix}_binaryDir") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} "${arg_BINARY_DIR}") + + set(propertyName "${prefix}_populated") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} TRUE) +endfunction() + +# Download and add a package from source +function(CPMAddPackage) + cpm_set_policies() + + set(oneValueArgs + NAME + FORCE + VERSION + GIT_TAG + DOWNLOAD_ONLY + GITHUB_REPOSITORY + GITLAB_REPOSITORY + BITBUCKET_REPOSITORY + GIT_REPOSITORY + SOURCE_DIR + FIND_PACKAGE_ARGUMENTS + NO_CACHE + SYSTEM + GIT_SHALLOW + EXCLUDE_FROM_ALL + SOURCE_SUBDIR + CUSTOM_CACHE_KEY + ) + + set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND PATCHES) + + list(LENGTH ARGN argnLength) + + # Parse single shorthand argument + if(argnLength EQUAL 1) + cpm_parse_add_package_single_arg("${ARGN}" ARGN) + + # The shorthand syntax implies EXCLUDE_FROM_ALL and SYSTEM + set(ARGN "${ARGN};EXCLUDE_FROM_ALL;YES;SYSTEM;YES;") + + # Parse URI shorthand argument + elseif(argnLength GREATER 1 AND "${ARGV0}" STREQUAL "URI") + list(REMOVE_AT ARGN 0 1) # remove "URI gh:<...>@version#tag" + cpm_parse_add_package_single_arg("${ARGV1}" ARGV0) + + set(ARGN "${ARGV0};EXCLUDE_FROM_ALL;YES;SYSTEM;YES;${ARGN}") + endif() + + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" "${ARGN}") + + # Set default values for arguments + if(NOT DEFINED CPM_ARGS_VERSION) + if(DEFINED CPM_ARGS_GIT_TAG) + cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) + endif() + endif() + + if(CPM_ARGS_DOWNLOAD_ONLY) + set(DOWNLOAD_ONLY ${CPM_ARGS_DOWNLOAD_ONLY}) + else() + set(DOWNLOAD_ONLY NO) + endif() + + if(DEFINED CPM_ARGS_GITHUB_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://github.com/${CPM_ARGS_GITHUB_REPOSITORY}.git") + elseif(DEFINED CPM_ARGS_GITLAB_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://gitlab.com/${CPM_ARGS_GITLAB_REPOSITORY}.git") + elseif(DEFINED CPM_ARGS_BITBUCKET_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://bitbucket.org/${CPM_ARGS_BITBUCKET_REPOSITORY}.git") + endif() + + if(DEFINED CPM_ARGS_GIT_REPOSITORY) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_REPOSITORY ${CPM_ARGS_GIT_REPOSITORY}) + if(NOT DEFINED CPM_ARGS_GIT_TAG) + set(CPM_ARGS_GIT_TAG v${CPM_ARGS_VERSION}) + endif() + + # If a name wasn't provided, try to infer it from the git repo + if(NOT DEFINED CPM_ARGS_NAME) + cpm_package_name_from_git_uri(${CPM_ARGS_GIT_REPOSITORY} CPM_ARGS_NAME) + endif() + endif() + + set(CPM_SKIP_FETCH FALSE) + + if(DEFINED CPM_ARGS_GIT_TAG) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_TAG ${CPM_ARGS_GIT_TAG}) + # If GIT_SHALLOW is explicitly specified, honor the value. + if(DEFINED CPM_ARGS_GIT_SHALLOW) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW ${CPM_ARGS_GIT_SHALLOW}) + endif() + endif() + + if(DEFINED CPM_ARGS_URL) + # If a name or version aren't provided, try to infer them from the URL + list(GET CPM_ARGS_URL 0 firstUrl) + cpm_package_name_and_ver_from_url(${firstUrl} nameFromUrl verFromUrl) + # If we fail to obtain name and version from the first URL, we could try other URLs if any. + # However multiple URLs are expected to be quite rare, so for now we won't bother. + + # If the caller provided their own name and version, they trump the inferred ones. + if(NOT DEFINED CPM_ARGS_NAME) + set(CPM_ARGS_NAME ${nameFromUrl}) + endif() + if(NOT DEFINED CPM_ARGS_VERSION) + set(CPM_ARGS_VERSION ${verFromUrl}) + endif() + + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS URL "${CPM_ARGS_URL}") + endif() + + # Check for required arguments + + if(NOT DEFINED CPM_ARGS_NAME) + message( + FATAL_ERROR + "${CPM_INDENT} 'NAME' was not provided and couldn't be automatically inferred for package added with arguments: '${ARGN}'" + ) + endif() + + # Check if package has been added before + cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") + if(CPM_PACKAGE_ALREADY_ADDED) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + # Check for manual overrides + if(NOT CPM_ARGS_FORCE AND NOT "${CPM_${CPM_ARGS_NAME}_SOURCE}" STREQUAL "") + set(PACKAGE_SOURCE ${CPM_${CPM_ARGS_NAME}_SOURCE}) + set(CPM_${CPM_ARGS_NAME}_SOURCE "") + CPMAddPackage( + NAME "${CPM_ARGS_NAME}" + SOURCE_DIR "${PACKAGE_SOURCE}" + EXCLUDE_FROM_ALL "${CPM_ARGS_EXCLUDE_FROM_ALL}" + SYSTEM "${CPM_ARGS_SYSTEM}" + PATCHES "${CPM_ARGS_PATCHES}" + OPTIONS "${CPM_ARGS_OPTIONS}" + SOURCE_SUBDIR "${CPM_ARGS_SOURCE_SUBDIR}" + DOWNLOAD_ONLY "${DOWNLOAD_ONLY}" + FORCE True + ) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + # Check for available declaration + if(NOT CPM_ARGS_FORCE AND NOT "${CPM_DECLARATION_${CPM_ARGS_NAME}}" STREQUAL "") + set(declaration ${CPM_DECLARATION_${CPM_ARGS_NAME}}) + set(CPM_DECLARATION_${CPM_ARGS_NAME} "") + CPMAddPackage(${declaration}) + cpm_export_variables(${CPM_ARGS_NAME}) + # checking again to ensure version and option compatibility + cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") + return() + endif() + + if(NOT CPM_ARGS_FORCE) + if(CPM_USE_LOCAL_PACKAGES OR CPM_LOCAL_PACKAGES_ONLY) + cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) + + if(CPM_PACKAGE_FOUND) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + if(CPM_LOCAL_PACKAGES_ONLY) + message( + SEND_ERROR + "${CPM_INDENT} ${CPM_ARGS_NAME} not found via find_package(${CPM_ARGS_NAME} ${CPM_ARGS_VERSION})" + ) + endif() + endif() + endif() + + CPMRegisterPackage("${CPM_ARGS_NAME}" "${CPM_ARGS_VERSION}") + + if(DEFINED CPM_ARGS_GIT_TAG) + set(PACKAGE_INFO "${CPM_ARGS_GIT_TAG}") + elseif(DEFINED CPM_ARGS_SOURCE_DIR) + set(PACKAGE_INFO "${CPM_ARGS_SOURCE_DIR}") + else() + set(PACKAGE_INFO "${CPM_ARGS_VERSION}") + endif() + + if(DEFINED FETCHCONTENT_BASE_DIR) + # respect user's FETCHCONTENT_BASE_DIR if set + set(CPM_FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR}) + else() + set(CPM_FETCHCONTENT_BASE_DIR ${CMAKE_BINARY_DIR}/_deps) + endif() + + cpm_add_patches(${CPM_ARGS_PATCHES}) + + if(DEFINED CPM_ARGS_DOWNLOAD_COMMAND) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS DOWNLOAD_COMMAND ${CPM_ARGS_DOWNLOAD_COMMAND}) + elseif(DEFINED CPM_ARGS_SOURCE_DIR) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${CPM_ARGS_SOURCE_DIR}) + if(NOT IS_ABSOLUTE ${CPM_ARGS_SOURCE_DIR}) + # Expand `CPM_ARGS_SOURCE_DIR` relative path. This is important because EXISTS doesn't work + # for relative paths. + get_filename_component( + source_directory ${CPM_ARGS_SOURCE_DIR} REALPATH BASE_DIR ${CMAKE_CURRENT_BINARY_DIR} + ) + else() + set(source_directory ${CPM_ARGS_SOURCE_DIR}) + endif() + if(NOT EXISTS ${source_directory}) + string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) + # remove timestamps so CMake will re-download the dependency + file(REMOVE_RECURSE "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild") + endif() + elseif(CPM_SOURCE_CACHE AND NOT CPM_ARGS_NO_CACHE) + string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) + set(origin_parameters ${CPM_ARGS_UNPARSED_ARGUMENTS}) + list(SORT origin_parameters) + if(CPM_ARGS_CUSTOM_CACHE_KEY) + # Application set a custom unique directory name + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${CPM_ARGS_CUSTOM_CACHE_KEY}) + elseif(CPM_USE_NAMED_CACHE_DIRECTORIES) + string(SHA1 origin_hash "${origin_parameters};NEW_CACHE_STRUCTURE_TAG") + cpm_get_shortest_hash( + "${CPM_SOURCE_CACHE}/${lower_case_name}" # source cache directory + "${origin_hash}" # Input hash + origin_hash # Computed hash + ) + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}/${CPM_ARGS_NAME}) + else() + string(SHA1 origin_hash "${origin_parameters}") + cpm_get_shortest_hash( + "${CPM_SOURCE_CACHE}/${lower_case_name}" # source cache directory + "${origin_hash}" # Input hash + origin_hash # Computed hash + ) + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}) + endif() + # Expand `download_directory` relative path. This is important because EXISTS doesn't work for + # relative paths. + get_filename_component(download_directory ${download_directory} ABSOLUTE) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${download_directory}) + + if(CPM_SOURCE_CACHE) + file(LOCK ${download_directory}/../cmake.lock) + endif() + + if(EXISTS ${download_directory}) + if(CPM_SOURCE_CACHE) + file(LOCK ${download_directory}/../cmake.lock RELEASE) + endif() + + cpm_store_fetch_properties( + ${CPM_ARGS_NAME} "${download_directory}" + "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" + ) + cpm_get_fetch_properties("${CPM_ARGS_NAME}") + + if(DEFINED CPM_ARGS_GIT_TAG AND NOT (PATCH_COMMAND IN_LIST CPM_ARGS_UNPARSED_ARGUMENTS)) + # warn if cache has been changed since checkout + cpm_check_git_working_dir_is_clean(${download_directory} ${CPM_ARGS_GIT_TAG} IS_CLEAN) + if(NOT ${IS_CLEAN}) + message( + WARNING "${CPM_INDENT} Cache for ${CPM_ARGS_NAME} (${download_directory}) is dirty" + ) + endif() + endif() + + cpm_add_subdirectory( + "${CPM_ARGS_NAME}" + "${DOWNLOAD_ONLY}" + "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + "${${CPM_ARGS_NAME}_BINARY_DIR}" + "${CPM_ARGS_EXCLUDE_FROM_ALL}" + "${CPM_ARGS_SYSTEM}" + "${CPM_ARGS_OPTIONS}" + ) + set(PACKAGE_INFO "${PACKAGE_INFO} at ${download_directory}") + + # As the source dir is already cached/populated, we override the call to FetchContent. + set(CPM_SKIP_FETCH TRUE) + cpm_override_fetchcontent( + "${lower_case_name}" SOURCE_DIR "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + BINARY_DIR "${${CPM_ARGS_NAME}_BINARY_DIR}" + ) + + else() + # Enable shallow clone when GIT_TAG is not a commit hash. Our guess may not be accurate, but + # it should guarantee no commit hash get mis-detected. + if(NOT DEFINED CPM_ARGS_GIT_SHALLOW) + cpm_is_git_tag_commit_hash("${CPM_ARGS_GIT_TAG}" IS_HASH) + if(NOT ${IS_HASH}) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW TRUE) + endif() + endif() + + # remove timestamps so CMake will re-download the dependency + file(REMOVE_RECURSE ${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild) + set(PACKAGE_INFO "${PACKAGE_INFO} to ${download_directory}") + endif() + endif() + + if(NOT "${DOWNLOAD_ONLY}") + cpm_create_module_file(${CPM_ARGS_NAME} "CPMAddPackage(\"${ARGN}\")") + endif() + + if(CPM_PACKAGE_LOCK_ENABLED) + if((CPM_ARGS_VERSION AND NOT CPM_ARGS_SOURCE_DIR) OR CPM_INCLUDE_ALL_IN_PACKAGE_LOCK) + cpm_add_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") + elseif(CPM_ARGS_SOURCE_DIR) + cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "local directory") + else() + cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") + endif() + endif() + + cpm_message( + STATUS "${CPM_INDENT} Adding package ${CPM_ARGS_NAME}@${CPM_ARGS_VERSION} (${PACKAGE_INFO})" + ) + + if(NOT CPM_SKIP_FETCH) + # CMake 3.28 added EXCLUDE, SYSTEM (3.25), and SOURCE_SUBDIR (3.18) to FetchContent_Declare. + # Calling FetchContent_MakeAvailable will then internally forward these options to + # add_subdirectory. Up until these changes, we had to call FetchContent_Populate and + # add_subdirectory separately, which is no longer necessary and has been deprecated as of 3.30. + # A Bug in CMake prevents us to use the non-deprecated functions until 3.30.3. + set(fetchContentDeclareExtraArgs "") + if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") + if(${CPM_ARGS_EXCLUDE_FROM_ALL}) + list(APPEND fetchContentDeclareExtraArgs EXCLUDE_FROM_ALL) + endif() + if(${CPM_ARGS_SYSTEM}) + list(APPEND fetchContentDeclareExtraArgs SYSTEM) + endif() + if(DEFINED CPM_ARGS_SOURCE_SUBDIR) + list(APPEND fetchContentDeclareExtraArgs SOURCE_SUBDIR ${CPM_ARGS_SOURCE_SUBDIR}) + endif() + # For CMake version <3.28 OPTIONS are parsed in cpm_add_subdirectory + if(CPM_ARGS_OPTIONS AND NOT DOWNLOAD_ONLY) + foreach(OPTION ${CPM_ARGS_OPTIONS}) + cpm_parse_option("${OPTION}") + set(${OPTION_KEY} "${OPTION_VALUE}") + endforeach() + endif() + endif() + cpm_declare_fetch( + "${CPM_ARGS_NAME}" ${fetchContentDeclareExtraArgs} "${CPM_ARGS_UNPARSED_ARGUMENTS}" + ) + + cpm_fetch_package("${CPM_ARGS_NAME}" ${DOWNLOAD_ONLY} populated ${CPM_ARGS_UNPARSED_ARGUMENTS}) + if(CPM_SOURCE_CACHE AND download_directory) + file(LOCK ${download_directory}/../cmake.lock RELEASE) + endif() + if(${populated} AND ${CMAKE_VERSION} VERSION_LESS "3.30.3") + cpm_add_subdirectory( + "${CPM_ARGS_NAME}" + "${DOWNLOAD_ONLY}" + "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + "${${CPM_ARGS_NAME}_BINARY_DIR}" + "${CPM_ARGS_EXCLUDE_FROM_ALL}" + "${CPM_ARGS_SYSTEM}" + "${CPM_ARGS_OPTIONS}" + ) + endif() + cpm_get_fetch_properties("${CPM_ARGS_NAME}") + endif() + + set(${CPM_ARGS_NAME}_ADDED YES) + cpm_export_variables("${CPM_ARGS_NAME}") +endfunction() + +# Fetch a previously declared package +macro(CPMGetPackage Name) + if(DEFINED "CPM_DECLARATION_${Name}") + CPMAddPackage(NAME ${Name}) + else() + message(SEND_ERROR "${CPM_INDENT} Cannot retrieve package ${Name}: no declaration available") + endif() +endmacro() + +# export variables available to the caller to the parent scope expects ${CPM_ARGS_NAME} to be set +macro(cpm_export_variables name) + set(${name}_SOURCE_DIR + "${${name}_SOURCE_DIR}" + PARENT_SCOPE + ) + set(${name}_BINARY_DIR + "${${name}_BINARY_DIR}" + PARENT_SCOPE + ) + set(${name}_ADDED + "${${name}_ADDED}" + PARENT_SCOPE + ) + set(CPM_LAST_PACKAGE_NAME + "${name}" + PARENT_SCOPE + ) +endmacro() + +# declares a package, so that any call to CPMAddPackage for the package name will use these +# arguments instead. Previous declarations will not be overridden. +macro(CPMDeclarePackage Name) + if(NOT DEFINED "CPM_DECLARATION_${Name}") + set("CPM_DECLARATION_${Name}" "${ARGN}") + endif() +endmacro() + +function(cpm_add_to_package_lock Name) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + cpm_prettify_package_arguments(PRETTY_ARGN false ${ARGN}) + file(APPEND ${CPM_PACKAGE_LOCK_FILE} "# ${Name}\nCPMDeclarePackage(${Name}\n${PRETTY_ARGN})\n") + endif() +endfunction() + +function(cpm_add_comment_to_package_lock Name) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + cpm_prettify_package_arguments(PRETTY_ARGN true ${ARGN}) + file(APPEND ${CPM_PACKAGE_LOCK_FILE} + "# ${Name} (unversioned)\n# CPMDeclarePackage(${Name}\n${PRETTY_ARGN}#)\n" + ) + endif() +endfunction() + +# includes the package lock file if it exists and creates a target `cpm-update-package-lock` to +# update it +macro(CPMUsePackageLock file) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + get_filename_component(CPM_ABSOLUTE_PACKAGE_LOCK_PATH ${file} ABSOLUTE) + if(EXISTS ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) + include(${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) + endif() + if(NOT TARGET cpm-update-package-lock) + add_custom_target( + cpm-update-package-lock COMMAND ${CMAKE_COMMAND} -E copy ${CPM_PACKAGE_LOCK_FILE} + ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH} + ) + endif() + set(CPM_PACKAGE_LOCK_ENABLED true) + endif() +endmacro() + +# registers a package that has been added to CPM +function(CPMRegisterPackage PACKAGE VERSION) + list(APPEND CPM_PACKAGES ${PACKAGE}) + set(CPM_PACKAGES + ${CPM_PACKAGES} + CACHE INTERNAL "" + ) + set("CPM_PACKAGE_${PACKAGE}_VERSION" + ${VERSION} + CACHE INTERNAL "" + ) +endfunction() + +# retrieve the current version of the package to ${OUTPUT} +function(CPMGetPackageVersion PACKAGE OUTPUT) + set(${OUTPUT} + "${CPM_PACKAGE_${PACKAGE}_VERSION}" + PARENT_SCOPE + ) +endfunction() + +# declares a package in FetchContent_Declare +function(cpm_declare_fetch PACKAGE) + if(${CPM_DRY_RUN}) + cpm_message(STATUS "${CPM_INDENT} Package not declared (dry run)") + return() + endif() + + FetchContent_Declare(${PACKAGE} ${ARGN}) +endfunction() + +# returns properties for a package previously defined by cpm_declare_fetch +function(cpm_get_fetch_properties PACKAGE) + if(${CPM_DRY_RUN}) + return() + endif() + + set(${PACKAGE}_SOURCE_DIR + "${CPM_PACKAGE_${PACKAGE}_SOURCE_DIR}" + PARENT_SCOPE + ) + set(${PACKAGE}_BINARY_DIR + "${CPM_PACKAGE_${PACKAGE}_BINARY_DIR}" + PARENT_SCOPE + ) +endfunction() + +function(cpm_store_fetch_properties PACKAGE source_dir binary_dir) + if(${CPM_DRY_RUN}) + return() + endif() + + set(CPM_PACKAGE_${PACKAGE}_SOURCE_DIR + "${source_dir}" + CACHE INTERNAL "" + ) + set(CPM_PACKAGE_${PACKAGE}_BINARY_DIR + "${binary_dir}" + CACHE INTERNAL "" + ) +endfunction() + +# adds a package as a subdirectory if viable, according to provided options +function( + cpm_add_subdirectory + PACKAGE + DOWNLOAD_ONLY + SOURCE_DIR + BINARY_DIR + EXCLUDE + SYSTEM + OPTIONS +) + + if(NOT DOWNLOAD_ONLY AND EXISTS ${SOURCE_DIR}/CMakeLists.txt) + set(addSubdirectoryExtraArgs "") + if(EXCLUDE) + list(APPEND addSubdirectoryExtraArgs EXCLUDE_FROM_ALL) + endif() + if("${SYSTEM}" AND "${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.25") + # https://cmake.org/cmake/help/latest/prop_dir/SYSTEM.html#prop_dir:SYSTEM + list(APPEND addSubdirectoryExtraArgs SYSTEM) + endif() + if(OPTIONS) + foreach(OPTION ${OPTIONS}) + cpm_parse_option("${OPTION}") + set(${OPTION_KEY} "${OPTION_VALUE}") + endforeach() + endif() + set(CPM_OLD_INDENT "${CPM_INDENT}") + set(CPM_INDENT "${CPM_INDENT} ${PACKAGE}:") + add_subdirectory(${SOURCE_DIR} ${BINARY_DIR} ${addSubdirectoryExtraArgs}) + set(CPM_INDENT "${CPM_OLD_INDENT}") + endif() +endfunction() + +# downloads a previously declared package via FetchContent and exports the variables +# `${PACKAGE}_SOURCE_DIR` and `${PACKAGE}_BINARY_DIR` to the parent scope +function(cpm_fetch_package PACKAGE DOWNLOAD_ONLY populated) + set(${populated} + FALSE + PARENT_SCOPE + ) + if(${CPM_DRY_RUN}) + cpm_message(STATUS "${CPM_INDENT} Package ${PACKAGE} not fetched (dry run)") + return() + endif() + + FetchContent_GetProperties(${PACKAGE}) + + string(TOLOWER "${PACKAGE}" lower_case_name) + + if(NOT ${lower_case_name}_POPULATED) + if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") + if(DOWNLOAD_ONLY) + # MakeAvailable will call add_subdirectory internally which is not what we want when + # DOWNLOAD_ONLY is set. Populate will only download the dependency without adding it to the + # build + FetchContent_Populate( + ${PACKAGE} + SOURCE_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-src" + BINARY_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" + SUBBUILD_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild" + ${ARGN} + ) + else() + FetchContent_MakeAvailable(${PACKAGE}) + endif() + else() + FetchContent_Populate(${PACKAGE}) + endif() + set(${populated} + TRUE + PARENT_SCOPE + ) + endif() + + cpm_store_fetch_properties( + ${CPM_ARGS_NAME} ${${lower_case_name}_SOURCE_DIR} ${${lower_case_name}_BINARY_DIR} + ) + + set(${PACKAGE}_SOURCE_DIR + ${${lower_case_name}_SOURCE_DIR} + PARENT_SCOPE + ) + set(${PACKAGE}_BINARY_DIR + ${${lower_case_name}_BINARY_DIR} + PARENT_SCOPE + ) +endfunction() + +# splits a package option +function(cpm_parse_option OPTION) + string(REGEX MATCH "^[^ ]+" OPTION_KEY "${OPTION}") + string(LENGTH "${OPTION}" OPTION_LENGTH) + string(LENGTH "${OPTION_KEY}" OPTION_KEY_LENGTH) + if(OPTION_KEY_LENGTH STREQUAL OPTION_LENGTH) + # no value for key provided, assume user wants to set option to "ON" + set(OPTION_VALUE "ON") + else() + math(EXPR OPTION_KEY_LENGTH "${OPTION_KEY_LENGTH}+1") + string(SUBSTRING "${OPTION}" "${OPTION_KEY_LENGTH}" "-1" OPTION_VALUE) + endif() + set(OPTION_KEY + "${OPTION_KEY}" + PARENT_SCOPE + ) + set(OPTION_VALUE + "${OPTION_VALUE}" + PARENT_SCOPE + ) +endfunction() + +# guesses the package version from a git tag +function(cpm_get_version_from_git_tag GIT_TAG RESULT) + string(LENGTH ${GIT_TAG} length) + if(length EQUAL 40) + # GIT_TAG is probably a git hash + set(${RESULT} + 0 + PARENT_SCOPE + ) + else() + string(REGEX MATCH "v?([0123456789.]*).*" _ ${GIT_TAG}) + set(${RESULT} + ${CMAKE_MATCH_1} + PARENT_SCOPE + ) + endif() +endfunction() + +# guesses if the git tag is a commit hash or an actual tag or a branch name. +function(cpm_is_git_tag_commit_hash GIT_TAG RESULT) + string(LENGTH "${GIT_TAG}" length) + # full hash has 40 characters, and short hash has at least 7 characters. + if(length LESS 7 OR length GREATER 40) + set(${RESULT} + 0 + PARENT_SCOPE + ) + else() + if(${GIT_TAG} MATCHES "^[a-fA-F0-9]+$") + set(${RESULT} + 1 + PARENT_SCOPE + ) + else() + set(${RESULT} + 0 + PARENT_SCOPE + ) + endif() + endif() +endfunction() + +function(cpm_prettify_package_arguments OUT_VAR IS_IN_COMMENT) + set(oneValueArgs + NAME + FORCE + VERSION + GIT_TAG + DOWNLOAD_ONLY + GITHUB_REPOSITORY + GITLAB_REPOSITORY + BITBUCKET_REPOSITORY + GIT_REPOSITORY + SOURCE_DIR + FIND_PACKAGE_ARGUMENTS + NO_CACHE + SYSTEM + GIT_SHALLOW + EXCLUDE_FROM_ALL + SOURCE_SUBDIR + ) + set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND) + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + foreach(oneArgName ${oneValueArgs}) + if(DEFINED CPM_ARGS_${oneArgName}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + if(${oneArgName} STREQUAL "SOURCE_DIR") + string(REPLACE ${CMAKE_SOURCE_DIR} "\${CMAKE_SOURCE_DIR}" CPM_ARGS_${oneArgName} + ${CPM_ARGS_${oneArgName}} + ) + endif() + string(APPEND PRETTY_OUT_VAR " ${oneArgName} ${CPM_ARGS_${oneArgName}}\n") + endif() + endforeach() + foreach(multiArgName ${multiValueArgs}) + if(DEFINED CPM_ARGS_${multiArgName}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " ${multiArgName}\n") + foreach(singleOption ${CPM_ARGS_${multiArgName}}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " \"${singleOption}\"\n") + endforeach() + endif() + endforeach() + + if(NOT "${CPM_ARGS_UNPARSED_ARGUMENTS}" STREQUAL "") + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " ") + foreach(CPM_ARGS_UNPARSED_ARGUMENT ${CPM_ARGS_UNPARSED_ARGUMENTS}) + string(APPEND PRETTY_OUT_VAR " ${CPM_ARGS_UNPARSED_ARGUMENT}") + endforeach() + string(APPEND PRETTY_OUT_VAR "\n") + endif() + + set(${OUT_VAR} + ${PRETTY_OUT_VAR} + PARENT_SCOPE + ) + +endfunction() diff --git a/cmake/CompilerWarnings.cmake b/cmake/CompilerWarnings.cmake new file mode 100644 index 0000000..65dd271 --- /dev/null +++ b/cmake/CompilerWarnings.cmake @@ -0,0 +1,29 @@ +# CompilerWarnings.cmake — project-wide warning configuration. +# +# Defines the `modulo_warnings` INTERFACE target carrying the warning flags +# shared by every first-party target, and `modulo_enable_warnings()` +# to attach them. Third-party code fetched via CPM is never touched. +# +# The flag set is controlled by the MODULO_WARNINGS_AS_ERRORS option +# (declared in the root CMakeLists.txt, enabled by the `dev` preset). + +include_guard(GLOBAL) + +add_library(modulo_warnings INTERFACE) + +target_compile_options( + modulo_warnings + INTERFACE -Wall + -Wextra + -Wpedantic + -Wconversion + -Wshadow) + +if(MODULO_WARNINGS_AS_ERRORS) + target_compile_options(modulo_warnings INTERFACE -Werror) +endif() + +# Attach the shared warning flags to a first-party target. +function(modulo_enable_warnings target) + target_link_libraries(${target} PRIVATE modulo_warnings) +endfunction() diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake new file mode 100644 index 0000000..c099193 --- /dev/null +++ b/cmake/Dependencies.cmake @@ -0,0 +1,60 @@ +# Dependencies.cmake — single home for every third-party dependency. +# +# `modulo_find_dependencies()` resolves, in one place: +# - Homebrew binary libs: Qt 6.8+, libpqxx, libsodium +# - CPM-pinned source libs: Catch2 v3, nlohmann-json +# +# A macro (not a function) so find_package results land in the caller's +# directory scope. Called exactly once, from the root CMakeLists.txt. + +include_guard(GLOBAL) + +# Source dependencies are cached outside the build tree so wiping build/ +# does not re-download them (.cache/ is gitignored). Must be set BEFORE +# include(CPM): CPM initializes this cache variable itself on include, and +# a later set(... CACHE ...) would not override the existing entry. +set(CPM_SOURCE_CACHE + "${CMAKE_SOURCE_DIR}/.cache/cpm" + CACHE PATH "Download cache for CPM source dependencies") + +include(CPM) + +macro(modulo_find_dependencies) + # --- Homebrew binary libraries ------------------------------------------- + # Qt path comes from CMAKE_PREFIX_PATH (set by the presets: /opt/homebrew/opt/qt). + find_package( + Qt6 6.8 REQUIRED + COMPONENTS Core + Network + HttpServer + Qml + Quick + QuickControls2 + Test + QuickTest) + + # libpqxx ships CMake package config (target: libpqxx::pqxx). + find_package(libpqxx REQUIRED) + + # libsodium ships no CMake config, only pkg-config; locate it directly so + # the build has no pkg-config dependency (works identically in Docker later). + if(NOT TARGET sodium::sodium) + find_path(MODULO_SODIUM_INCLUDE_DIR sodium.h) + find_library(MODULO_SODIUM_LIBRARY sodium) + if(NOT MODULO_SODIUM_INCLUDE_DIR OR NOT MODULO_SODIUM_LIBRARY) + message(FATAL_ERROR "libsodium not found (brew install libsodium)") + endif() + add_library(sodium::sodium UNKNOWN IMPORTED) + set_target_properties( + sodium::sodium + PROPERTIES IMPORTED_LOCATION "${MODULO_SODIUM_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${MODULO_SODIUM_INCLUDE_DIR}") + endif() + + # --- CPM source libraries (version-pinned) ------------------------------- + if(MODULO_BUILD_TESTS) + cpmaddpackage("gh:catchorg/Catch2@3.8.1") + endif() + + cpmaddpackage("gh:nlohmann/json@3.11.3") +endmacro() diff --git a/cmake/ModuloTargets.cmake b/cmake/ModuloTargets.cmake new file mode 100644 index 0000000..445a39d --- /dev/null +++ b/cmake/ModuloTargets.cmake @@ -0,0 +1,134 @@ +# ModuloTargets.cmake — declarative target creation for first-party code. +# +# Every CMakeLists.txt in the repo stays a short, generic call into one of +# these functions; all shared logic (C++23, include/src layout, warnings, +# sanitizers, clang-tidy, CTest registration) lives here. +# +# modulo_add_library( SOURCES ... [PUBLIC_DEPS ...] [PRIVATE_DEPS ...]) +# Static library following the module convention: public headers in +# ./include (as ), implementation in ./src. +# +# modulo_add_executable( SOURCES ... [DEPS ...]) +# Application or tool binary. +# +# modulo_add_test( LABEL unit|integration SOURCES ... [DEPS ...]) +# Catch2 test binary, registered with CTest under the given label +# (labels drive the `unit` / `integration` / `all` test presets). +# +# modulo_add_qml_test( QML_DIR SOURCES ... [DEPS ...]) +# Qt Quick Test binary running the tst_*.qml files in QML_DIR, +# registered with CTest under the `ui` label. +# +# Test functions are no-ops when MODULO_BUILD_TESTS is OFF. + +include_guard(GLOBAL) + +include(CompilerWarnings) +include(Sanitizers) +include(StaticAnalysis) + +# Settings common to every first-party target (never applied to third-party code). +function(_modulo_apply_common_settings target) + target_compile_features(${target} PUBLIC cxx_std_23) + set_target_properties(${target} PROPERTIES CXX_EXTENSIONS OFF) + modulo_enable_warnings(${target}) + modulo_enable_sanitizers(${target}) + modulo_enable_clang_tidy(${target}) +endfunction() + +function(modulo_add_library name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "" "SOURCES;PUBLIC_DEPS;PRIVATE_DEPS") + + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_library(${name}): SOURCES is required") + endif() + + add_library(${name} STATIC ${ARG_SOURCES}) + + # Module convention: consumers include from ./include; + # implementation files may include internals from ./src. + target_include_directories( + ${name} + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + + if(ARG_PUBLIC_DEPS) + target_link_libraries(${name} PUBLIC ${ARG_PUBLIC_DEPS}) + endif() + if(ARG_PRIVATE_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_PRIVATE_DEPS}) + endif() + + _modulo_apply_common_settings(${name}) +endfunction() + +function(modulo_add_executable name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "" "SOURCES;DEPS") + + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_executable(${name}): SOURCES is required") + endif() + + add_executable(${name} ${ARG_SOURCES}) + + if(ARG_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_DEPS}) + endif() + + _modulo_apply_common_settings(${name}) +endfunction() + +function(modulo_add_test name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "LABEL" "SOURCES;DEPS") + + if(NOT MODULO_BUILD_TESTS) + return() + endif() + + if(NOT ARG_LABEL MATCHES "^(unit|integration)$") + message(FATAL_ERROR "modulo_add_test(${name}): LABEL must be 'unit' or 'integration'") + endif() + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_test(${name}): SOURCES is required") + endif() + + add_executable(${name} ${ARG_SOURCES}) + target_link_libraries(${name} PRIVATE Catch2::Catch2WithMain) + if(ARG_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_DEPS}) + endif() + + _modulo_apply_common_settings(${name}) + + add_test(NAME ${name} COMMAND ${name}) + set_tests_properties(${name} PROPERTIES LABELS ${ARG_LABEL}) +endfunction() + +function(modulo_add_qml_test name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "QML_DIR" "SOURCES;DEPS") + + if(NOT MODULO_BUILD_TESTS) + return() + endif() + + if(NOT ARG_QML_DIR) + message(FATAL_ERROR "modulo_add_qml_test(${name}): QML_DIR is required") + endif() + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_qml_test(${name}): SOURCES is required") + endif() + + add_executable(${name} ${ARG_SOURCES}) + target_link_libraries(${name} PRIVATE Qt6::QuickTest Qt6::Qml) + if(ARG_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_DEPS}) + endif() + + # QUICK_TEST_SOURCE_DIR points the QUICK_TEST_MAIN runner at the tst_*.qml files. + target_compile_definitions(${name} PRIVATE QUICK_TEST_SOURCE_DIR="${ARG_QML_DIR}") + + _modulo_apply_common_settings(${name}) + + add_test(NAME ${name} COMMAND ${name}) + set_tests_properties(${name} PROPERTIES LABELS ui) +endfunction() diff --git a/cmake/Sanitizers.cmake b/cmake/Sanitizers.cmake new file mode 100644 index 0000000..6aa2d45 --- /dev/null +++ b/cmake/Sanitizers.cmake @@ -0,0 +1,20 @@ +# Sanitizers.cmake — runtime sanitizer instrumentation. +# +# Defines `modulo_enable_sanitizers()`, which honors the +# MODULO_SANITIZERS cache variable: a comma-separated -fsanitize= value such +# as "address,undefined" (as set by the `dev-asan` preset). When the variable +# is empty (the default) this function is a no-op, so plain builds carry no +# instrumentation cost. + +include_guard(GLOBAL) + +# Instrument a first-party target with the sanitizers named in MODULO_SANITIZERS. +function(modulo_enable_sanitizers target) + if(NOT MODULO_SANITIZERS) + return() + endif() + + # -fno-omit-frame-pointer keeps sanitizer stack traces readable. + target_compile_options(${target} PRIVATE -fsanitize=${MODULO_SANITIZERS} -fno-omit-frame-pointer) + target_link_options(${target} PRIVATE -fsanitize=${MODULO_SANITIZERS}) +endfunction() diff --git a/cmake/StaticAnalysis.cmake b/cmake/StaticAnalysis.cmake new file mode 100644 index 0000000..83642e1 --- /dev/null +++ b/cmake/StaticAnalysis.cmake @@ -0,0 +1,29 @@ +# StaticAnalysis.cmake — clang-tidy integration. +# +# Defines `modulo_enable_clang_tidy()`, which honors the +# MODULO_CLANG_TIDY option (enabled by the `dev-tidy` preset). When on, +# every compile of the target also runs clang-tidy with the repo-root +# .clang-tidy configuration. +# +# Homebrew LLVM is keg-only, so the binary is referenced by absolute path; +# override with -DMODULO_CLANG_TIDY_EXE=... on other machines. + +include_guard(GLOBAL) + +set(MODULO_CLANG_TIDY_EXE + "/opt/homebrew/opt/llvm/bin/clang-tidy" + CACHE FILEPATH "clang-tidy executable used when MODULO_CLANG_TIDY is ON") + +# Run clang-tidy alongside compilation for a first-party target. +function(modulo_enable_clang_tidy target) + if(NOT MODULO_CLANG_TIDY) + return() + endif() + + if(NOT EXISTS "${MODULO_CLANG_TIDY_EXE}") + message(FATAL_ERROR "MODULO_CLANG_TIDY is ON but clang-tidy was not found at " + "'${MODULO_CLANG_TIDY_EXE}' (brew install llvm, or set MODULO_CLANG_TIDY_EXE)") + endif() + + set_target_properties(${target} PROPERTIES CXX_CLANG_TIDY "${MODULO_CLANG_TIDY_EXE}") +endfunction() From d771f39297becd4e0df7c09d406d1ec42a6be533 Mon Sep 17 00:00:00 2001 From: angelobarbu Date: Fri, 7 Aug 2026 17:59:09 +0300 Subject: [PATCH 4/9] Increment 1 - Step 3: Docker Compose Postgres --- README.md | 130 +++++++++++++++++++++++++++- docker/docker-compose.yml | 35 ++++++++ docker/initdb/01_create_test_db.sql | 4 + scripts/db-down.sh | 20 +++++ scripts/db-up.sh | 15 ++++ 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 docker/docker-compose.yml create mode 100644 docker/initdb/01_create_test_db.sql create mode 100755 scripts/db-down.sh create mode 100755 scripts/db-up.sh diff --git a/README.md b/README.md index cf620ba..b5eb2a4 100644 --- a/README.md +++ b/README.md @@ -1 +1,129 @@ -# Modulo \ No newline at end of file +# Modulo + +A personal investment tracker for crypto and stock assets — transactions, bank↔exchange +transfers, aggregated holdings with dashboards, uploaded documents, and daily exchange-rate +updates. + +**Architecture:** client-server. A C++23 REST backend (Qt `QHttpServer`) owns PostgreSQL, +authentication sessions, and business logic; a Qt 6 / QML desktop client for macOS consumes +the API. The backend is designed to be containerized later, and future web/mobile clients +can target the same API. + +**Stack:** C++23 · Qt 6.8 · QML · PostgreSQL 16 · CMake ≥ 3.28 · libpqxx · libsodium · +Catch2 v3 · nlohmann-json + +> Developed incrementally, one reviewed step at a time. This README grows with each step — +> see [Repository layout](#repository-layout) for what exists today. + +## Prerequisites + +One-time setup on macOS (Apple Silicon): + +```sh +brew install cmake ninja llvm libpqxx libsodium qt +``` + +- **Qt 6.8+** is expected at `/opt/homebrew/opt/qt` (the CMake presets bake this path in). +- **llvm** provides `clang-format`/`clang-tidy`; it is keg-only, so scripts and CMake + reference `/opt/homebrew/opt/llvm/bin` by absolute path. +- **Docker Desktop** must be running for the development database. +- The local Homebrew PostgreSQL (if any) can keep running — the dockerized database uses + port **5433** precisely to avoid clashing with a local server on 5432. + +Copy the environment template and adjust if needed: + +```sh +cp .env.example .env +``` + +## Building + +The build is driven entirely by CMake presets: + +```sh +cmake --preset dev # configure (Debug, warnings-as-errors) +cmake --build --preset dev # build +``` + +| Configure preset | Purpose | +|---|---| +| `dev` | Debug build, `-Werror`, compile-commands export | +| `dev-asan` | `dev` + address & undefined-behavior sanitizers | +| `dev-tidy` | `dev` + clang-tidy on every compile | +| `release` | RelWithDebInfo | + +Build directories land in `build//`. Third-party sources fetched by CPM are cached +in `.cache/cpm/` and survive build-directory wipes. + +All build logic lives as `modulo_*` functions in [`cmake/`](cmake/) — +`modulo_add_library`, `modulo_add_executable`, `modulo_add_test`, `modulo_add_qml_test` — +so every `CMakeLists.txt` stays a short declarative call. Each server-side module is its +own static library with public headers in `include/modulo/...` and implementation in +`src/` (header files use the `.h` extension). + +## Development database + +Postgres 16 runs in Docker with a persistent named volume: + +```sh +scripts/db-up.sh # start + wait until healthy +scripts/db-down.sh # stop (data preserved) +scripts/db-down.sh --wipe # stop AND delete all data (asks for confirmation) +``` + +| What | Value | +|---|---| +| Host/port | `localhost:5433` (bound to 127.0.0.1 only) | +| Databases | `modulo_dev` (development), `modulo_test` (integration tests) | +| Credentials | user `modulo`, password `modulo` (dev-only) | +| Volume | `modulo_pgdata` | + +`modulo_test` is created by [`docker/initdb/01_create_test_db.sql`](docker/initdb/01_create_test_db.sql) +on the first initialization of an empty volume. + +## Testing + +Tests are registered with CTest under the labels `unit`, `integration`, and `ui`: + +```sh +ctest --preset unit # fast, no Docker needed +ctest --preset integration # requires the database (scripts/db-up.sh) +ctest --preset ui # QML/Qt Quick tests +ctest --preset all +``` + +(No tests exist yet — the first scaffolding tests arrive with the test-setup step of +Increment 1.) + +## Code style + +- [`.clang-format`](.clang-format) — LLVM base, 4-space indent, 120 columns, `int* p` + pointer style, include groups ordered local → `` → Qt → third-party → std. +- [`.clang-tidy`](.clang-tidy) — `bugprone-*`, `performance-*`, `modernize-*`, + `readability-*` plus naming rules (`CamelCase` types, `camelBack` functions/variables, + trailing-underscore private members). + +```sh +scripts/format.sh # format all first-party sources in place +scripts/format.sh --check # verify only (CI mode) +``` + +## Repository layout + +``` +cmake/ CMake toolkit: all build logic as modulo_* functions + vendored CPM.cmake +docker/ docker-compose.yml (Postgres 16 on :5433) + one-time initdb scripts +scripts/ db-up.sh, db-down.sh, format.sh +CMakeLists.txt thin root: options, toolkit includes, dependency resolution +CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release) +.env.example environment template (DB URLs, HTTP port, data dir) +``` + +## Implementation log + +| Increment / step | Delivered | +|---|---| +| 1.0 — Prerequisites | Toolchain verified: ninja, llvm 22, libpqxx 8, libsodium, Qt 6.8.2 (no QPSQL driver → libpqxx), Docker | +| 1.1 — Style & hygiene | `.clang-format`, `.clang-tidy`, `.env.example`, `.gitignore` extension, `scripts/format.sh` | +| 1.2 — CMake superstructure | Function-based `cmake/` toolkit, vendored CPM v0.42.0, thin root `CMakeLists.txt`, presets | +| 1.3 — Dev database | Dockerized Postgres 16 (`:5433`, named volume, healthcheck), initdb for `modulo_test`, `db-up`/`db-down` scripts | diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..30ab29b --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,35 @@ +# Modulo development database. +# +# Postgres 16 with a named volume for persistence. The host port is 5433 +# (NOT the default 5432) because the local Homebrew PostgreSQL 14 already +# listens on 5432 on this machine. +# +# Managed via scripts/db-up.sh and scripts/db-down.sh. +# Data survives `down`; wipe it with scripts/db-down.sh --wipe. + +name: modulo + +services: + postgres: + image: postgres:16-alpine + container_name: modulo-postgres + restart: unless-stopped + ports: + # Bind to localhost only — never expose the dev database on the network. + - "127.0.0.1:5433:5432" + environment: + POSTGRES_USER: modulo + POSTGRES_PASSWORD: modulo + POSTGRES_DB: modulo_dev + volumes: + - modulo_pgdata:/var/lib/postgresql/data + # Runs *.sql once, on first initialization of an empty volume only. + - ./initdb:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U modulo -d modulo_dev"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + modulo_pgdata: diff --git a/docker/initdb/01_create_test_db.sql b/docker/initdb/01_create_test_db.sql new file mode 100644 index 0000000..9567e6d --- /dev/null +++ b/docker/initdb/01_create_test_db.sql @@ -0,0 +1,4 @@ +-- Create the integration-test database alongside the dev database. +-- Executed by the postgres image entrypoint on FIRST initialization of an +-- empty data volume only (wipe with scripts/db-down.sh --wipe to re-run). +CREATE DATABASE modulo_test OWNER modulo; diff --git a/scripts/db-down.sh b/scripts/db-down.sh new file mode 100755 index 0000000..402df54 --- /dev/null +++ b/scripts/db-down.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Stop the dockerized Modulo development database. +# +# Usage: +# scripts/db-down.sh # stop; data volume is preserved +# scripts/db-down.sh --wipe # stop AND delete all database data +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +DOWN_ARGS=() +if [[ "${1:-}" == "--wipe" ]]; then + read -r -p "This DELETES all data in the dev and test databases. Continue? [y/N] " reply + [[ "${reply}" == "y" || "${reply}" == "Y" ]] || { echo "aborted"; exit 1; } + DOWN_ARGS=(--volumes) +fi + +docker compose -f "${REPO_ROOT}/docker/docker-compose.yml" down "${DOWN_ARGS[@]+"${DOWN_ARGS[@]}"}" + +echo "db-down.sh: done" diff --git a/scripts/db-up.sh b/scripts/db-up.sh new file mode 100755 index 0000000..49f900c --- /dev/null +++ b/scripts/db-up.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Start the dockerized Modulo development database (Postgres 16 on localhost:5433) +# and wait until it is healthy. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! docker info > /dev/null 2>&1; then + echo "error: Docker is not running (open -a Docker)" >&2 + exit 1 +fi + +docker compose -f "${REPO_ROOT}/docker/docker-compose.yml" up -d --wait + +echo "db-up.sh: postgres ready on localhost:5433 (databases: modulo_dev, modulo_test)" From 0031fb810efda17c8395a0f8f007353b4335c3f9 Mon Sep 17 00:00:00 2001 From: angelobarbu Date: Fri, 7 Aug 2026 18:57:22 +0300 Subject: [PATCH 5/9] Increment 1 - Step 4: Migration Engine & Runner --- CMakeLists.txt | 2 +- README.md | 30 +++- db/migrations/0001_init.sql | 16 ++ scripts/migrate.sh | 25 +++ server/CMakeLists.txt | 4 + server/migrate/CMakeLists.txt | 6 + server/migrate/main.cpp | 80 +++++++++ server/modules/db/CMakeLists.txt | 7 + .../db/include/modulo/server/db/migrator.h | 68 ++++++++ server/modules/db/src/migrator.cpp | 154 ++++++++++++++++++ 10 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 db/migrations/0001_init.sql create mode 100755 scripts/migrate.sh create mode 100644 server/CMakeLists.txt create mode 100644 server/migrate/CMakeLists.txt create mode 100644 server/migrate/main.cpp create mode 100644 server/modules/db/CMakeLists.txt create mode 100644 server/modules/db/include/modulo/server/db/migrator.h create mode 100644 server/modules/db/src/migrator.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b6adf0f..ff011dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,5 +35,5 @@ enable_testing() # Subdirectories are appended as the increments introduce them: # add_subdirectory(libs/core) # Increment 1, Step 5 # add_subdirectory(libs/api) # Increment 1, Step 5 -# add_subdirectory(server) # Increment 1, Steps 4-5 +add_subdirectory(server) # add_subdirectory(client) # Increment 1, Step 5 diff --git a/README.md b/README.md index b5eb2a4..995810e 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,29 @@ scripts/db-down.sh --wipe # stop AND delete all data (asks for confirmation) `modulo_test` is created by [`docker/initdb/01_create_test_db.sql`](docker/initdb/01_create_test_db.sql) on the first initialization of an empty volume. +### Migrations + +Schema changes are plain SQL files in [`db/migrations/`](db/migrations/), named +`NNNN_name.sql` and applied in version order by the `modulo_migrate` binary +(module `modulo_server_db`, wrapped by a script): + +```sh +scripts/migrate.sh # applies pending migrations to $MODULO_DB_URL +``` + +The runner tracks state in a `schema_migrations` table (version, name, content +checksum, timestamp) and enforces these rules: + +- each migration runs inside **one transaction** — a failure rolls back cleanly; +- already-applied, unchanged files are **skipped** (re-running is a no-op); +- migration files are **append-only**: editing an applied file changes its + checksum and the runner refuses to continue; +- a stray non-migration file in the directory is an error (dotfiles are tolerated). + +The database URL resolves in order: existing `MODULO_DB_URL` in the environment → +`.env` at the repo root → the dev-database default. `modulo_migrate --help` shows +the underlying CLI (`--url`, `--dir`). + ## Testing Tests are registered with CTest under the labels `unit`, `integration`, and `ui`: @@ -112,8 +135,12 @@ scripts/format.sh --check # verify only (CI mode) ``` cmake/ CMake toolkit: all build logic as modulo_* functions + vendored CPM.cmake +db/migrations/ append-only SQL schema migrations (NNNN_name.sql) docker/ docker-compose.yml (Postgres 16 on :5433) + one-time initdb scripts -scripts/ db-up.sh, db-down.sh, format.sh +scripts/ db-up.sh, db-down.sh, migrate.sh, format.sh +server/ backend: per-module static libraries + executables + modules/db/ modulo_server_db — migration engine (connection pool arrives in Increment 2) + migrate/ modulo_migrate — CLI migration runner CMakeLists.txt thin root: options, toolkit includes, dependency resolution CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release) .env.example environment template (DB URLs, HTTP port, data dir) @@ -127,3 +154,4 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release | 1.1 — Style & hygiene | `.clang-format`, `.clang-tidy`, `.env.example`, `.gitignore` extension, `scripts/format.sh` | | 1.2 — CMake superstructure | Function-based `cmake/` toolkit, vendored CPM v0.42.0, thin root `CMakeLists.txt`, presets | | 1.3 — Dev database | Dockerized Postgres 16 (`:5433`, named volume, healthcheck), initdb for `modulo_test`, `db-up`/`db-down` scripts | +| 1.4 — Migrations | `modulo_server_db` module (first server static lib) with transactional, checksum-verified migration engine; `modulo_migrate` CLI; `0001_init.sql`; `scripts/migrate.sh` | diff --git a/db/migrations/0001_init.sql b/db/migrations/0001_init.sql new file mode 100644 index 0000000..195775b --- /dev/null +++ b/db/migrations/0001_init.sql @@ -0,0 +1,16 @@ +-- 0001_init: baseline migration. +-- +-- Establishes the meta table and exercises the migration pipeline end to end. +-- Real schema (auth, transactions, ...) arrives in later increments, one +-- append-only migration file each. Applied migration files must NEVER be +-- edited — the runner verifies checksums and refuses to continue if one +-- changes. + +CREATE TABLE meta ( + key text PRIMARY KEY, + value text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO meta (key, value) +VALUES ('schema_baseline', 'increment-1'); diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000..0227a45 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Apply pending database migrations using the modulo_migrate binary. +# +# The database URL comes from, in order: an existing MODULO_DB_URL in the +# environment, the repo-root .env file, or the dev-database default. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ -z "${MODULO_DB_URL:-}" && -f "${REPO_ROOT}/.env" ]]; then + set -a + # shellcheck source=/dev/null + source "${REPO_ROOT}/.env" + set +a +fi +export MODULO_DB_URL="${MODULO_DB_URL:-postgresql://modulo:modulo@localhost:5433/modulo_dev}" + +MIGRATE_BIN="${REPO_ROOT}/build/dev/server/migrate/modulo_migrate" +if [[ ! -x "${MIGRATE_BIN}" ]]; then + echo "error: ${MIGRATE_BIN} not found — build it first:" >&2 + echo " cmake --preset dev && cmake --build --preset dev" >&2 + exit 1 +fi + +exec "${MIGRATE_BIN}" --dir "${REPO_ROOT}/db/migrations" diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt new file mode 100644 index 0000000..dbfee29 --- /dev/null +++ b/server/CMakeLists.txt @@ -0,0 +1,4 @@ +# Server-side modules (one static library each) and executables. + +add_subdirectory(modules/db) +add_subdirectory(migrate) diff --git a/server/migrate/CMakeLists.txt b/server/migrate/CMakeLists.txt new file mode 100644 index 0000000..c4f155b --- /dev/null +++ b/server/migrate/CMakeLists.txt @@ -0,0 +1,6 @@ +# modulo_migrate — command-line migration runner (scripts/migrate.sh wraps it). + +modulo_add_executable( + modulo_migrate + SOURCES main.cpp + DEPS modulo_server_db) diff --git a/server/migrate/main.cpp b/server/migrate/main.cpp new file mode 100644 index 0000000..1635799 --- /dev/null +++ b/server/migrate/main.cpp @@ -0,0 +1,80 @@ +// modulo_migrate — command-line migration runner. +// +// Usage: +// modulo_migrate [--url ] [--dir ] +// +// The database URL falls back to the MODULO_DB_URL environment variable; +// the migrations directory defaults to db/migrations relative to the +// current working directory (scripts/migrate.sh passes it explicitly). + +#include + +#include +#include +#include +#include +#include + +namespace { + +struct Options { + std::string url; + std::string dir = "db/migrations"; + bool help = false; +}; + +Options parseArguments(std::span args) { + Options options; + if (const char* env = std::getenv("MODULO_DB_URL")) { + options.url = env; + } + + for (std::size_t i = 1; i < args.size(); ++i) { + const std::string_view arg{args[i]}; + if (arg == "--help" || arg == "-h") { + options.help = true; + } else if (arg == "--url" && i + 1 < args.size()) { + options.url = args[++i]; + } else if (arg == "--dir" && i + 1 < args.size()) { + options.dir = args[++i]; + } else { + throw std::invalid_argument(std::string{"unknown or incomplete argument: "} + std::string{arg}); + } + } + return options; +} + +} // namespace + +int main(int argc, char* argv[]) { + Options options; + try { + options = parseArguments(std::span{argv, static_cast(argc)}); + } catch (const std::invalid_argument& error) { + std::println(stderr, "error: {}", error.what()); + return EXIT_FAILURE; + } + + if (options.help) { + std::println("usage: modulo_migrate [--url ] [--dir ]"); + std::println(" --url defaults to $MODULO_DB_URL; --dir defaults to db/migrations"); + return EXIT_SUCCESS; + } + + if (options.url.empty()) { + std::println(stderr, "error: no database URL (pass --url or set MODULO_DB_URL)"); + return EXIT_FAILURE; + } + + try { + modulo::server::db::Migrator migrator{options.url, options.dir, + [](std::string_view line) { std::println("{}", line); }}; + const auto result = migrator.run(); + std::println("migrations: {} applied, {} skipped", result.applied, result.skipped); + return EXIT_SUCCESS; + } catch (const std::exception& error) { + // Never echo options.url here — it may contain credentials. + std::println(stderr, "error: {}", error.what()); + return EXIT_FAILURE; + } +} diff --git a/server/modules/db/CMakeLists.txt b/server/modules/db/CMakeLists.txt new file mode 100644 index 0000000..2513cc7 --- /dev/null +++ b/server/modules/db/CMakeLists.txt @@ -0,0 +1,7 @@ +# modulo_server_db — database access module: migration engine (and, from +# Increment 2, the libpqxx connection pool used by all repositories). + +modulo_add_library( + modulo_server_db + SOURCES src/migrator.cpp + PRIVATE_DEPS libpqxx::pqxx) diff --git a/server/modules/db/include/modulo/server/db/migrator.h b/server/modules/db/include/modulo/server/db/migrator.h new file mode 100644 index 0000000..68a3892 --- /dev/null +++ b/server/modules/db/include/modulo/server/db/migrator.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace modulo::server::db { + +/// Thrown when migration discovery or application fails. The migration that +/// caused the failure is named in the message; the database is left as of the +/// last successfully committed migration. +class MigrationError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +/// A migration file discovered on disk. Files live in db/migrations/ and are +/// named NNNN_name.sql (four-digit version, underscore, snake_case name). +struct Migration { + int version = 0; + std::string name; + std::filesystem::path path; +}; + +/// Outcome of a Migrator::run() invocation. +struct MigrationResult { + int applied = 0; + int skipped = 0; +}; + +/// Applies SQL migration files to a PostgreSQL database. +/// +/// State is tracked in the schema_migrations table (created on demand): +/// one row per applied migration with its version, name, content checksum, +/// and timestamp. Rules: +/// - migrations run in ascending version order, each inside one transaction; +/// - an already-applied migration whose file is unchanged is skipped; +/// - an already-applied migration whose file content CHANGED aborts the run +/// (migrations are append-only — never edit an applied file); +/// - a failing migration rolls back and aborts; nothing after it runs. +class Migrator { +public: + /// Receives one human-readable progress line per migration. + using Logger = std::function; + + Migrator(std::string databaseUrl, std::filesystem::path migrationsDir, Logger logger = {}); + + /// Scan the migrations directory. Non-dot files that do not match the + /// NNNN_name.sql pattern, and duplicate versions, raise MigrationError. + /// Returns migrations sorted by ascending version. + [[nodiscard]] std::vector discover() const; + + /// Apply every pending migration. Throws MigrationError (see class docs) + /// or pqxx errors on connection failure. + MigrationResult run(); + +private: + void log(std::string_view message) const; + + std::string databaseUrl_; + std::filesystem::path migrationsDir_; + Logger logger_; +}; + +} // namespace modulo::server::db diff --git a/server/modules/db/src/migrator.cpp b/server/modules/db/src/migrator.cpp new file mode 100644 index 0000000..1d4d808 --- /dev/null +++ b/server/modules/db/src/migrator.cpp @@ -0,0 +1,154 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace modulo::server::db { + +namespace { + +constexpr std::string_view kCreateSchemaMigrations = R"sql( + CREATE TABLE IF NOT EXISTS schema_migrations ( + version integer PRIMARY KEY, + name text NOT NULL, + checksum text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() + ) +)sql"; + +/// Parse "NNNN_name.sql" into (version, name); std::nullopt if the pattern +/// does not match. +std::optional> parseFilename(const std::string& filename) { + constexpr std::string_view kSuffix = ".sql"; + constexpr std::size_t kVersionDigits = 4; + // Shortest valid: "0000_x.sql" + if (filename.size() < kVersionDigits + 1 + 1 + kSuffix.size() || !filename.ends_with(kSuffix)) { + return std::nullopt; + } + if (filename[kVersionDigits] != '_') { + return std::nullopt; + } + + int version = 0; + const auto [ptr, ec] = std::from_chars(filename.data(), filename.data() + kVersionDigits, version); + if (ec != std::errc{} || ptr != filename.data() + kVersionDigits) { + return std::nullopt; + } + + std::string name = filename.substr(kVersionDigits + 1, filename.size() - kVersionDigits - 1 - kSuffix.size()); + return std::pair{version, std::move(name)}; +} + +std::string readFile(const std::filesystem::path& path) { + std::ifstream stream{path, std::ios::binary}; + if (!stream) { + throw MigrationError(std::format("cannot read migration file '{}'", path.string())); + } + std::ostringstream contents; + contents << stream.rdbuf(); + return std::move(contents).str(); +} + +/// Content checksum, computed by PostgreSQL itself (md5 is fine here: this +/// detects accidental edits of applied files, it is not a security boundary). +std::string checksumOf(pqxx::work& tx, const std::string& sql) { + return tx.query_value("SELECT md5($1)", pqxx::params{sql}); +} + +} // namespace + +Migrator::Migrator(std::string databaseUrl, std::filesystem::path migrationsDir, Logger logger) + : databaseUrl_{std::move(databaseUrl)}, migrationsDir_{std::move(migrationsDir)}, logger_{std::move(logger)} { +} + +std::vector Migrator::discover() const { + if (!std::filesystem::is_directory(migrationsDir_)) { + throw MigrationError(std::format("migrations directory '{}' does not exist", migrationsDir_.string())); + } + + std::vector migrations; + for (const auto& entry : std::filesystem::directory_iterator{migrationsDir_}) { + const std::string filename = entry.path().filename().string(); + if (filename.starts_with('.')) { + continue; // tolerate .DS_Store and friends + } + + auto parsed = parseFilename(filename); + if (!parsed || !entry.is_regular_file()) { + throw MigrationError( + std::format("unexpected file '{}' in migrations directory (expected NNNN_name.sql)", filename)); + } + migrations.push_back({.version = parsed->first, .name = std::move(parsed->second), .path = entry.path()}); + } + + std::ranges::sort(migrations, {}, &Migration::version); + + const auto duplicate = std::ranges::adjacent_find(migrations, {}, &Migration::version); + if (duplicate != migrations.end()) { + throw MigrationError(std::format("duplicate migration version {:04}", duplicate->version)); + } + + return migrations; +} + +MigrationResult Migrator::run() { + const auto migrations = discover(); + + pqxx::connection connection{databaseUrl_}; + + { + pqxx::work tx{connection}; + tx.exec(kCreateSchemaMigrations); + tx.commit(); + } + + MigrationResult result; + for (const auto& migration : migrations) { + pqxx::work tx{connection}; + + const std::string sql = readFile(migration.path); + const std::string checksum = checksumOf(tx, sql); + + const auto known = + tx.exec("SELECT checksum FROM schema_migrations WHERE version = $1", pqxx::params{migration.version}); + if (!known.empty()) { + if (known[0][0].as() != checksum) { + throw MigrationError(std::format("migration {:04}_{} was applied with a different content checksum; " + "applied migration files are append-only and must never be edited", + migration.version, migration.name)); + } + ++result.skipped; + continue; // transaction aborts harmlessly + } + + try { + tx.exec(sql); + tx.exec("INSERT INTO schema_migrations (version, name, checksum) VALUES ($1, $2, $3)", + pqxx::params{migration.version, migration.name, checksum}); + tx.commit(); + } catch (const pqxx::sql_error& error) { + throw MigrationError(std::format("migration {:04}_{} failed and was rolled back: {}", migration.version, + migration.name, error.what())); + } + + ++result.applied; + log(std::format("applied {:04}_{}", migration.version, migration.name)); + } + + return result; +} + +void Migrator::log(std::string_view message) const { + if (logger_) { + logger_(message); + } +} + +} // namespace modulo::server::db From eb108a9c10a2447e923283c75ee103ced7201e16 Mon Sep 17 00:00:00 2001 From: Angelo Barbu <77395130+angelobarbu@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:32:04 +0300 Subject: [PATCH 6/9] Increment 1 - Step 5: Stubs across the stack & Qt uniformity (#5) * Increment 1 - Step 5: Stubs across the stack & Qt uniformity * Increment 1 - Step 5: Code & Documentation Cleanup * Increment 1 - Step 5: Minor code formatting --- .clang-format | 2 +- .clang-tidy | 2 + CMakeLists.txt | 12 +- CMakePresets.json | 3 +- README.md | 66 +++++++-- client/CMakeLists.txt | 8 + client/include/modulo/client/api_client.h | 39 +++++ client/qml/Main.qml | 82 +++++++++++ client/src/api_client.cpp | 52 +++++++ client/src/main.cpp | 24 +++ cmake/CompilerWarnings.cmake | 8 +- cmake/Dependencies.cmake | 14 +- cmake/ModuloTargets.cmake | 72 ++++++++- docs/high_level_design.md | 137 ++++++++++++++++++ libs/api/CMakeLists.txt | 7 + libs/api/include/modulo/api/error.h | 26 ++++ libs/api/include/modulo/api/health.h | 29 ++++ libs/api/include/modulo/api/json.h | 20 +++ libs/api/src/error.cpp | 32 ++++ libs/api/src/health.cpp | 26 ++++ libs/api/src/json.cpp | 25 ++++ libs/core/CMakeLists.txt | 6 + libs/core/include/modulo/core/result.h | 38 +++++ libs/core/include/modulo/core/version.h | 11 ++ libs/core/src/version.cpp | 9 ++ server/CMakeLists.txt | 4 + server/app/CMakeLists.txt | 6 + server/app/main.cpp | 33 +++++ server/modules/config/CMakeLists.txt | 6 + .../include/modulo/server/config/config.h | 29 ++++ server/modules/config/src/config.cpp | 34 +++++ server/modules/db/CMakeLists.txt | 3 +- .../db/include/modulo/server/db/migrator.h | 14 +- server/modules/db/src/migrator.cpp | 2 +- server/modules/http/CMakeLists.txt | 8 + .../http/include/modulo/server/http/server.h | 33 +++++ server/modules/http/src/server.cpp | 66 +++++++++ 37 files changed, 945 insertions(+), 43 deletions(-) create mode 100644 client/CMakeLists.txt create mode 100644 client/include/modulo/client/api_client.h create mode 100644 client/qml/Main.qml create mode 100644 client/src/api_client.cpp create mode 100644 client/src/main.cpp create mode 100644 docs/high_level_design.md create mode 100644 libs/api/CMakeLists.txt create mode 100644 libs/api/include/modulo/api/error.h create mode 100644 libs/api/include/modulo/api/health.h create mode 100644 libs/api/include/modulo/api/json.h create mode 100644 libs/api/src/error.cpp create mode 100644 libs/api/src/health.cpp create mode 100644 libs/api/src/json.cpp create mode 100644 libs/core/CMakeLists.txt create mode 100644 libs/core/include/modulo/core/result.h create mode 100644 libs/core/include/modulo/core/version.h create mode 100644 libs/core/src/version.cpp create mode 100644 server/app/CMakeLists.txt create mode 100644 server/app/main.cpp create mode 100644 server/modules/config/CMakeLists.txt create mode 100644 server/modules/config/include/modulo/server/config/config.h create mode 100644 server/modules/config/src/config.cpp create mode 100644 server/modules/http/CMakeLists.txt create mode 100644 server/modules/http/include/modulo/server/http/server.h create mode 100644 server/modules/http/src/server.cpp diff --git a/.clang-format b/.clang-format index 25abe11..dd1bb15 100644 --- a/.clang-format +++ b/.clang-format @@ -40,7 +40,7 @@ IncludeCategories: Priority: 2 - Regex: '^$' Priority: 5 diff --git a/.clang-tidy b/.clang-tidy index bf596e3..cf7dd36 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -5,6 +5,7 @@ # Suppressions (kept deliberately short): # - bugprone-easily-swappable-parameters: too noisy for small DTO/ctor signatures. # - modernize-use-trailing-return-type: we use classic return-type style. +# - modernize-use-nodiscard: project decision — no [[nodiscard]] on declarations. # - readability-identifier-length: short names (id, tx, db) are idiomatic here. # - readability-magic-numbers: config defaults and test literals would drown the signal. --- @@ -15,6 +16,7 @@ Checks: > readability-*, -bugprone-easily-swappable-parameters, -modernize-use-trailing-return-type, + -modernize-use-nodiscard, -readability-identifier-length, -readability-magic-numbers diff --git a/CMakeLists.txt b/CMakeLists.txt index ff011dc..f9b316f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,8 +1,7 @@ # Modulo — personal investment tracker. # -# This file stays deliberately thin: options, toolkit includes, dependency -# resolution, and subdirectory wiring. All build logic lives as modulo_* -# functions in cmake/ (see cmake/ModuloTargets.cmake). +# Contains options, toolkit includes, dependency resolution and subdirectory wiring. +# All build logic is implemented as modulo_* functions in cmake/ (see cmake/ModuloTargets.cmake). cmake_minimum_required(VERSION 3.28) @@ -32,8 +31,7 @@ qt_standard_project_setup(REQUIRES 6.8) enable_testing() # --- Project targets --------------------------------------------------------- -# Subdirectories are appended as the increments introduce them: -# add_subdirectory(libs/core) # Increment 1, Step 5 -# add_subdirectory(libs/api) # Increment 1, Step 5 +add_subdirectory(libs/core) +add_subdirectory(libs/api) add_subdirectory(server) -# add_subdirectory(client) # Increment 1, Step 5 +add_subdirectory(client) diff --git a/CMakePresets.json b/CMakePresets.json index 0ec70fb..28203e7 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -9,7 +9,8 @@ "binaryDir": "${sourceDir}/build/${presetName}", "cacheVariables": { "CMAKE_PREFIX_PATH": "/opt/homebrew/opt/qt", - "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "WrapOpenGL_AGL": "/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk/System/Library/Frameworks/AGL.framework" } }, { diff --git a/README.md b/README.md index 995810e..81c2e04 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,21 @@ transfers, aggregated holdings with dashboards, uploaded documents, and daily ex updates. **Architecture:** client-server. A C++23 REST backend (Qt `QHttpServer`) owns PostgreSQL, -authentication sessions, and business logic; a Qt 6 / QML desktop client for macOS consumes -the API. The backend is designed to be containerized later, and future web/mobile clients +authentication sessions and business logic; a Qt 6 / QML desktop client for macOS consumes +the API. The backend is designed to be containerized later and future web/mobile clients can target the same API. **Stack:** C++23 · Qt 6.8 · QML · PostgreSQL 16 · CMake ≥ 3.28 · libpqxx · libsodium · -Catch2 v3 · nlohmann-json +Catch2 v3 + +The project focuses on maximizing Qt framework usage: QJson wire format, `Q_GADGET` DTOs readable +from QML, `QString` + `.arg()` as the project-wide string idiom, Qt integer typedefs +(`quint16`, etc.) in Qt-facing code, `qInfo()`/`qCritical()` logging in applications +(`QLoggingCategory` planned with the auth increment), and Qt networking/HTTP/UI +throughout. The C++23 standard library is used only where Qt has no equivalent +(`std::expected`-based `Result`, `std::filesystem`). The Qt-free zone is +`server/modules/db` + `modulo_migrate` (pure libpqxx; stdout is the CLI's interface), +keeping the future container's migration entrypoint minimal. > Developed incrementally, one reviewed step at a time. This README grows with each step — > see [Repository layout](#repository-layout) for what exists today. @@ -27,9 +36,18 @@ brew install cmake ninja llvm libpqxx libsodium qt - **llvm** provides `clang-format`/`clang-tidy`; it is keg-only, so scripts and CMake reference `/opt/homebrew/opt/llvm/bin` by absolute path. - **Docker Desktop** must be running for the development database. -- The local Homebrew PostgreSQL (if any) can keep running — the dockerized database uses +- The local Homebrew PostgreSQL (if any) can run in parallel - the dockerized database uses port **5433** precisely to avoid clashing with a local server on 5432. +Two quirks of this machine are compensated for in the build (no action needed): + +- The newest macOS SDK no longer ships the legacy `AGL` framework, but Qt's OpenGL CMake + wrapper unconditionally links it - the `dev` presets pin `WrapOpenGL_AGL` to the stub in + the older SDK (Homebrew's Qt itself links AGL at runtime, so this adds nothing new). +- A second Qt (`qtbase`) shadows the shared Homebrew plugin path with version-incompatible + plugins; the build generates a `qt.conf` beside every executable pinning plugin/QML + resolution to the Qt actually linked. + Copy the environment template and adjust if needed: ```sh @@ -52,14 +70,29 @@ cmake --build --preset dev # build | `dev-tidy` | `dev` + clang-tidy on every compile | | `release` | RelWithDebInfo | -Build directories land in `build//`. Third-party sources fetched by CPM are cached +Build directories are generated in `build//`. Third-party sources fetched by CPM are cached in `.cache/cpm/` and survive build-directory wipes. -All build logic lives as `modulo_*` functions in [`cmake/`](cmake/) — -`modulo_add_library`, `modulo_add_executable`, `modulo_add_test`, `modulo_add_qml_test` — -so every `CMakeLists.txt` stays a short declarative call. Each server-side module is its +All build logic can be found in `modulo_*` functions under [`cmake/`](cmake/) module - +`modulo_add_library`, `modulo_add_executable`, `modulo_add_test`, `modulo_add_qml_test`. +Thus, `CMakeLists.txt` becomes a short declarative call. Each server-side module is its own static library with public headers in `include/modulo/...` and implementation in -`src/` (header files use the `.h` extension). +`src/`. + +## Running the stack + +```sh +scripts/db-up.sh # 1. database (not needed by health yet) +./build/dev/server/app/modulo_server # 2. REST API on http://127.0.0.1:8080 +./build/dev/client/modulo_client # 3. desktop client (separate terminal) +``` + +The server exposes `GET /api/v1/health` → `{"status":"ok","version":"0.1.0"}`; any +unknown route returns the uniform error envelope +`{"error":{"code":"not_found","message":"..."}}` with the matching HTTP status. The +client window (placeholder) polls health every 3 s and shows a live +green/red status indicator. `MODULO_HTTP_PORT` and `MODULO_API_URL` override the +server port and the client's target. ## Development database @@ -115,8 +148,7 @@ ctest --preset ui # QML/Qt Quick tests ctest --preset all ``` -(No tests exist yet — the first scaffolding tests arrive with the test-setup step of -Increment 1.) +(No tests exist yet - they are planned for implementation soon.) ## Code style @@ -127,7 +159,7 @@ Increment 1.) trailing-underscore private members). ```sh -scripts/format.sh # format all first-party sources in place +scripts/format.sh # format all sources in place scripts/format.sh --check # verify only (CI mode) ``` @@ -136,11 +168,18 @@ scripts/format.sh --check # verify only (CI mode) ``` cmake/ CMake toolkit: all build logic as modulo_* functions + vendored CPM.cmake db/migrations/ append-only SQL schema migrations (NNNN_name.sql) +docs/ high_level_design.md (Architecture diagrams) docker/ docker-compose.yml (Postgres 16 on :5433) + one-time initdb scripts +libs/core/ modulo_core — Qt-free foundations (version, Result on std::expected) +libs/api/ modulo_api — Q_GADGET DTOs + validating QJson mappings shared by server and client scripts/ db-up.sh, db-down.sh, migrate.sh, format.sh server/ backend: per-module static libraries + executables + modules/config/ modulo_server_config — env-based process configuration modules/db/ modulo_server_db — migration engine (connection pool arrives in Increment 2) + modules/http/ modulo_server_http — QHttpServer wrapper, routes, error envelope + app/ modulo_server — REST API server executable migrate/ modulo_migrate — CLI migration runner +client/ modulo_client — QML desktop app (ApiClient + dark-theme shell) CMakeLists.txt thin root: options, toolkit includes, dependency resolution CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release) .env.example environment template (DB URLs, HTTP port, data dir) @@ -155,3 +194,6 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release | 1.2 — CMake superstructure | Function-based `cmake/` toolkit, vendored CPM v0.42.0, thin root `CMakeLists.txt`, presets | | 1.3 — Dev database | Dockerized Postgres 16 (`:5433`, named volume, healthcheck), initdb for `modulo_test`, `db-up`/`db-down` scripts | | 1.4 — Migrations | `modulo_server_db` module (first server static lib) with transactional, checksum-verified migration engine; `modulo_migrate` CLI; `0001_init.sql`; `scripts/migrate.sh` | +| 1.5 — Stubs across the stack | `modulo_core` (version, `Result`), `modulo_api` (Health/Error DTOs), `config` + `http` server modules, `modulo_server` serving `/api/v1/health`, QML client with live status; toolkit grew `modulo_add_qml_app`, version injection, qt.conf generation, AGL workaround | +| 1.5b — Qt-wide uniformity | Decision: maximize Qt uniformity. DTOs became `Q_GADGET`s with validating QJson mappings (`api::json::require*` — no silent defaults); nlohmann-json dependency removed. `QString` project-wide (incl. `core::Error`/`version()`), `.arg()` over `std::format` in Qt code, `quint16` in Qt-facing types, `qInfo`/`qCritical` in apps; Qt-free zone narrowed to `modules/db` + `modulo_migrate` | +| 1.5c — Cleanup | Further code & comments cleanup; revisioned documentation | diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt new file mode 100644 index 0000000..2c01b8d --- /dev/null +++ b/client/CMakeLists.txt @@ -0,0 +1,8 @@ +# modulo_client — the QML desktop application. + +modulo_add_qml_app( + modulo_client + URI Modulo + SOURCES include/modulo/client/api_client.h src/api_client.cpp src/main.cpp + QML_FILES qml/Main.qml + DEPS modulo_api Qt6::Quick Qt6::QuickControls2) diff --git a/client/include/modulo/client/api_client.h b/client/include/modulo/client/api_client.h new file mode 100644 index 0000000..04660f0 --- /dev/null +++ b/client/include/modulo/client/api_client.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include + +namespace modulo::client { + +/// Minimal client for the Modulo REST API exposed to QML as `ApiClient`. +/// The API base URL comes from the MODULO_API_URL environment variable. +/// Default: http://127.0.0.1:8080 +class ApiClient : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(bool serverReachable READ serverReachable NOTIFY healthChanged) + Q_PROPERTY(QString serverStatus READ serverStatus NOTIFY healthChanged) + +public: + explicit ApiClient(QObject* parent = nullptr); + + bool serverReachable() const { return serverReachable_; } + + QString serverStatus() const { return serverStatus_; } + + /// GET /api/v1/health; the outcome lands in the properties above. + Q_INVOKABLE void checkHealth(); + +signals: + void healthChanged(); + +private: + QNetworkAccessManager network_; + QUrl baseUrl_; + bool serverReachable_ = false; + QString serverStatus_; +}; + +} // namespace modulo::client diff --git a/client/qml/Main.qml b/client/qml/Main.qml new file mode 100644 index 0000000..6f34bb8 --- /dev/null +++ b/client/qml/Main.qml @@ -0,0 +1,82 @@ +// Main application window. Placeholder shell for the ultrasound.money-inspired +// dark theme: near-black background, neon-green accent, muted secondary text. +// The real design system (Theme singleton, pages, navigation) arrives with +// the auth increment. + +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Layouts +import Modulo + +ApplicationWindow { + id: window + + visible: true + width: 960 + height: 600 + minimumWidth: 480 + minimumHeight: 320 + title: qsTr("Modulo") + + Material.theme: Material.Dark + Material.accent: "#00ffa3" + color: "#10141b" + + ApiClient { + id: api + } + + Timer { + interval: 3000 + repeat: true + running: true + triggeredOnStart: true + onTriggered: api.checkHealth() + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 12 + + Label { + text: qsTr("Modulo") + font.pixelSize: 48 + font.weight: Font.DemiBold + color: "#e6edf3" + Layout.alignment: Qt.AlignHCenter + } + + Label { + text: qsTr("personal investment tracker") + font.pixelSize: 16 + color: "#8b949e" + Layout.alignment: Qt.AlignHCenter + } + + RowLayout { + spacing: 8 + Layout.alignment: Qt.AlignHCenter + + Rectangle { + id: statusDot + width: 10 + height: 10 + radius: width / 2 + color: api.serverReachable ? "#00ffa3" : "#f85149" + + SequentialAnimation on opacity { + running: api.serverReachable + loops: Animation.Infinite + NumberAnimation { from: 1.0; to: 0.35; duration: 900 } + NumberAnimation { from: 0.35; to: 1.0; duration: 900 } + } + } + + Label { + text: api.serverStatus + font.pixelSize: 14 + color: "#8b949e" + } + } + } +} diff --git a/client/src/api_client.cpp b/client/src/api_client.cpp new file mode 100644 index 0000000..8cff777 --- /dev/null +++ b/client/src/api_client.cpp @@ -0,0 +1,52 @@ +#include +#include + +#include +#include + +namespace modulo::client { + +namespace { + +QUrl defaultBaseUrl() { + return QUrl{qEnvironmentVariable("MODULO_API_URL", QStringLiteral("http://127.0.0.1:8080"))}; +} + +} // namespace + +ApiClient::ApiClient(QObject* parent) : QObject{parent}, baseUrl_{defaultBaseUrl()}, serverStatus_{tr("connecting…")} { +} + +void ApiClient::checkHealth() { + const QNetworkRequest request{baseUrl_.resolved(QUrl{QStringLiteral("/api/v1/health")})}; + auto* reply = network_.get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply] { + reply->deleteLater(); + + bool reachable = false; + QString status; + if (reply->error() == QNetworkReply::NoError) { + const auto document = QJsonDocument::fromJson(reply->readAll()); + const auto health = document.isObject() + ? api::HealthResponse::fromJson(document.object()) + : core::makeError(QStringLiteral("api.invalid_field"), + QStringLiteral("response body is not a JSON object")); + if (health && health->status == QStringLiteral("ok")) { + reachable = true; + status = tr("server %1 (v%2)").arg(health->status, health->version); + } else { + status = tr("invalid response from server"); + } + } else { + status = tr("server unreachable"); + } + + if (reachable != serverReachable_ || status != serverStatus_) { + serverReachable_ = reachable; + serverStatus_ = status; + emit healthChanged(); + } + }); +} + +} // namespace modulo::client diff --git a/client/src/main.cpp b/client/src/main.cpp new file mode 100644 index 0000000..5ab63e1 --- /dev/null +++ b/client/src/main.cpp @@ -0,0 +1,24 @@ +// modulo_client — Modulo QML desktop application. + +#include +#include +#include + +#include + +int main(int argc, char* argv[]) { + QGuiApplication app{argc, argv}; + QGuiApplication::setApplicationName(QStringLiteral("Modulo")); + QGuiApplication::setOrganizationName(QStringLiteral("Modulo")); + + // Material is the base Controls style; the Modulo dark theme layers on top. + QQuickStyle::setStyle(QStringLiteral("Material")); + + QQmlApplicationEngine engine; + QObject::connect( + &engine, &QQmlApplicationEngine::objectCreationFailed, &app, [] { QCoreApplication::exit(EXIT_FAILURE); }, + Qt::QueuedConnection); + engine.loadFromModule("Modulo", "Main"); + + return app.exec(); +} diff --git a/cmake/CompilerWarnings.cmake b/cmake/CompilerWarnings.cmake index 65dd271..8821be5 100644 --- a/cmake/CompilerWarnings.cmake +++ b/cmake/CompilerWarnings.cmake @@ -1,11 +1,11 @@ # CompilerWarnings.cmake — project-wide warning configuration. # -# Defines the `modulo_warnings` INTERFACE target carrying the warning flags -# shared by every first-party target, and `modulo_enable_warnings()` -# to attach them. Third-party code fetched via CPM is never touched. +# Defines the `modulo_warnings` INTERFACE target carrying +# the warning flags shared by every first-party target +# and `modulo_enable_warnings() to attach them. # # The flag set is controlled by the MODULO_WARNINGS_AS_ERRORS option -# (declared in the root CMakeLists.txt, enabled by the `dev` preset). +# (declared in the root CMakeLists.txt enabled by the `dev` preset). include_guard(GLOBAL) diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index c099193..2bec3ee 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -1,17 +1,17 @@ -# Dependencies.cmake — single home for every third-party dependency. +# Dependencies.cmake — Third-party dependencies. # -# `modulo_find_dependencies()` resolves, in one place: +# `modulo_find_dependencies()` resolves: # - Homebrew binary libs: Qt 6.8+, libpqxx, libsodium -# - CPM-pinned source libs: Catch2 v3, nlohmann-json +# - CPM-pinned source libs: Catch2 v3 # -# A macro (not a function) so find_package results land in the caller's -# directory scope. Called exactly once, from the root CMakeLists.txt. +# A macro so find_package results land in the caller's +# directory scope. Called from the root CMakeLists.txt. include_guard(GLOBAL) # Source dependencies are cached outside the build tree so wiping build/ # does not re-download them (.cache/ is gitignored). Must be set BEFORE -# include(CPM): CPM initializes this cache variable itself on include, and +# include(CPM): CPM initializes this cache variable itself on include and # a later set(... CACHE ...) would not override the existing entry. set(CPM_SOURCE_CACHE "${CMAKE_SOURCE_DIR}/.cache/cpm" @@ -55,6 +55,4 @@ macro(modulo_find_dependencies) if(MODULO_BUILD_TESTS) cpmaddpackage("gh:catchorg/Catch2@3.8.1") endif() - - cpmaddpackage("gh:nlohmann/json@3.11.3") endmacro() diff --git a/cmake/ModuloTargets.cmake b/cmake/ModuloTargets.cmake index 445a39d..5d77563 100644 --- a/cmake/ModuloTargets.cmake +++ b/cmake/ModuloTargets.cmake @@ -2,7 +2,7 @@ # # Every CMakeLists.txt in the repo stays a short, generic call into one of # these functions; all shared logic (C++23, include/src layout, warnings, -# sanitizers, clang-tidy, CTest registration) lives here. +# sanitizers, clang-tidy, CTest registration) are included here. # # modulo_add_library( SOURCES ... [PUBLIC_DEPS ...] [PRIVATE_DEPS ...]) # Static library following the module convention: public headers in @@ -31,11 +31,36 @@ include(StaticAnalysis) function(_modulo_apply_common_settings target) target_compile_features(${target} PUBLIC cxx_std_23) set_target_properties(${target} PROPERTIES CXX_EXTENSIONS OFF) + # Single source of truth for the project version: the root project() call. + target_compile_definitions(${target} PRIVATE MODULO_VERSION="${PROJECT_VERSION}") modulo_enable_warnings(${target}) modulo_enable_sanitizers(${target}) modulo_enable_clang_tidy(${target}) endfunction() +# Write a qt.conf beside an executable, pinning Qt's plugin/QML resolution to +# the Qt installation we actually link against. Without this, Homebrew's keg-only +# qt resolves plugins via the brew prefix root (/opt/homebrew/share/qt), which a +# different Qt formula (e.g. a newer qtbase) can shadow — the app then tries to +# load version-incompatible plugins and refuses to start. +function(_modulo_write_qt_conf target) + if(NOT TARGET Qt6::Core) + return() + endif() + + get_filename_component(_modulo_qt_root "${Qt6_DIR}/../../.." ABSOLUTE) + if(EXISTS "${_modulo_qt_root}/share/qt/plugins") + set(_modulo_qt_prefix "${_modulo_qt_root}/share/qt") # Homebrew layout + else() + set(_modulo_qt_prefix "${_modulo_qt_root}") # official-installer layout + endif() + + file( + GENERATE + OUTPUT "$/qt.conf" + CONTENT "[Paths]\nPrefix = ${_modulo_qt_prefix}\n") +endfunction() + function(modulo_add_library name) cmake_parse_arguments(PARSE_ARGV 1 ARG "" "" "SOURCES;PUBLIC_DEPS;PRIVATE_DEPS") @@ -76,6 +101,49 @@ function(modulo_add_executable name) endif() _modulo_apply_common_settings(${name}) + _modulo_write_qt_conf(${name}) +endfunction() + +function(modulo_add_qml_app name) + cmake_parse_arguments(PARSE_ARGV 1 ARG "" "URI" "SOURCES;QML_FILES;DEPS") + + if(NOT ARG_URI) + message(FATAL_ERROR "modulo_add_qml_app(${name}): URI is required") + endif() + if(NOT ARG_SOURCES) + message(FATAL_ERROR "modulo_add_qml_app(${name}): SOURCES is required") + endif() + + qt_add_executable(${name} ${ARG_SOURCES}) + qt_add_qml_module( + ${name} + URI ${ARG_URI} + VERSION 1.0 + QML_FILES ${ARG_QML_FILES}) + + # Apps follow the same include/src split as libraries, but their headers + # are private — nobody links against an application. + if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/include) + target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) + endif() + + # qmltyperegistrar's generated registration file includes each QML-exposed + # header by BASENAME only (guarded by __has_include, so a miss is silent + # and surfaces as "undeclared identifier" instead). Make every listed + # header's own directory an include dir so those basename includes resolve. + foreach(source IN LISTS ARG_SOURCES) + if(source MATCHES "\\.h$") + get_filename_component(header_dir "${CMAKE_CURRENT_SOURCE_DIR}/${source}" DIRECTORY) + target_include_directories(${name} PRIVATE "${header_dir}") + endif() + endforeach() + + if(ARG_DEPS) + target_link_libraries(${name} PRIVATE ${ARG_DEPS}) + endif() + + _modulo_apply_common_settings(${name}) + _modulo_write_qt_conf(${name}) endfunction() function(modulo_add_test name) @@ -99,6 +167,7 @@ function(modulo_add_test name) endif() _modulo_apply_common_settings(${name}) + _modulo_write_qt_conf(${name}) add_test(NAME ${name} COMMAND ${name}) set_tests_properties(${name} PROPERTIES LABELS ${ARG_LABEL}) @@ -128,6 +197,7 @@ function(modulo_add_qml_test name) target_compile_definitions(${name} PRIVATE QUICK_TEST_SOURCE_DIR="${ARG_QML_DIR}") _modulo_apply_common_settings(${name}) + _modulo_write_qt_conf(${name}) add_test(NAME ${name} COMMAND ${name}) set_tests_properties(${name} PROPERTIES LABELS ui) diff --git a/docs/high_level_design.md b/docs/high_level_design.md new file mode 100644 index 0000000..59f2334 --- /dev/null +++ b/docs/high_level_design.md @@ -0,0 +1,137 @@ +# Modulo — High-Level Design + +## 1. Component & deployment view + +```mermaid +flowchart LR + subgraph desktop["macOS desktop"] + subgraph client["modulo_client (Qt 6.8 / QML)"] + qml["Main.qml +dark shell · status dot +polls every 3 s"] + apiclient["ApiClient (QObject) +QNetworkAccessManager +QML_ELEMENT"] + qml --> apiclient + end + + subgraph serverproc["modulo_server (C++23 / QCoreApplication)"] + http["http module +QHttpServer · routes +JSON error envelope"] + config["config module +env → Config +(MODULO_* vars)"] + http --> config + end + + subgraph migrate["modulo_migrate (CLI, Qt-free)"] + migrator["db module +Migrator · libpqxx +checksums · transactions"] + end + end + + subgraph docker["Docker"] + pg[("PostgreSQL 16 +127.0.0.1:5433 +modulo_dev · modulo_test +volume: modulo_pgdata")] + end + + sql["db/migrations/ +NNNN_name.sql +(append-only)"] + + apiclient -- "HTTP GET /api/v1/health +127.0.0.1:8080 (loopback only)" --> http + migrator -- "SQL over libpq" --> pg + sql --> migrator + http -. "libpqxx pool — Increment 2" .-> pg +``` + +## 2. Static library dependency graph + +```mermaid +flowchart BT + core["modulo_core +Result<T> (std::expected) +version() · QString-based"] + api["modulo_api +Q_GADGET DTOs +Health / Error +api::json::require*"] + cfg["modulo_server_config"] + httpm["modulo_server_http"] + dbm["modulo_server_db +(Qt-free · libpqxx)"] + + server(["modulo_server (exe)"]) + migrateexe(["modulo_migrate (exe, Qt-free)"]) + clientexe(["modulo_client (exe)"]) + + api --> core + cfg --> core + httpm --> api + httpm --> cfg + server --> httpm + migrateexe --> dbm + clientexe --> api + + qt["Qt6: Core · Network · HttpServer · Quick"] + pqxx["libpqxx 8"] + httpm -.-> qt + clientexe -.-> qt + core -.-> qt + dbm -.-> pqxx +``` + +Every server-side module is its own static library (`CMakeLists.txt` + `include/modulo/...` + `src/`), +created by the `modulo_*` CMake toolkit functions (warnings, sanitizers, clang-tidy, version +injection, qt.conf generation applied uniformly). Coming next: `modulo_server_auth` (Increment 2), +then transactions / transfers / holdings / rates / documents as sibling modules. + +## 3. Runtime flow — health check (the pipe proven in Step 5) + +```mermaid +sequenceDiagram + participant Q as Main.qml (Timer 3 s) + participant A as ApiClient + participant S as QHttpServer route + participant D as modulo_api DTOs + + Q->>A: checkHealth() + A->>S: GET /api/v1/health + S->>D: HealthResponse{ok, 0.1.0}.toJson() + S-->>A: 200 {"status":"ok","version":"0.1.0"} + A->>D: HealthResponse::fromJson (validating) + D-->>A: Result of HealthResponse or Error + A-->>Q: serverReachable / serverStatus properties + Note over Q: green pulsing dot · "server ok (v0.1.0)" +``` + +## 4. Runtime flow — migrations + +```mermaid +sequenceDiagram + participant U as scripts/migrate.sh + participant M as modulo_migrate + participant G as Migrator (db module) + participant P as PostgreSQL 16 + + U->>M: MODULO_DB_URL + --dir db/migrations + M->>G: run() + G->>G: discover() — NNNN_name.sql, sorted, dup check + G->>P: ensure schema_migrations + loop each migration (one transaction) + G->>P: md5(content) — checksum via Postgres + alt already applied, checksum matches + G->>G: skip + else checksum differs + G-->>M: MigrationError (append-only violated) + else pending + G->>P: apply SQL + record row, commit + end + end + M-->>U: "N applied, M skipped" (exit code) +``` \ No newline at end of file diff --git a/libs/api/CMakeLists.txt b/libs/api/CMakeLists.txt new file mode 100644 index 0000000..36fda59 --- /dev/null +++ b/libs/api/CMakeLists.txt @@ -0,0 +1,7 @@ +# modulo_api — request/response DTOs and their JSON mappings, shared verbatim +# by the server and every client so both sides agree on the wire format. + +modulo_add_library( + modulo_api + SOURCES src/error.cpp src/health.cpp src/json.cpp + PUBLIC_DEPS modulo_core Qt6::Core) diff --git a/libs/api/include/modulo/api/error.h b/libs/api/include/modulo/api/error.h new file mode 100644 index 0000000..d78fac8 --- /dev/null +++ b/libs/api/include/modulo/api/error.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::api { + +/// Uniform error envelope used by every API endpoint: +/// {"error": {"code": "", "message": ""}} +/// Clients branch on `code`; `message` is for humans and logs only. +struct ErrorResponse { + Q_GADGET + Q_PROPERTY(QString code MEMBER code) + Q_PROPERTY(QString message MEMBER message) + +public: + QString code; + QString message; + + QJsonObject toJson() const; + static core::Result fromJson(const QJsonObject& json); +}; + +} // namespace modulo::api diff --git a/libs/api/include/modulo/api/health.h b/libs/api/include/modulo/api/health.h new file mode 100644 index 0000000..0a31ecb --- /dev/null +++ b/libs/api/include/modulo/api/health.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::api { + +/// Response body of GET /api/v1/health. +/// +/// DTO conventions (all Modulo DTOs follow this shape): a Q_GADGET struct — +/// QML-readable by value, no QObject overhead — with an explicit, validating +/// QJson mapping. fromJson() rejects missing/mistyped fields via +/// api::json::require* instead of QJson's silent defaults. +struct HealthResponse { + Q_GADGET + Q_PROPERTY(QString status MEMBER status) + Q_PROPERTY(QString version MEMBER version) + +public: + QString status; ///< "ok" when the server is serving requests. + QString version; ///< Server semantic version. + + QJsonObject toJson() const; + static core::Result fromJson(const QJsonObject& json); +}; + +} // namespace modulo::api diff --git a/libs/api/include/modulo/api/json.h b/libs/api/include/modulo/api/json.h new file mode 100644 index 0000000..114e00d --- /dev/null +++ b/libs/api/include/modulo/api/json.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::api::json { + +/// Required-field accessors for wire data. +/// +/// QJson's own reads default silently (a missing key yields an empty value); +/// wire parsing must never do that. These helpers make every absent or +/// wrongly-typed field an explicit Error with code "api.invalid_field". + +core::Result requireString(const QJsonObject& object, QLatin1StringView key); + +core::Result requireObject(const QJsonObject& object, QLatin1StringView key); + +} // namespace modulo::api::json diff --git a/libs/api/src/error.cpp b/libs/api/src/error.cpp new file mode 100644 index 0000000..f17baf9 --- /dev/null +++ b/libs/api/src/error.cpp @@ -0,0 +1,32 @@ +#include +#include + +#include + +namespace modulo::api { + +QJsonObject ErrorResponse::toJson() const { + return QJsonObject{ + {QStringLiteral("error"), QJsonObject{{QStringLiteral("code"), code}, {QStringLiteral("message"), message}}}}; +} + +core::Result ErrorResponse::fromJson(const QJsonObject& json) { + auto envelope = json::requireObject(json, QLatin1StringView{"error"}); + if (!envelope) { + return std::unexpected{std::move(envelope).error()}; + } + + auto code = json::requireString(*envelope, QLatin1StringView{"code"}); + if (!code) { + return std::unexpected{std::move(code).error()}; + } + + auto message = json::requireString(*envelope, QLatin1StringView{"message"}); + if (!message) { + return std::unexpected{std::move(message).error()}; + } + + return ErrorResponse{.code = std::move(*code), .message = std::move(*message)}; +} + +} // namespace modulo::api diff --git a/libs/api/src/health.cpp b/libs/api/src/health.cpp new file mode 100644 index 0000000..6756ed9 --- /dev/null +++ b/libs/api/src/health.cpp @@ -0,0 +1,26 @@ +#include +#include + +#include + +namespace modulo::api { + +QJsonObject HealthResponse::toJson() const { + return QJsonObject{{QStringLiteral("status"), status}, {QStringLiteral("version"), version}}; +} + +core::Result HealthResponse::fromJson(const QJsonObject& json) { + auto status = json::requireString(json, QLatin1StringView{"status"}); + if (!status) { + return std::unexpected{std::move(status).error()}; + } + + auto version = json::requireString(json, QLatin1StringView{"version"}); + if (!version) { + return std::unexpected{std::move(version).error()}; + } + + return HealthResponse{.status = std::move(*status), .version = std::move(*version)}; +} + +} // namespace modulo::api diff --git a/libs/api/src/json.cpp b/libs/api/src/json.cpp new file mode 100644 index 0000000..5979845 --- /dev/null +++ b/libs/api/src/json.cpp @@ -0,0 +1,25 @@ +#include + +#include + +namespace modulo::api::json { + +core::Result requireString(const QJsonObject& object, QLatin1StringView key) { + const QJsonValue value = object.value(key); + if (!value.isString()) { + return core::makeError(QStringLiteral("api.invalid_field"), + QStringLiteral("missing or non-string field '%1'").arg(key)); + } + return value.toString(); +} + +core::Result requireObject(const QJsonObject& object, QLatin1StringView key) { + const QJsonValue value = object.value(key); + if (!value.isObject()) { + return core::makeError(QStringLiteral("api.invalid_field"), + QStringLiteral("missing or non-object field '%1'").arg(key)); + } + return value.toObject(); +} + +} // namespace modulo::api::json diff --git a/libs/core/CMakeLists.txt b/libs/core/CMakeLists.txt new file mode 100644 index 0000000..3d170b8 --- /dev/null +++ b/libs/core/CMakeLists.txt @@ -0,0 +1,6 @@ +# modulo_core — domain foundations shared by every other target. + +modulo_add_library( + modulo_core + SOURCES src/version.cpp + PUBLIC_DEPS Qt6::Core) diff --git a/libs/core/include/modulo/core/result.h b/libs/core/include/modulo/core/result.h new file mode 100644 index 0000000..9833898 --- /dev/null +++ b/libs/core/include/modulo/core/result.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::core { + +/// Error value carried by Result. +/// +/// `code` is a stable, machine-readable identifier in dotted-snake form +/// (e.g. "config.invalid_port", "http.bind_failed") — it is what tests and +/// API clients match on. `message` is human-readable detail and carries no +/// stability guarantee. +struct Error { + QString code; + QString message; +}; + +/// Project-wide result type: a value of T or an Error. +/// +/// Used instead of exceptions on expected failure paths (bad input, +/// unavailable resources). Exceptions remain for genuinely exceptional, +/// non-recoverable situations. +template +using Result = std::expected; + +/// Result for operations that produce no value on success. +using VoidResult = std::expected; + +/// Convenience factory: `return makeError("config.invalid_port", "...");` +/// converts implicitly to any Result. +inline std::unexpected makeError(QString code, QString message) { + return std::unexpected{Error{std::move(code), std::move(message)}}; +} + +} // namespace modulo::core diff --git a/libs/core/include/modulo/core/version.h b/libs/core/include/modulo/core/version.h new file mode 100644 index 0000000..6108585 --- /dev/null +++ b/libs/core/include/modulo/core/version.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace modulo::core { + +/// Semantic version of the Modulo project, e.g. "0.1.0". Single source of +/// truth is the root CMake project() call (injected at compile time). +QString version(); + +} // namespace modulo::core diff --git a/libs/core/src/version.cpp b/libs/core/src/version.cpp new file mode 100644 index 0000000..5af5f92 --- /dev/null +++ b/libs/core/src/version.cpp @@ -0,0 +1,9 @@ +#include + +namespace modulo::core { + +QString version() { + return QStringLiteral(MODULO_VERSION); +} + +} // namespace modulo::core diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index dbfee29..25d8dd6 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -1,4 +1,8 @@ # Server-side modules (one static library each) and executables. +add_subdirectory(modules/config) add_subdirectory(modules/db) +add_subdirectory(modules/http) + +add_subdirectory(app) add_subdirectory(migrate) diff --git a/server/app/CMakeLists.txt b/server/app/CMakeLists.txt new file mode 100644 index 0000000..6530d30 --- /dev/null +++ b/server/app/CMakeLists.txt @@ -0,0 +1,6 @@ +# modulo_server — the REST API server executable. + +modulo_add_executable( + modulo_server + SOURCES main.cpp + DEPS modulo_server_http Qt6::Core) diff --git a/server/app/main.cpp b/server/app/main.cpp new file mode 100644 index 0000000..f1ee167 --- /dev/null +++ b/server/app/main.cpp @@ -0,0 +1,33 @@ +// modulo_server — Modulo REST API server. +// +// Configuration from environment variables (see .env.example). +// Runs until interrupted; serves on 127.0.0.1 only. + +#include +#include +#include + +#include + +#include + +int main(int argc, char* argv[]) { + QCoreApplication app{argc, argv}; + + const auto config = modulo::server::config::Config::fromEnvironment(); + if (!config) { + qCritical().noquote() << QStringLiteral("error [%1]: %2").arg(config.error().code, config.error().message); + return EXIT_FAILURE; + } + + modulo::server::http::Server server{*config}; + const auto port = server.listen(); + if (!port) { + qCritical().noquote() << QStringLiteral("error [%1]: %2").arg(port.error().code, port.error().message); + return EXIT_FAILURE; + } + + qInfo().noquote() + << QStringLiteral("modulo_server v%1 listening on http://127.0.0.1:%2").arg(modulo::core::version()).arg(*port); + return app.exec(); +} diff --git a/server/modules/config/CMakeLists.txt b/server/modules/config/CMakeLists.txt new file mode 100644 index 0000000..450c056 --- /dev/null +++ b/server/modules/config/CMakeLists.txt @@ -0,0 +1,6 @@ +# modulo_server_config — server process configuration from the environment. + +modulo_add_library( + modulo_server_config + SOURCES src/config.cpp + PUBLIC_DEPS modulo_core Qt6::Core) diff --git a/server/modules/config/include/modulo/server/config/config.h b/server/modules/config/include/modulo/server/config/config.h new file mode 100644 index 0000000..97b1a9b --- /dev/null +++ b/server/modules/config/include/modulo/server/config/config.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include +#include + +namespace modulo::server::config { + +/// Server process configuration sourced from environment variables +/// (documented in .env.example at the repo root). +struct Config { + /// MODULO_DB_URL. May be empty for features that do not touch the + /// database; features that need it validate at their own startup. + QString databaseUrl; + + /// MODULO_HTTP_PORT. Port 0 asks the OS for a free port (used by tests). + quint16 httpPort = 8080; + + /// MODULO_DATA_DIR — root for server-managed files (document uploads). + QString dataDir = QStringLiteral("./var/data"); + + /// Build a Config from the process environment. Unset or empty variables + /// keep their defaults; malformed values yield an Error whose code is + /// prefixed "config.". + static core::Result fromEnvironment(); +}; + +} // namespace modulo::server::config diff --git a/server/modules/config/src/config.cpp b/server/modules/config/src/config.cpp new file mode 100644 index 0000000..60ba3dd --- /dev/null +++ b/server/modules/config/src/config.cpp @@ -0,0 +1,34 @@ +#include + +namespace modulo::server::config { + +namespace { + +/// qEnvironmentVariable's own default only covers UNSET variables; an +/// exported-but-empty variable should fall back to the default too. +QString envOr(const char* name, const QString& fallback) { + const QString value = qEnvironmentVariable(name); + return value.isEmpty() ? fallback : value; +} + +} // namespace + +core::Result Config::fromEnvironment() { + Config config; + config.databaseUrl = envOr("MODULO_DB_URL", QString{}); + config.dataDir = envOr("MODULO_DATA_DIR", config.dataDir); + + const QString portText = envOr("MODULO_HTTP_PORT", QStringLiteral("8080")); + bool valid = false; + const quint16 port = portText.toUShort(&valid); // rejects non-numeric and > 65535 + if (!valid) { + return core::makeError( + QStringLiteral("config.invalid_port"), + QStringLiteral("MODULO_HTTP_PORT must be an integer in [0, 65535], got '%1'").arg(portText)); + } + config.httpPort = port; + + return config; +} + +} // namespace modulo::server::config diff --git a/server/modules/db/CMakeLists.txt b/server/modules/db/CMakeLists.txt index 2513cc7..3b78e52 100644 --- a/server/modules/db/CMakeLists.txt +++ b/server/modules/db/CMakeLists.txt @@ -1,5 +1,4 @@ -# modulo_server_db — database access module: migration engine (and, from -# Increment 2, the libpqxx connection pool used by all repositories). +# modulo_server_db — database access module and migration engine. modulo_add_library( modulo_server_db diff --git a/server/modules/db/include/modulo/server/db/migrator.h b/server/modules/db/include/modulo/server/db/migrator.h index 68a3892..3012cfc 100644 --- a/server/modules/db/include/modulo/server/db/migrator.h +++ b/server/modules/db/include/modulo/server/db/migrator.h @@ -33,13 +33,15 @@ struct MigrationResult { /// Applies SQL migration files to a PostgreSQL database. /// -/// State is tracked in the schema_migrations table (created on demand): +/// State is tracked in the schema_migrations table: /// one row per applied migration with its version, name, content checksum, -/// and timestamp. Rules: +/// and timestamp. +/// +/// Rules: /// - migrations run in ascending version order, each inside one transaction; /// - an already-applied migration whose file is unchanged is skipped; -/// - an already-applied migration whose file content CHANGED aborts the run -/// (migrations are append-only — never edit an applied file); +/// - an already-applied migration whose file content changed aborts the run +/// (migrations are append-only; never edit an applied file); /// - a failing migration rolls back and aborts; nothing after it runs. class Migrator { public: @@ -49,9 +51,9 @@ class Migrator { Migrator(std::string databaseUrl, std::filesystem::path migrationsDir, Logger logger = {}); /// Scan the migrations directory. Non-dot files that do not match the - /// NNNN_name.sql pattern, and duplicate versions, raise MigrationError. + /// NNNN_name.sql pattern and duplicate versions raise MigrationError. /// Returns migrations sorted by ascending version. - [[nodiscard]] std::vector discover() const; + std::vector discover() const; /// Apply every pending migration. Throws MigrationError (see class docs) /// or pqxx errors on connection failure. diff --git a/server/modules/db/src/migrator.cpp b/server/modules/db/src/migrator.cpp index 1d4d808..3e36b61 100644 --- a/server/modules/db/src/migrator.cpp +++ b/server/modules/db/src/migrator.cpp @@ -56,7 +56,7 @@ std::string readFile(const std::filesystem::path& path) { return std::move(contents).str(); } -/// Content checksum, computed by PostgreSQL itself (md5 is fine here: this +/// Content checksum computed by PostgreSQL (md5 is fine here: this /// detects accidental edits of applied files, it is not a security boundary). std::string checksumOf(pqxx::work& tx, const std::string& sql) { return tx.query_value("SELECT md5($1)", pqxx::params{sql}); diff --git a/server/modules/http/CMakeLists.txt b/server/modules/http/CMakeLists.txt new file mode 100644 index 0000000..8b3891b --- /dev/null +++ b/server/modules/http/CMakeLists.txt @@ -0,0 +1,8 @@ +# modulo_server_http — the REST API server: owns the QHttpServer, registers +# every route, and enforces the uniform JSON error envelope. + +modulo_add_library( + modulo_server_http + SOURCES src/server.cpp + PUBLIC_DEPS modulo_api modulo_server_config Qt6::HttpServer + PRIVATE_DEPS Qt6::Network) diff --git a/server/modules/http/include/modulo/server/http/server.h b/server/modules/http/include/modulo/server/http/server.h new file mode 100644 index 0000000..248bd5d --- /dev/null +++ b/server/modules/http/include/modulo/server/http/server.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +#include + +namespace modulo::server::http { + +/// The Modulo REST API server. +/// +/// Owns the QHttpServer instance and registers every route. Feature modules +/// contribute their routes here as increments land (auth, transactions, etc.). +/// Requires a running Qt event loop (QCoreApplication) to serve requests. +class Server { +public: + explicit Server(config::Config config); + + Server(const Server&) = delete; + Server& operator=(const Server&) = delete; + + /// Bind to 127.0.0.1 on config.httpPort (0 = OS-assigned, used by tests) + /// and start serving. Returns the actually bound port. + core::Result listen(); + +private: + void registerRoutes(); + + config::Config config_; + QHttpServer server_; +}; + +} // namespace modulo::server::http diff --git a/server/modules/http/src/server.cpp b/server/modules/http/src/server.cpp new file mode 100644 index 0000000..9b9d812 --- /dev/null +++ b/server/modules/http/src/server.cpp @@ -0,0 +1,66 @@ +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +namespace modulo::server::http { + +namespace { + +QByteArray toBody(const QJsonObject& json) { + return QJsonDocument{json}.toJson(QJsonDocument::Compact); +} + +QHttpServerResponse jsonResponse(const QJsonObject& body, QHttpServerResponse::StatusCode status) { + return QHttpServerResponse{"application/json", toBody(body), status}; +} + +} // namespace + +Server::Server(config::Config config) : config_{std::move(config)} { + registerRoutes(); +} + +core::Result Server::listen() { + // Bind to loopback only: in development the API must never be reachable + // from the network; production exposure goes through a reverse proxy. + auto tcpServer = std::make_unique(); + if (!tcpServer->listen(QHostAddress::LocalHost, config_.httpPort)) { + return core::makeError( + QStringLiteral("http.bind_failed"), + QStringLiteral("cannot listen on 127.0.0.1:%1: %2").arg(config_.httpPort).arg(tcpServer->errorString())); + } + + const quint16 port = tcpServer->serverPort(); + if (!server_.bind(tcpServer.get())) { + return core::makeError(QStringLiteral("http.bind_failed"), + QStringLiteral("QHttpServer refused the socket on port %1").arg(port)); + } + tcpServer.release(); // ownership transferred to server_ by bind() + + return port; +} + +void Server::registerRoutes() { + server_.route("/api/v1/health", QHttpServerRequest::Method::Get, [] { + const api::HealthResponse health{.status = QStringLiteral("ok"), .version = core::version()}; + return jsonResponse(health.toJson(), QHttpServerResponse::StatusCode::Ok); + }); + + // Anything unrouted gets the uniform error envelope instead of Qt's + // default HTML 404 page. + server_.setMissingHandler(&server_, [](const QHttpServerRequest&, QHttpServerResponder& responder) { + const api::ErrorResponse error{.code = QStringLiteral("not_found"), + .message = QStringLiteral("resource not found")}; + responder.write(toBody(error.toJson()), "application/json", QHttpServerResponder::StatusCode::NotFound); + }); +} + +} // namespace modulo::server::http From 3564bd9d08e5e255effee009ca154e973a67868a Mon Sep 17 00:00:00 2001 From: Angelo Barbu <77395130+angelobarbu@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:18:01 +0300 Subject: [PATCH 7/9] Increment 1 - Step 6: Unit & Integration Testing (#6) --- .clang-format | 2 +- README.md | 47 +- client/tests/CMakeLists.txt | 7 + client/tests/qml/main.cpp | 6 + client/tests/qml/tst_smoke.qml | 38 + cmake/CPM.cmake | 1363 ----------------- cmake/Dependencies.cmake | 22 +- cmake/ModuloTargets.cmake | 38 +- libs/api/tests/CMakeLists.txt | 17 + libs/api/tests/test_error_dto.cpp | 55 + libs/api/tests/test_health_dto.cpp | 50 + libs/api/tests/test_json.cpp | 61 + libs/core/tests/CMakeLists.txt | 5 + libs/core/tests/test_version.cpp | 23 + server/CMakeLists.txt | 3 + server/modules/config/tests/CMakeLists.txt | 5 + server/modules/config/tests/test_config.cpp | 115 ++ server/tests/integration/CMakeLists.txt | 9 + .../integration/test_health_endpoint.cpp | 65 + .../include/modulo/testing/integration.h | 59 + 20 files changed, 588 insertions(+), 1402 deletions(-) create mode 100644 client/tests/CMakeLists.txt create mode 100644 client/tests/qml/main.cpp create mode 100644 client/tests/qml/tst_smoke.qml delete mode 100644 cmake/CPM.cmake create mode 100644 libs/api/tests/CMakeLists.txt create mode 100644 libs/api/tests/test_error_dto.cpp create mode 100644 libs/api/tests/test_health_dto.cpp create mode 100644 libs/api/tests/test_json.cpp create mode 100644 libs/core/tests/CMakeLists.txt create mode 100644 libs/core/tests/test_version.cpp create mode 100644 server/modules/config/tests/CMakeLists.txt create mode 100644 server/modules/config/tests/test_config.cpp create mode 100644 server/tests/integration/CMakeLists.txt create mode 100644 server/tests/integration/test_health_endpoint.cpp create mode 100644 tests/support/include/modulo/testing/integration.h diff --git a/.clang-format b/.clang-format index dd1bb15..771540f 100644 --- a/.clang-format +++ b/.clang-format @@ -40,7 +40,7 @@ IncludeCategories: Priority: 2 - Regex: '^$' Priority: 5 diff --git a/README.md b/README.md index 81c2e04..96fc081 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@ authentication sessions and business logic; a Qt 6 / QML desktop client for macO the API. The backend is designed to be containerized later and future web/mobile clients can target the same API. -**Stack:** C++23 · Qt 6.8 · QML · PostgreSQL 16 · CMake ≥ 3.28 · libpqxx · libsodium · -Catch2 v3 +**Stack:** C++23 · Qt 6.8 · QML · PostgreSQL 16 · CMake ≥ 3.28 · libpqxx · libsodium · Testing: Qt Test / Qt Quick Test (Client UI) -The project focuses on maximizing Qt framework usage: QJson wire format, `Q_GADGET` DTOs readable +The project maximizes Qt framework usage — Qt is used everywhere unless it is clearly +costly and an alternative is much more efficient: QJson wire format, `Q_GADGET` DTOs readable from QML, `QString` + `.arg()` as the project-wide string idiom, Qt integer typedefs (`quint16`, etc.) in Qt-facing code, `qInfo()`/`qCritical()` logging in applications -(`QLoggingCategory` planned with the auth increment), and Qt networking/HTTP/UI -throughout. The C++23 standard library is used only where Qt has no equivalent +(`QLoggingCategory` planned with the auth increment), Qt Test for every test suite, and Qt +networking/HTTP/UI throughout. The C++23 standard library is used only where Qt has no equivalent (`std::expected`-based `Result`, `std::filesystem`). The Qt-free zone is `server/modules/db` + `modulo_migrate` (pure libpqxx; stdout is the CLI's interface), keeping the future container's migration entrypoint minimal. @@ -70,8 +70,8 @@ cmake --build --preset dev # build | `dev-tidy` | `dev` + clang-tidy on every compile | | `release` | RelWithDebInfo | -Build directories are generated in `build//`. Third-party sources fetched by CPM are cached -in `.cache/cpm/` and survive build-directory wipes. +Build directories are generated in `build//`. All dependencies are Homebrew binary +libraries — nothing is downloaded at configure time. All build logic can be found in `modulo_*` functions under [`cmake/`](cmake/) module - `modulo_add_library`, `modulo_add_executable`, `modulo_add_test`, `modulo_add_qml_test`. @@ -142,13 +142,31 @@ the underlying CLI (`--url`, `--dir`). Tests are registered with CTest under the labels `unit`, `integration`, and `ui`: ```sh -ctest --preset unit # fast, no Docker needed -ctest --preset integration # requires the database (scripts/db-up.sh) -ctest --preset ui # QML/Qt Quick tests +ctest --preset unit # Qt Test, fast, no Docker needed +ctest --preset integration # opt-in: set MODULO_TEST_DB_URL (see .env.example) +ctest --preset ui # Qt Quick Test, runs offscreen automatically ctest --preset all ``` -(No tests exist yet - they are planned for implementation soon.) +All suites use **Qt Test** (C++) and **Qt Quick Test** (QML) — one QObject test class per +binary, data-driven rows via `_data()` slots: + +| Test binary | Label | What it covers | +|---|---|---| +| `modulo_core_tests` | unit | `version()` matches the CMake project version, semver shape | +| `modulo_api_health_dto_tests` | unit | `HealthResponse` JSON round-trip; `fromJson` rejecting missing/mistyped fields | +| `modulo_api_error_dto_tests` | unit | `ErrorResponse` envelope shape and round-trip; rejection of flat/incomplete envelopes | +| `modulo_api_json_tests` | unit | `api::json::require*` never falling back to QJson's silent defaults (missing, number, object, array, null) | +| `modulo_server_config_tests` | unit | defaults, every variable, empty-means-unset, port 0, malformed ports → `config.invalid_port` | +| `modulo_integration_tests` | integration | real `QHttpServer` on an OS-assigned port + real HTTP client: `/api/v1/health` body and version, 404 error envelope | +| `modulo_client_qml_tests` | ui | `QUICK_TEST_MAIN` runner over `client/tests/qml/tst_*.qml` (Qt Quick + Material smoke) | + +Conventions: every module's tests live in its own `tests/` directory (auto-discovered by +the CMake toolkit); cross-module integration tests live in `server/tests/integration/`; +shared fixtures are in `tests/support/include/modulo/testing/`. Integration tests are +**opt-in**: they start with `MODULO_REQUIRE_TEST_DATABASE()`, which `QSKIP`s without +`MODULO_TEST_DB_URL`, and CTest reports the binary as *Skipped* — so `ctest --preset all` +never needs Docker to pass. ## Code style @@ -166,14 +184,15 @@ scripts/format.sh --check # verify only (CI mode) ## Repository layout ``` -cmake/ CMake toolkit: all build logic as modulo_* functions + vendored CPM.cmake +cmake/ CMake toolkit: all build logic as modulo_* functions db/migrations/ append-only SQL schema migrations (NNNN_name.sql) docs/ high_level_design.md (Architecture diagrams) docker/ docker-compose.yml (Postgres 16 on :5433) + one-time initdb scripts libs/core/ modulo_core — Qt-free foundations (version, Result on std::expected) libs/api/ modulo_api — Q_GADGET DTOs + validating QJson mappings shared by server and client scripts/ db-up.sh, db-down.sh, migrate.sh, format.sh -server/ backend: per-module static libraries + executables +tests/support/ shared test fixtures () for integration tests +server/ backend: per-module static libraries + executables (each module has its own tests/) modules/config/ modulo_server_config — env-based process configuration modules/db/ modulo_server_db — migration engine (connection pool arrives in Increment 2) modules/http/ modulo_server_http — QHttpServer wrapper, routes, error envelope @@ -197,3 +216,5 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release | 1.5 — Stubs across the stack | `modulo_core` (version, `Result`), `modulo_api` (Health/Error DTOs), `config` + `http` server modules, `modulo_server` serving `/api/v1/health`, QML client with live status; toolkit grew `modulo_add_qml_app`, version injection, qt.conf generation, AGL workaround | | 1.5b — Qt-wide uniformity | Decision: maximize Qt uniformity. DTOs became `Q_GADGET`s with validating QJson mappings (`api::json::require*` — no silent defaults); nlohmann-json dependency removed. `QString` project-wide (incl. `core::Error`/`version()`), `.arg()` over `std::format` in Qt code, `quint16` in Qt-facing types, `qInfo`/`qCritical` in apps; Qt-free zone narrowed to `modules/db` + `modulo_migrate` | | 1.5c — Cleanup | Further code & comments cleanup; revisioned documentation | +| 1.6 — Test scaffolding | One passing suite per layer: core, api (DTO + `require*` rejection paths), config, in-process HTTP integration (opt-in via `MODULO_TEST_DB_URL`, Skipped otherwise), QML smoke (offscreen); toolkit auto-discovers `tests/` dirs | +| 1.6b — Qt Test everywhere | Decision: Qt Test replaces Catch2 (one framework for C++ and QML); Catch2 + CPM removed — the project now has zero source-level dependencies | diff --git a/client/tests/CMakeLists.txt b/client/tests/CMakeLists.txt new file mode 100644 index 0000000..d52561b --- /dev/null +++ b/client/tests/CMakeLists.txt @@ -0,0 +1,7 @@ +# QML / Qt Quick tests: every tst_*.qml file in ./qml runs under the QUICK_TEST_MAIN +# runner, offscreen (no display needed). + +modulo_add_qml_test( + modulo_client_qml_tests + QML_DIR ${CMAKE_CURRENT_SOURCE_DIR}/qml + SOURCES qml/main.cpp) diff --git a/client/tests/qml/main.cpp b/client/tests/qml/main.cpp new file mode 100644 index 0000000..260592a --- /dev/null +++ b/client/tests/qml/main.cpp @@ -0,0 +1,6 @@ +// Qt Quick Test runner: executes every tst_*.qml in QUICK_TEST_SOURCE_DIR +// (injected by modulo_add_qml_test). + +#include + +QUICK_TEST_MAIN(modulo_client_qml) diff --git a/client/tests/qml/tst_smoke.qml b/client/tests/qml/tst_smoke.qml new file mode 100644 index 0000000..cf7f6ee --- /dev/null +++ b/client/tests/qml/tst_smoke.qml @@ -0,0 +1,38 @@ +// Smoke test: proves the QML test runner, the Qt Quick runtime, and the +// Material controls style are all available offscreen. Component-level +// tests (ApiClient, pages) arrive once the client's QML module is split into +// an importable library (auth increment). + +import QtQuick +import QtQuick.Controls.Material +import QtTest + +TestCase { + id: testCase + name: "Smoke" + + function test_qtquick_items_instantiate() { + const item = createTemporaryQmlObject("import QtQuick; Item { width: 42; height: 7 }", testCase) + verify(item) + compare(item.width, 42) + compare(item.height, 7) + } + + function test_material_controls_available() { + const button = createTemporaryQmlObject( + "import QtQuick.Controls.Material; Button { text: 'probe'; Material.theme: Material.Dark }", + testCase) + verify(button) + compare(button.text, "probe") + compare(button.Material.theme, Material.Dark) + } + + function test_dark_theme_palette_constants() { + // The placeholder palette used by Main.qml; guards against typos when + // it moves into the Theme singleton. + const background = Qt.color("#10141b") + const accent = Qt.color("#00ffa3") + verify(background.r < 0.1 && background.g < 0.1 && background.b < 0.15, "background is near-black") + verify(accent.g > 0.9 && accent.r < 0.1, "accent is neon green") + } +} diff --git a/cmake/CPM.cmake b/cmake/CPM.cmake deleted file mode 100644 index 3636ee5..0000000 --- a/cmake/CPM.cmake +++ /dev/null @@ -1,1363 +0,0 @@ -# CPM.cmake - CMake's missing package manager -# =========================================== -# See https://github.com/cpm-cmake/CPM.cmake for usage and update instructions. -# -# MIT License -# ----------- -#[[ - Copyright (c) 2019-2023 Lars Melchior and contributors - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. -]] - -cmake_minimum_required(VERSION 3.14 FATAL_ERROR) - -# Initialize logging prefix -if(NOT CPM_INDENT) - set(CPM_INDENT - "CPM:" - CACHE INTERNAL "" - ) -endif() - -if(NOT COMMAND cpm_message) - function(cpm_message) - message(${ARGV}) - endfunction() -endif() - -if(DEFINED EXTRACTED_CPM_VERSION) - set(CURRENT_CPM_VERSION "${EXTRACTED_CPM_VERSION}${CPM_DEVELOPMENT}") -else() - set(CURRENT_CPM_VERSION 0.42.0) -endif() - -get_filename_component(CPM_CURRENT_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" REALPATH) -if(CPM_DIRECTORY) - if(NOT CPM_DIRECTORY STREQUAL CPM_CURRENT_DIRECTORY) - if(CPM_VERSION VERSION_LESS CURRENT_CPM_VERSION) - message( - AUTHOR_WARNING - "${CPM_INDENT} \ -A dependency is using a more recent CPM version (${CURRENT_CPM_VERSION}) than the current project (${CPM_VERSION}). \ -It is recommended to upgrade CPM to the most recent version. \ -See https://github.com/cpm-cmake/CPM.cmake for more information." - ) - endif() - if(${CMAKE_VERSION} VERSION_LESS "3.17.0") - include(FetchContent) - endif() - return() - endif() - - get_property( - CPM_INITIALIZED GLOBAL "" - PROPERTY CPM_INITIALIZED - SET - ) - if(CPM_INITIALIZED) - return() - endif() -endif() - -if(CURRENT_CPM_VERSION MATCHES "development-version") - message( - WARNING "${CPM_INDENT} Your project is using an unstable development version of CPM.cmake. \ -Please update to a recent release if possible. \ -See https://github.com/cpm-cmake/CPM.cmake for details." - ) -endif() - -set_property(GLOBAL PROPERTY CPM_INITIALIZED true) - -macro(cpm_set_policies) - # the policy allows us to change options without caching - cmake_policy(SET CMP0077 NEW) - set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) - - # the policy allows us to change set(CACHE) without caching - if(POLICY CMP0126) - cmake_policy(SET CMP0126 NEW) - set(CMAKE_POLICY_DEFAULT_CMP0126 NEW) - endif() - - # The policy uses the download time for timestamp, instead of the timestamp in the archive. This - # allows for proper rebuilds when a projects url changes - if(POLICY CMP0135) - cmake_policy(SET CMP0135 NEW) - set(CMAKE_POLICY_DEFAULT_CMP0135 NEW) - endif() - - # treat relative git repository paths as being relative to the parent project's remote - if(POLICY CMP0150) - cmake_policy(SET CMP0150 NEW) - set(CMAKE_POLICY_DEFAULT_CMP0150 NEW) - endif() -endmacro() -cpm_set_policies() - -option(CPM_USE_LOCAL_PACKAGES "Always try to use `find_package` to get dependencies" - $ENV{CPM_USE_LOCAL_PACKAGES} -) -option(CPM_LOCAL_PACKAGES_ONLY "Only use `find_package` to get dependencies" - $ENV{CPM_LOCAL_PACKAGES_ONLY} -) -option(CPM_DOWNLOAD_ALL "Always download dependencies from source" $ENV{CPM_DOWNLOAD_ALL}) -option(CPM_DONT_UPDATE_MODULE_PATH "Don't update the module path to allow using find_package" - $ENV{CPM_DONT_UPDATE_MODULE_PATH} -) -option(CPM_DONT_CREATE_PACKAGE_LOCK "Don't create a package lock file in the binary path" - $ENV{CPM_DONT_CREATE_PACKAGE_LOCK} -) -option(CPM_INCLUDE_ALL_IN_PACKAGE_LOCK - "Add all packages added through CPM.cmake to the package lock" - $ENV{CPM_INCLUDE_ALL_IN_PACKAGE_LOCK} -) -option(CPM_USE_NAMED_CACHE_DIRECTORIES - "Use additional directory of package name in cache on the most nested level." - $ENV{CPM_USE_NAMED_CACHE_DIRECTORIES} -) - -set(CPM_VERSION - ${CURRENT_CPM_VERSION} - CACHE INTERNAL "" -) -set(CPM_DIRECTORY - ${CPM_CURRENT_DIRECTORY} - CACHE INTERNAL "" -) -set(CPM_FILE - ${CMAKE_CURRENT_LIST_FILE} - CACHE INTERNAL "" -) -set(CPM_PACKAGES - "" - CACHE INTERNAL "" -) -set(CPM_DRY_RUN - OFF - CACHE INTERNAL "Don't download or configure dependencies (for testing)" -) - -if(DEFINED ENV{CPM_SOURCE_CACHE}) - set(CPM_SOURCE_CACHE_DEFAULT $ENV{CPM_SOURCE_CACHE}) -else() - set(CPM_SOURCE_CACHE_DEFAULT OFF) -endif() - -set(CPM_SOURCE_CACHE - ${CPM_SOURCE_CACHE_DEFAULT} - CACHE PATH "Directory to download CPM dependencies" -) - -if(NOT CPM_DONT_UPDATE_MODULE_PATH AND NOT DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) - set(CPM_MODULE_PATH - "${CMAKE_BINARY_DIR}/CPM_modules" - CACHE INTERNAL "" - ) - # remove old modules - file(REMOVE_RECURSE ${CPM_MODULE_PATH}) - file(MAKE_DIRECTORY ${CPM_MODULE_PATH}) - # locally added CPM modules should override global packages - set(CMAKE_MODULE_PATH "${CPM_MODULE_PATH};${CMAKE_MODULE_PATH}") -endif() - -if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) - set(CPM_PACKAGE_LOCK_FILE - "${CMAKE_BINARY_DIR}/cpm-package-lock.cmake" - CACHE INTERNAL "" - ) - file(WRITE ${CPM_PACKAGE_LOCK_FILE} - "# CPM Package Lock\n# This file should be committed to version control\n\n" - ) -endif() - -include(FetchContent) - -# Try to infer package name from git repository uri (path or url) -function(cpm_package_name_from_git_uri URI RESULT) - if("${URI}" MATCHES "([^/:]+)/?.git/?$") - set(${RESULT} - ${CMAKE_MATCH_1} - PARENT_SCOPE - ) - else() - unset(${RESULT} PARENT_SCOPE) - endif() -endfunction() - -# Find the shortest hash that can be used eg, if origin_hash is -# cccb77ae9609d2768ed80dd42cec54f77b1f1455 the following files will be checked, until one is found -# that is either empty (allowing us to assign origin_hash), or whose contents matches ${origin_hash} -# -# * .../cccb.hash -# * .../cccb77ae.hash -# * .../cccb77ae9609.hash -# * .../cccb77ae9609d276.hash -# * etc -# -# We will be able to use a shorter path with very high probability, but in the (rare) event that the -# first couple characters collide, we will check longer and longer substrings. -function(cpm_get_shortest_hash source_cache_dir origin_hash short_hash_output_var) - # for compatibility with caches populated by a previous version of CPM, check if a directory using - # the full hash already exists - if(EXISTS "${source_cache_dir}/${origin_hash}") - set(${short_hash_output_var} - "${origin_hash}" - PARENT_SCOPE - ) - return() - endif() - - foreach(len RANGE 4 40 4) - string(SUBSTRING "${origin_hash}" 0 ${len} short_hash) - set(hash_lock ${source_cache_dir}/${short_hash}.lock) - set(hash_fp ${source_cache_dir}/${short_hash}.hash) - # Take a lock, so we don't have a race condition with another instance of cmake. We will release - # this lock when we can, however, if there is an error, we want to ensure it gets released on - # it's own on exit from the function. - file(LOCK ${hash_lock} GUARD FUNCTION) - - # Load the contents of .../${short_hash}.hash - file(TOUCH ${hash_fp}) - file(READ ${hash_fp} hash_fp_contents) - - if(hash_fp_contents STREQUAL "") - # Write the origin hash - file(WRITE ${hash_fp} ${origin_hash}) - file(LOCK ${hash_lock} RELEASE) - break() - elseif(hash_fp_contents STREQUAL origin_hash) - file(LOCK ${hash_lock} RELEASE) - break() - else() - file(LOCK ${hash_lock} RELEASE) - endif() - endforeach() - set(${short_hash_output_var} - "${short_hash}" - PARENT_SCOPE - ) -endfunction() - -# Try to infer package name and version from a url -function(cpm_package_name_and_ver_from_url url outName outVer) - if(url MATCHES "[/\\?]([a-zA-Z0-9_\\.-]+)\\.(tar|tar\\.gz|tar\\.bz2|zip|ZIP)(\\?|/|$)") - # We matched an archive - set(filename "${CMAKE_MATCH_1}") - - if(filename MATCHES "([a-zA-Z0-9_\\.-]+)[_-]v?(([0-9]+\\.)*[0-9]+[a-zA-Z0-9]*)") - # We matched - (ie foo-1.2.3) - set(${outName} - "${CMAKE_MATCH_1}" - PARENT_SCOPE - ) - set(${outVer} - "${CMAKE_MATCH_2}" - PARENT_SCOPE - ) - elseif(filename MATCHES "(([0-9]+\\.)+[0-9]+[a-zA-Z0-9]*)") - # We couldn't find a name, but we found a version - # - # In many cases (which we don't handle here) the url would look something like - # `irrelevant/ACTUAL_PACKAGE_NAME/irrelevant/1.2.3.zip`. In such a case we can't possibly - # distinguish the package name from the irrelevant bits. Moreover if we try to match the - # package name from the filename, we'd get bogus at best. - unset(${outName} PARENT_SCOPE) - set(${outVer} - "${CMAKE_MATCH_1}" - PARENT_SCOPE - ) - else() - # Boldly assume that the file name is the package name. - # - # Yes, something like `irrelevant/ACTUAL_NAME/irrelevant/download.zip` will ruin our day, but - # such cases should be quite rare. No popular service does this... we think. - set(${outName} - "${filename}" - PARENT_SCOPE - ) - unset(${outVer} PARENT_SCOPE) - endif() - else() - # No ideas yet what to do with non-archives - unset(${outName} PARENT_SCOPE) - unset(${outVer} PARENT_SCOPE) - endif() -endfunction() - -function(cpm_find_package NAME VERSION) - string(REPLACE " " ";" EXTRA_ARGS "${ARGN}") - find_package(${NAME} ${VERSION} ${EXTRA_ARGS} QUIET) - if(${CPM_ARGS_NAME}_FOUND) - if(DEFINED ${CPM_ARGS_NAME}_VERSION) - set(VERSION ${${CPM_ARGS_NAME}_VERSION}) - endif() - cpm_message(STATUS "${CPM_INDENT} Using local package ${CPM_ARGS_NAME}@${VERSION}") - CPMRegisterPackage(${CPM_ARGS_NAME} "${VERSION}") - set(CPM_PACKAGE_FOUND - YES - PARENT_SCOPE - ) - else() - set(CPM_PACKAGE_FOUND - NO - PARENT_SCOPE - ) - endif() -endfunction() - -# Create a custom FindXXX.cmake module for a CPM package This prevents `find_package(NAME)` from -# finding the system library -function(cpm_create_module_file Name) - if(NOT CPM_DONT_UPDATE_MODULE_PATH) - if(DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) - # Redirect find_package calls to the CPM package. This is what FetchContent does when you set - # OVERRIDE_FIND_PACKAGE. The CMAKE_FIND_PACKAGE_REDIRECTS_DIR works for find_package in CONFIG - # mode, unlike the Find${Name}.cmake fallback. CMAKE_FIND_PACKAGE_REDIRECTS_DIR is not defined - # in script mode, or in CMake < 3.24. - # https://cmake.org/cmake/help/latest/module/FetchContent.html#fetchcontent-find-package-integration-examples - string(TOLOWER ${Name} NameLower) - file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config.cmake - "include(\"\${CMAKE_CURRENT_LIST_DIR}/${NameLower}-extra.cmake\" OPTIONAL)\n" - "include(\"\${CMAKE_CURRENT_LIST_DIR}/${Name}Extra.cmake\" OPTIONAL)\n" - ) - file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config-version.cmake - "set(PACKAGE_VERSION_COMPATIBLE TRUE)\n" "set(PACKAGE_VERSION_EXACT TRUE)\n" - ) - else() - file(WRITE ${CPM_MODULE_PATH}/Find${Name}.cmake - "include(\"${CPM_FILE}\")\n${ARGN}\nset(${Name}_FOUND TRUE)" - ) - endif() - endif() -endfunction() - -# Find a package locally or fallback to CPMAddPackage -function(CPMFindPackage) - set(oneValueArgs NAME VERSION GIT_TAG FIND_PACKAGE_ARGUMENTS) - - cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "" ${ARGN}) - - if(NOT DEFINED CPM_ARGS_VERSION) - if(DEFINED CPM_ARGS_GIT_TAG) - cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) - endif() - endif() - - set(downloadPackage ${CPM_DOWNLOAD_ALL}) - if(DEFINED CPM_DOWNLOAD_${CPM_ARGS_NAME}) - set(downloadPackage ${CPM_DOWNLOAD_${CPM_ARGS_NAME}}) - elseif(DEFINED ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) - set(downloadPackage $ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) - endif() - if(downloadPackage) - CPMAddPackage(${ARGN}) - cpm_export_variables(${CPM_ARGS_NAME}) - return() - endif() - - cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) - - if(NOT CPM_PACKAGE_FOUND) - CPMAddPackage(${ARGN}) - cpm_export_variables(${CPM_ARGS_NAME}) - endif() - -endfunction() - -# checks if a package has been added before -function(cpm_check_if_package_already_added CPM_ARGS_NAME CPM_ARGS_VERSION) - if("${CPM_ARGS_NAME}" IN_LIST CPM_PACKAGES) - CPMGetPackageVersion(${CPM_ARGS_NAME} CPM_PACKAGE_VERSION) - if("${CPM_PACKAGE_VERSION}" VERSION_LESS "${CPM_ARGS_VERSION}") - message( - WARNING - "${CPM_INDENT} Requires a newer version of ${CPM_ARGS_NAME} (${CPM_ARGS_VERSION}) than currently included (${CPM_PACKAGE_VERSION})." - ) - endif() - cpm_get_fetch_properties(${CPM_ARGS_NAME}) - set(${CPM_ARGS_NAME}_ADDED NO) - set(CPM_PACKAGE_ALREADY_ADDED - YES - PARENT_SCOPE - ) - cpm_export_variables(${CPM_ARGS_NAME}) - else() - set(CPM_PACKAGE_ALREADY_ADDED - NO - PARENT_SCOPE - ) - endif() -endfunction() - -# Parse the argument of CPMAddPackage in case a single one was provided and convert it to a list of -# arguments which can then be parsed idiomatically. For example gh:foo/bar@1.2.3 will be converted -# to: GITHUB_REPOSITORY;foo/bar;VERSION;1.2.3 -function(cpm_parse_add_package_single_arg arg outArgs) - # Look for a scheme - if("${arg}" MATCHES "^([a-zA-Z]+):(.+)$") - string(TOLOWER "${CMAKE_MATCH_1}" scheme) - set(uri "${CMAKE_MATCH_2}") - - # Check for CPM-specific schemes - if(scheme STREQUAL "gh") - set(out "GITHUB_REPOSITORY;${uri}") - set(packageType "git") - elseif(scheme STREQUAL "gl") - set(out "GITLAB_REPOSITORY;${uri}") - set(packageType "git") - elseif(scheme STREQUAL "bb") - set(out "BITBUCKET_REPOSITORY;${uri}") - set(packageType "git") - # A CPM-specific scheme was not found. Looks like this is a generic URL so try to determine - # type - elseif(arg MATCHES ".git/?(@|#|$)") - set(out "GIT_REPOSITORY;${arg}") - set(packageType "git") - else() - # Fall back to a URL - set(out "URL;${arg}") - set(packageType "archive") - - # We could also check for SVN since FetchContent supports it, but SVN is so rare these days. - # We just won't bother with the additional complexity it will induce in this function. SVN is - # done by multi-arg - endif() - else() - if(arg MATCHES ".git/?(@|#|$)") - set(out "GIT_REPOSITORY;${arg}") - set(packageType "git") - else() - # Give up - message(FATAL_ERROR "${CPM_INDENT} Can't determine package type of '${arg}'") - endif() - endif() - - # For all packages we interpret @... as version. Only replace the last occurrence. Thus URIs - # containing '@' can be used - string(REGEX REPLACE "@([^@]+)$" ";VERSION;\\1" out "${out}") - - # Parse the rest according to package type - if(packageType STREQUAL "git") - # For git repos we interpret #... as a tag or branch or commit hash - string(REGEX REPLACE "#([^#]+)$" ";GIT_TAG;\\1" out "${out}") - elseif(packageType STREQUAL "archive") - # For archives we interpret #... as a URL hash. - string(REGEX REPLACE "#([^#]+)$" ";URL_HASH;\\1" out "${out}") - # We don't try to parse the version if it's not provided explicitly. cpm_get_version_from_url - # should do this at a later point - else() - # We should never get here. This is an assertion and hitting it means there's a problem with the - # code above. A packageType was set, but not handled by this if-else. - message(FATAL_ERROR "${CPM_INDENT} Unsupported package type '${packageType}' of '${arg}'") - endif() - - set(${outArgs} - ${out} - PARENT_SCOPE - ) -endfunction() - -# Check that the working directory for a git repo is clean -function(cpm_check_git_working_dir_is_clean repoPath gitTag isClean) - - find_package(Git REQUIRED) - - if(NOT GIT_EXECUTABLE) - # No git executable, assume directory is clean - set(${isClean} - TRUE - PARENT_SCOPE - ) - return() - endif() - - # check for uncommitted changes - execute_process( - COMMAND ${GIT_EXECUTABLE} status --porcelain - RESULT_VARIABLE resultGitStatus - OUTPUT_VARIABLE repoStatus - OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET - WORKING_DIRECTORY ${repoPath} - ) - if(resultGitStatus) - # not supposed to happen, assume clean anyway - message(WARNING "${CPM_INDENT} Calling git status on folder ${repoPath} failed") - set(${isClean} - TRUE - PARENT_SCOPE - ) - return() - endif() - - if(NOT "${repoStatus}" STREQUAL "") - set(${isClean} - FALSE - PARENT_SCOPE - ) - return() - endif() - - # check for committed changes - execute_process( - COMMAND ${GIT_EXECUTABLE} diff -s --exit-code ${gitTag} - RESULT_VARIABLE resultGitDiff - OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_QUIET - WORKING_DIRECTORY ${repoPath} - ) - - if(${resultGitDiff} EQUAL 0) - set(${isClean} - TRUE - PARENT_SCOPE - ) - else() - set(${isClean} - FALSE - PARENT_SCOPE - ) - endif() - -endfunction() - -# Add PATCH_COMMAND to CPM_ARGS_UNPARSED_ARGUMENTS. This method consumes a list of files in ARGN -# then generates a `PATCH_COMMAND` appropriate for `ExternalProject_Add()`. This command is appended -# to the parent scope's `CPM_ARGS_UNPARSED_ARGUMENTS`. -function(cpm_add_patches) - # Return if no patch files are supplied. - if(NOT ARGN) - return() - endif() - - # Find the patch program. - find_program(PATCH_EXECUTABLE patch) - if(CMAKE_HOST_WIN32 AND NOT PATCH_EXECUTABLE) - # The Windows git executable is distributed with patch.exe. Find the path to the executable, if - # it exists, then search `../usr/bin` and `../../usr/bin` for patch.exe. - find_package(Git QUIET) - if(GIT_EXECUTABLE) - get_filename_component(extra_search_path ${GIT_EXECUTABLE} DIRECTORY) - get_filename_component(extra_search_path_1up ${extra_search_path} DIRECTORY) - get_filename_component(extra_search_path_2up ${extra_search_path_1up} DIRECTORY) - find_program( - PATCH_EXECUTABLE patch HINTS "${extra_search_path_1up}/usr/bin" - "${extra_search_path_2up}/usr/bin" - ) - endif() - endif() - if(NOT PATCH_EXECUTABLE) - message(FATAL_ERROR "Couldn't find `patch` executable to use with PATCHES keyword.") - endif() - - # Create a temporary - set(temp_list ${CPM_ARGS_UNPARSED_ARGUMENTS}) - - # Ensure each file exists (or error out) and add it to the list. - set(first_item True) - foreach(PATCH_FILE ${ARGN}) - # Make sure the patch file exists, if we can't find it, try again in the current directory. - if(NOT EXISTS "${PATCH_FILE}") - if(NOT EXISTS "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") - message(FATAL_ERROR "Couldn't find patch file: '${PATCH_FILE}'") - endif() - set(PATCH_FILE "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") - endif() - - # Convert to absolute path for use with patch file command. - get_filename_component(PATCH_FILE "${PATCH_FILE}" ABSOLUTE) - - # The first patch entry must be preceded by "PATCH_COMMAND" while the following items are - # preceded by "&&". - if(first_item) - set(first_item False) - list(APPEND temp_list "PATCH_COMMAND") - else() - list(APPEND temp_list "&&") - endif() - # Add the patch command to the list - list(APPEND temp_list "${PATCH_EXECUTABLE}" "-p1" "<" "${PATCH_FILE}") - endforeach() - - # Move temp out into parent scope. - set(CPM_ARGS_UNPARSED_ARGUMENTS - ${temp_list} - PARENT_SCOPE - ) - -endfunction() - -# method to overwrite internal FetchContent properties, to allow using CPM.cmake to overload -# FetchContent calls. As these are internal cmake properties, this method should be used carefully -# and may need modification in future CMake versions. Source: -# https://github.com/Kitware/CMake/blob/dc3d0b5a0a7d26d43d6cfeb511e224533b5d188f/Modules/FetchContent.cmake#L1152 -function(cpm_override_fetchcontent contentName) - cmake_parse_arguments(PARSE_ARGV 1 arg "" "SOURCE_DIR;BINARY_DIR" "") - if(NOT "${arg_UNPARSED_ARGUMENTS}" STREQUAL "") - message(FATAL_ERROR "${CPM_INDENT} Unsupported arguments: ${arg_UNPARSED_ARGUMENTS}") - endif() - - string(TOLOWER ${contentName} contentNameLower) - set(prefix "_FetchContent_${contentNameLower}") - - set(propertyName "${prefix}_sourceDir") - define_property( - GLOBAL - PROPERTY ${propertyName} - BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" - FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" - ) - set_property(GLOBAL PROPERTY ${propertyName} "${arg_SOURCE_DIR}") - - set(propertyName "${prefix}_binaryDir") - define_property( - GLOBAL - PROPERTY ${propertyName} - BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" - FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" - ) - set_property(GLOBAL PROPERTY ${propertyName} "${arg_BINARY_DIR}") - - set(propertyName "${prefix}_populated") - define_property( - GLOBAL - PROPERTY ${propertyName} - BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" - FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" - ) - set_property(GLOBAL PROPERTY ${propertyName} TRUE) -endfunction() - -# Download and add a package from source -function(CPMAddPackage) - cpm_set_policies() - - set(oneValueArgs - NAME - FORCE - VERSION - GIT_TAG - DOWNLOAD_ONLY - GITHUB_REPOSITORY - GITLAB_REPOSITORY - BITBUCKET_REPOSITORY - GIT_REPOSITORY - SOURCE_DIR - FIND_PACKAGE_ARGUMENTS - NO_CACHE - SYSTEM - GIT_SHALLOW - EXCLUDE_FROM_ALL - SOURCE_SUBDIR - CUSTOM_CACHE_KEY - ) - - set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND PATCHES) - - list(LENGTH ARGN argnLength) - - # Parse single shorthand argument - if(argnLength EQUAL 1) - cpm_parse_add_package_single_arg("${ARGN}" ARGN) - - # The shorthand syntax implies EXCLUDE_FROM_ALL and SYSTEM - set(ARGN "${ARGN};EXCLUDE_FROM_ALL;YES;SYSTEM;YES;") - - # Parse URI shorthand argument - elseif(argnLength GREATER 1 AND "${ARGV0}" STREQUAL "URI") - list(REMOVE_AT ARGN 0 1) # remove "URI gh:<...>@version#tag" - cpm_parse_add_package_single_arg("${ARGV1}" ARGV0) - - set(ARGN "${ARGV0};EXCLUDE_FROM_ALL;YES;SYSTEM;YES;${ARGN}") - endif() - - cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" "${ARGN}") - - # Set default values for arguments - if(NOT DEFINED CPM_ARGS_VERSION) - if(DEFINED CPM_ARGS_GIT_TAG) - cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) - endif() - endif() - - if(CPM_ARGS_DOWNLOAD_ONLY) - set(DOWNLOAD_ONLY ${CPM_ARGS_DOWNLOAD_ONLY}) - else() - set(DOWNLOAD_ONLY NO) - endif() - - if(DEFINED CPM_ARGS_GITHUB_REPOSITORY) - set(CPM_ARGS_GIT_REPOSITORY "https://github.com/${CPM_ARGS_GITHUB_REPOSITORY}.git") - elseif(DEFINED CPM_ARGS_GITLAB_REPOSITORY) - set(CPM_ARGS_GIT_REPOSITORY "https://gitlab.com/${CPM_ARGS_GITLAB_REPOSITORY}.git") - elseif(DEFINED CPM_ARGS_BITBUCKET_REPOSITORY) - set(CPM_ARGS_GIT_REPOSITORY "https://bitbucket.org/${CPM_ARGS_BITBUCKET_REPOSITORY}.git") - endif() - - if(DEFINED CPM_ARGS_GIT_REPOSITORY) - list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_REPOSITORY ${CPM_ARGS_GIT_REPOSITORY}) - if(NOT DEFINED CPM_ARGS_GIT_TAG) - set(CPM_ARGS_GIT_TAG v${CPM_ARGS_VERSION}) - endif() - - # If a name wasn't provided, try to infer it from the git repo - if(NOT DEFINED CPM_ARGS_NAME) - cpm_package_name_from_git_uri(${CPM_ARGS_GIT_REPOSITORY} CPM_ARGS_NAME) - endif() - endif() - - set(CPM_SKIP_FETCH FALSE) - - if(DEFINED CPM_ARGS_GIT_TAG) - list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_TAG ${CPM_ARGS_GIT_TAG}) - # If GIT_SHALLOW is explicitly specified, honor the value. - if(DEFINED CPM_ARGS_GIT_SHALLOW) - list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW ${CPM_ARGS_GIT_SHALLOW}) - endif() - endif() - - if(DEFINED CPM_ARGS_URL) - # If a name or version aren't provided, try to infer them from the URL - list(GET CPM_ARGS_URL 0 firstUrl) - cpm_package_name_and_ver_from_url(${firstUrl} nameFromUrl verFromUrl) - # If we fail to obtain name and version from the first URL, we could try other URLs if any. - # However multiple URLs are expected to be quite rare, so for now we won't bother. - - # If the caller provided their own name and version, they trump the inferred ones. - if(NOT DEFINED CPM_ARGS_NAME) - set(CPM_ARGS_NAME ${nameFromUrl}) - endif() - if(NOT DEFINED CPM_ARGS_VERSION) - set(CPM_ARGS_VERSION ${verFromUrl}) - endif() - - list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS URL "${CPM_ARGS_URL}") - endif() - - # Check for required arguments - - if(NOT DEFINED CPM_ARGS_NAME) - message( - FATAL_ERROR - "${CPM_INDENT} 'NAME' was not provided and couldn't be automatically inferred for package added with arguments: '${ARGN}'" - ) - endif() - - # Check if package has been added before - cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") - if(CPM_PACKAGE_ALREADY_ADDED) - cpm_export_variables(${CPM_ARGS_NAME}) - return() - endif() - - # Check for manual overrides - if(NOT CPM_ARGS_FORCE AND NOT "${CPM_${CPM_ARGS_NAME}_SOURCE}" STREQUAL "") - set(PACKAGE_SOURCE ${CPM_${CPM_ARGS_NAME}_SOURCE}) - set(CPM_${CPM_ARGS_NAME}_SOURCE "") - CPMAddPackage( - NAME "${CPM_ARGS_NAME}" - SOURCE_DIR "${PACKAGE_SOURCE}" - EXCLUDE_FROM_ALL "${CPM_ARGS_EXCLUDE_FROM_ALL}" - SYSTEM "${CPM_ARGS_SYSTEM}" - PATCHES "${CPM_ARGS_PATCHES}" - OPTIONS "${CPM_ARGS_OPTIONS}" - SOURCE_SUBDIR "${CPM_ARGS_SOURCE_SUBDIR}" - DOWNLOAD_ONLY "${DOWNLOAD_ONLY}" - FORCE True - ) - cpm_export_variables(${CPM_ARGS_NAME}) - return() - endif() - - # Check for available declaration - if(NOT CPM_ARGS_FORCE AND NOT "${CPM_DECLARATION_${CPM_ARGS_NAME}}" STREQUAL "") - set(declaration ${CPM_DECLARATION_${CPM_ARGS_NAME}}) - set(CPM_DECLARATION_${CPM_ARGS_NAME} "") - CPMAddPackage(${declaration}) - cpm_export_variables(${CPM_ARGS_NAME}) - # checking again to ensure version and option compatibility - cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") - return() - endif() - - if(NOT CPM_ARGS_FORCE) - if(CPM_USE_LOCAL_PACKAGES OR CPM_LOCAL_PACKAGES_ONLY) - cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) - - if(CPM_PACKAGE_FOUND) - cpm_export_variables(${CPM_ARGS_NAME}) - return() - endif() - - if(CPM_LOCAL_PACKAGES_ONLY) - message( - SEND_ERROR - "${CPM_INDENT} ${CPM_ARGS_NAME} not found via find_package(${CPM_ARGS_NAME} ${CPM_ARGS_VERSION})" - ) - endif() - endif() - endif() - - CPMRegisterPackage("${CPM_ARGS_NAME}" "${CPM_ARGS_VERSION}") - - if(DEFINED CPM_ARGS_GIT_TAG) - set(PACKAGE_INFO "${CPM_ARGS_GIT_TAG}") - elseif(DEFINED CPM_ARGS_SOURCE_DIR) - set(PACKAGE_INFO "${CPM_ARGS_SOURCE_DIR}") - else() - set(PACKAGE_INFO "${CPM_ARGS_VERSION}") - endif() - - if(DEFINED FETCHCONTENT_BASE_DIR) - # respect user's FETCHCONTENT_BASE_DIR if set - set(CPM_FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR}) - else() - set(CPM_FETCHCONTENT_BASE_DIR ${CMAKE_BINARY_DIR}/_deps) - endif() - - cpm_add_patches(${CPM_ARGS_PATCHES}) - - if(DEFINED CPM_ARGS_DOWNLOAD_COMMAND) - list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS DOWNLOAD_COMMAND ${CPM_ARGS_DOWNLOAD_COMMAND}) - elseif(DEFINED CPM_ARGS_SOURCE_DIR) - list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${CPM_ARGS_SOURCE_DIR}) - if(NOT IS_ABSOLUTE ${CPM_ARGS_SOURCE_DIR}) - # Expand `CPM_ARGS_SOURCE_DIR` relative path. This is important because EXISTS doesn't work - # for relative paths. - get_filename_component( - source_directory ${CPM_ARGS_SOURCE_DIR} REALPATH BASE_DIR ${CMAKE_CURRENT_BINARY_DIR} - ) - else() - set(source_directory ${CPM_ARGS_SOURCE_DIR}) - endif() - if(NOT EXISTS ${source_directory}) - string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) - # remove timestamps so CMake will re-download the dependency - file(REMOVE_RECURSE "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild") - endif() - elseif(CPM_SOURCE_CACHE AND NOT CPM_ARGS_NO_CACHE) - string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) - set(origin_parameters ${CPM_ARGS_UNPARSED_ARGUMENTS}) - list(SORT origin_parameters) - if(CPM_ARGS_CUSTOM_CACHE_KEY) - # Application set a custom unique directory name - set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${CPM_ARGS_CUSTOM_CACHE_KEY}) - elseif(CPM_USE_NAMED_CACHE_DIRECTORIES) - string(SHA1 origin_hash "${origin_parameters};NEW_CACHE_STRUCTURE_TAG") - cpm_get_shortest_hash( - "${CPM_SOURCE_CACHE}/${lower_case_name}" # source cache directory - "${origin_hash}" # Input hash - origin_hash # Computed hash - ) - set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}/${CPM_ARGS_NAME}) - else() - string(SHA1 origin_hash "${origin_parameters}") - cpm_get_shortest_hash( - "${CPM_SOURCE_CACHE}/${lower_case_name}" # source cache directory - "${origin_hash}" # Input hash - origin_hash # Computed hash - ) - set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}) - endif() - # Expand `download_directory` relative path. This is important because EXISTS doesn't work for - # relative paths. - get_filename_component(download_directory ${download_directory} ABSOLUTE) - list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${download_directory}) - - if(CPM_SOURCE_CACHE) - file(LOCK ${download_directory}/../cmake.lock) - endif() - - if(EXISTS ${download_directory}) - if(CPM_SOURCE_CACHE) - file(LOCK ${download_directory}/../cmake.lock RELEASE) - endif() - - cpm_store_fetch_properties( - ${CPM_ARGS_NAME} "${download_directory}" - "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" - ) - cpm_get_fetch_properties("${CPM_ARGS_NAME}") - - if(DEFINED CPM_ARGS_GIT_TAG AND NOT (PATCH_COMMAND IN_LIST CPM_ARGS_UNPARSED_ARGUMENTS)) - # warn if cache has been changed since checkout - cpm_check_git_working_dir_is_clean(${download_directory} ${CPM_ARGS_GIT_TAG} IS_CLEAN) - if(NOT ${IS_CLEAN}) - message( - WARNING "${CPM_INDENT} Cache for ${CPM_ARGS_NAME} (${download_directory}) is dirty" - ) - endif() - endif() - - cpm_add_subdirectory( - "${CPM_ARGS_NAME}" - "${DOWNLOAD_ONLY}" - "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" - "${${CPM_ARGS_NAME}_BINARY_DIR}" - "${CPM_ARGS_EXCLUDE_FROM_ALL}" - "${CPM_ARGS_SYSTEM}" - "${CPM_ARGS_OPTIONS}" - ) - set(PACKAGE_INFO "${PACKAGE_INFO} at ${download_directory}") - - # As the source dir is already cached/populated, we override the call to FetchContent. - set(CPM_SKIP_FETCH TRUE) - cpm_override_fetchcontent( - "${lower_case_name}" SOURCE_DIR "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" - BINARY_DIR "${${CPM_ARGS_NAME}_BINARY_DIR}" - ) - - else() - # Enable shallow clone when GIT_TAG is not a commit hash. Our guess may not be accurate, but - # it should guarantee no commit hash get mis-detected. - if(NOT DEFINED CPM_ARGS_GIT_SHALLOW) - cpm_is_git_tag_commit_hash("${CPM_ARGS_GIT_TAG}" IS_HASH) - if(NOT ${IS_HASH}) - list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW TRUE) - endif() - endif() - - # remove timestamps so CMake will re-download the dependency - file(REMOVE_RECURSE ${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild) - set(PACKAGE_INFO "${PACKAGE_INFO} to ${download_directory}") - endif() - endif() - - if(NOT "${DOWNLOAD_ONLY}") - cpm_create_module_file(${CPM_ARGS_NAME} "CPMAddPackage(\"${ARGN}\")") - endif() - - if(CPM_PACKAGE_LOCK_ENABLED) - if((CPM_ARGS_VERSION AND NOT CPM_ARGS_SOURCE_DIR) OR CPM_INCLUDE_ALL_IN_PACKAGE_LOCK) - cpm_add_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") - elseif(CPM_ARGS_SOURCE_DIR) - cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "local directory") - else() - cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") - endif() - endif() - - cpm_message( - STATUS "${CPM_INDENT} Adding package ${CPM_ARGS_NAME}@${CPM_ARGS_VERSION} (${PACKAGE_INFO})" - ) - - if(NOT CPM_SKIP_FETCH) - # CMake 3.28 added EXCLUDE, SYSTEM (3.25), and SOURCE_SUBDIR (3.18) to FetchContent_Declare. - # Calling FetchContent_MakeAvailable will then internally forward these options to - # add_subdirectory. Up until these changes, we had to call FetchContent_Populate and - # add_subdirectory separately, which is no longer necessary and has been deprecated as of 3.30. - # A Bug in CMake prevents us to use the non-deprecated functions until 3.30.3. - set(fetchContentDeclareExtraArgs "") - if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") - if(${CPM_ARGS_EXCLUDE_FROM_ALL}) - list(APPEND fetchContentDeclareExtraArgs EXCLUDE_FROM_ALL) - endif() - if(${CPM_ARGS_SYSTEM}) - list(APPEND fetchContentDeclareExtraArgs SYSTEM) - endif() - if(DEFINED CPM_ARGS_SOURCE_SUBDIR) - list(APPEND fetchContentDeclareExtraArgs SOURCE_SUBDIR ${CPM_ARGS_SOURCE_SUBDIR}) - endif() - # For CMake version <3.28 OPTIONS are parsed in cpm_add_subdirectory - if(CPM_ARGS_OPTIONS AND NOT DOWNLOAD_ONLY) - foreach(OPTION ${CPM_ARGS_OPTIONS}) - cpm_parse_option("${OPTION}") - set(${OPTION_KEY} "${OPTION_VALUE}") - endforeach() - endif() - endif() - cpm_declare_fetch( - "${CPM_ARGS_NAME}" ${fetchContentDeclareExtraArgs} "${CPM_ARGS_UNPARSED_ARGUMENTS}" - ) - - cpm_fetch_package("${CPM_ARGS_NAME}" ${DOWNLOAD_ONLY} populated ${CPM_ARGS_UNPARSED_ARGUMENTS}) - if(CPM_SOURCE_CACHE AND download_directory) - file(LOCK ${download_directory}/../cmake.lock RELEASE) - endif() - if(${populated} AND ${CMAKE_VERSION} VERSION_LESS "3.30.3") - cpm_add_subdirectory( - "${CPM_ARGS_NAME}" - "${DOWNLOAD_ONLY}" - "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" - "${${CPM_ARGS_NAME}_BINARY_DIR}" - "${CPM_ARGS_EXCLUDE_FROM_ALL}" - "${CPM_ARGS_SYSTEM}" - "${CPM_ARGS_OPTIONS}" - ) - endif() - cpm_get_fetch_properties("${CPM_ARGS_NAME}") - endif() - - set(${CPM_ARGS_NAME}_ADDED YES) - cpm_export_variables("${CPM_ARGS_NAME}") -endfunction() - -# Fetch a previously declared package -macro(CPMGetPackage Name) - if(DEFINED "CPM_DECLARATION_${Name}") - CPMAddPackage(NAME ${Name}) - else() - message(SEND_ERROR "${CPM_INDENT} Cannot retrieve package ${Name}: no declaration available") - endif() -endmacro() - -# export variables available to the caller to the parent scope expects ${CPM_ARGS_NAME} to be set -macro(cpm_export_variables name) - set(${name}_SOURCE_DIR - "${${name}_SOURCE_DIR}" - PARENT_SCOPE - ) - set(${name}_BINARY_DIR - "${${name}_BINARY_DIR}" - PARENT_SCOPE - ) - set(${name}_ADDED - "${${name}_ADDED}" - PARENT_SCOPE - ) - set(CPM_LAST_PACKAGE_NAME - "${name}" - PARENT_SCOPE - ) -endmacro() - -# declares a package, so that any call to CPMAddPackage for the package name will use these -# arguments instead. Previous declarations will not be overridden. -macro(CPMDeclarePackage Name) - if(NOT DEFINED "CPM_DECLARATION_${Name}") - set("CPM_DECLARATION_${Name}" "${ARGN}") - endif() -endmacro() - -function(cpm_add_to_package_lock Name) - if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) - cpm_prettify_package_arguments(PRETTY_ARGN false ${ARGN}) - file(APPEND ${CPM_PACKAGE_LOCK_FILE} "# ${Name}\nCPMDeclarePackage(${Name}\n${PRETTY_ARGN})\n") - endif() -endfunction() - -function(cpm_add_comment_to_package_lock Name) - if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) - cpm_prettify_package_arguments(PRETTY_ARGN true ${ARGN}) - file(APPEND ${CPM_PACKAGE_LOCK_FILE} - "# ${Name} (unversioned)\n# CPMDeclarePackage(${Name}\n${PRETTY_ARGN}#)\n" - ) - endif() -endfunction() - -# includes the package lock file if it exists and creates a target `cpm-update-package-lock` to -# update it -macro(CPMUsePackageLock file) - if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) - get_filename_component(CPM_ABSOLUTE_PACKAGE_LOCK_PATH ${file} ABSOLUTE) - if(EXISTS ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) - include(${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) - endif() - if(NOT TARGET cpm-update-package-lock) - add_custom_target( - cpm-update-package-lock COMMAND ${CMAKE_COMMAND} -E copy ${CPM_PACKAGE_LOCK_FILE} - ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH} - ) - endif() - set(CPM_PACKAGE_LOCK_ENABLED true) - endif() -endmacro() - -# registers a package that has been added to CPM -function(CPMRegisterPackage PACKAGE VERSION) - list(APPEND CPM_PACKAGES ${PACKAGE}) - set(CPM_PACKAGES - ${CPM_PACKAGES} - CACHE INTERNAL "" - ) - set("CPM_PACKAGE_${PACKAGE}_VERSION" - ${VERSION} - CACHE INTERNAL "" - ) -endfunction() - -# retrieve the current version of the package to ${OUTPUT} -function(CPMGetPackageVersion PACKAGE OUTPUT) - set(${OUTPUT} - "${CPM_PACKAGE_${PACKAGE}_VERSION}" - PARENT_SCOPE - ) -endfunction() - -# declares a package in FetchContent_Declare -function(cpm_declare_fetch PACKAGE) - if(${CPM_DRY_RUN}) - cpm_message(STATUS "${CPM_INDENT} Package not declared (dry run)") - return() - endif() - - FetchContent_Declare(${PACKAGE} ${ARGN}) -endfunction() - -# returns properties for a package previously defined by cpm_declare_fetch -function(cpm_get_fetch_properties PACKAGE) - if(${CPM_DRY_RUN}) - return() - endif() - - set(${PACKAGE}_SOURCE_DIR - "${CPM_PACKAGE_${PACKAGE}_SOURCE_DIR}" - PARENT_SCOPE - ) - set(${PACKAGE}_BINARY_DIR - "${CPM_PACKAGE_${PACKAGE}_BINARY_DIR}" - PARENT_SCOPE - ) -endfunction() - -function(cpm_store_fetch_properties PACKAGE source_dir binary_dir) - if(${CPM_DRY_RUN}) - return() - endif() - - set(CPM_PACKAGE_${PACKAGE}_SOURCE_DIR - "${source_dir}" - CACHE INTERNAL "" - ) - set(CPM_PACKAGE_${PACKAGE}_BINARY_DIR - "${binary_dir}" - CACHE INTERNAL "" - ) -endfunction() - -# adds a package as a subdirectory if viable, according to provided options -function( - cpm_add_subdirectory - PACKAGE - DOWNLOAD_ONLY - SOURCE_DIR - BINARY_DIR - EXCLUDE - SYSTEM - OPTIONS -) - - if(NOT DOWNLOAD_ONLY AND EXISTS ${SOURCE_DIR}/CMakeLists.txt) - set(addSubdirectoryExtraArgs "") - if(EXCLUDE) - list(APPEND addSubdirectoryExtraArgs EXCLUDE_FROM_ALL) - endif() - if("${SYSTEM}" AND "${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.25") - # https://cmake.org/cmake/help/latest/prop_dir/SYSTEM.html#prop_dir:SYSTEM - list(APPEND addSubdirectoryExtraArgs SYSTEM) - endif() - if(OPTIONS) - foreach(OPTION ${OPTIONS}) - cpm_parse_option("${OPTION}") - set(${OPTION_KEY} "${OPTION_VALUE}") - endforeach() - endif() - set(CPM_OLD_INDENT "${CPM_INDENT}") - set(CPM_INDENT "${CPM_INDENT} ${PACKAGE}:") - add_subdirectory(${SOURCE_DIR} ${BINARY_DIR} ${addSubdirectoryExtraArgs}) - set(CPM_INDENT "${CPM_OLD_INDENT}") - endif() -endfunction() - -# downloads a previously declared package via FetchContent and exports the variables -# `${PACKAGE}_SOURCE_DIR` and `${PACKAGE}_BINARY_DIR` to the parent scope -function(cpm_fetch_package PACKAGE DOWNLOAD_ONLY populated) - set(${populated} - FALSE - PARENT_SCOPE - ) - if(${CPM_DRY_RUN}) - cpm_message(STATUS "${CPM_INDENT} Package ${PACKAGE} not fetched (dry run)") - return() - endif() - - FetchContent_GetProperties(${PACKAGE}) - - string(TOLOWER "${PACKAGE}" lower_case_name) - - if(NOT ${lower_case_name}_POPULATED) - if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") - if(DOWNLOAD_ONLY) - # MakeAvailable will call add_subdirectory internally which is not what we want when - # DOWNLOAD_ONLY is set. Populate will only download the dependency without adding it to the - # build - FetchContent_Populate( - ${PACKAGE} - SOURCE_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-src" - BINARY_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" - SUBBUILD_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild" - ${ARGN} - ) - else() - FetchContent_MakeAvailable(${PACKAGE}) - endif() - else() - FetchContent_Populate(${PACKAGE}) - endif() - set(${populated} - TRUE - PARENT_SCOPE - ) - endif() - - cpm_store_fetch_properties( - ${CPM_ARGS_NAME} ${${lower_case_name}_SOURCE_DIR} ${${lower_case_name}_BINARY_DIR} - ) - - set(${PACKAGE}_SOURCE_DIR - ${${lower_case_name}_SOURCE_DIR} - PARENT_SCOPE - ) - set(${PACKAGE}_BINARY_DIR - ${${lower_case_name}_BINARY_DIR} - PARENT_SCOPE - ) -endfunction() - -# splits a package option -function(cpm_parse_option OPTION) - string(REGEX MATCH "^[^ ]+" OPTION_KEY "${OPTION}") - string(LENGTH "${OPTION}" OPTION_LENGTH) - string(LENGTH "${OPTION_KEY}" OPTION_KEY_LENGTH) - if(OPTION_KEY_LENGTH STREQUAL OPTION_LENGTH) - # no value for key provided, assume user wants to set option to "ON" - set(OPTION_VALUE "ON") - else() - math(EXPR OPTION_KEY_LENGTH "${OPTION_KEY_LENGTH}+1") - string(SUBSTRING "${OPTION}" "${OPTION_KEY_LENGTH}" "-1" OPTION_VALUE) - endif() - set(OPTION_KEY - "${OPTION_KEY}" - PARENT_SCOPE - ) - set(OPTION_VALUE - "${OPTION_VALUE}" - PARENT_SCOPE - ) -endfunction() - -# guesses the package version from a git tag -function(cpm_get_version_from_git_tag GIT_TAG RESULT) - string(LENGTH ${GIT_TAG} length) - if(length EQUAL 40) - # GIT_TAG is probably a git hash - set(${RESULT} - 0 - PARENT_SCOPE - ) - else() - string(REGEX MATCH "v?([0123456789.]*).*" _ ${GIT_TAG}) - set(${RESULT} - ${CMAKE_MATCH_1} - PARENT_SCOPE - ) - endif() -endfunction() - -# guesses if the git tag is a commit hash or an actual tag or a branch name. -function(cpm_is_git_tag_commit_hash GIT_TAG RESULT) - string(LENGTH "${GIT_TAG}" length) - # full hash has 40 characters, and short hash has at least 7 characters. - if(length LESS 7 OR length GREATER 40) - set(${RESULT} - 0 - PARENT_SCOPE - ) - else() - if(${GIT_TAG} MATCHES "^[a-fA-F0-9]+$") - set(${RESULT} - 1 - PARENT_SCOPE - ) - else() - set(${RESULT} - 0 - PARENT_SCOPE - ) - endif() - endif() -endfunction() - -function(cpm_prettify_package_arguments OUT_VAR IS_IN_COMMENT) - set(oneValueArgs - NAME - FORCE - VERSION - GIT_TAG - DOWNLOAD_ONLY - GITHUB_REPOSITORY - GITLAB_REPOSITORY - BITBUCKET_REPOSITORY - GIT_REPOSITORY - SOURCE_DIR - FIND_PACKAGE_ARGUMENTS - NO_CACHE - SYSTEM - GIT_SHALLOW - EXCLUDE_FROM_ALL - SOURCE_SUBDIR - ) - set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND) - cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) - - foreach(oneArgName ${oneValueArgs}) - if(DEFINED CPM_ARGS_${oneArgName}) - if(${IS_IN_COMMENT}) - string(APPEND PRETTY_OUT_VAR "#") - endif() - if(${oneArgName} STREQUAL "SOURCE_DIR") - string(REPLACE ${CMAKE_SOURCE_DIR} "\${CMAKE_SOURCE_DIR}" CPM_ARGS_${oneArgName} - ${CPM_ARGS_${oneArgName}} - ) - endif() - string(APPEND PRETTY_OUT_VAR " ${oneArgName} ${CPM_ARGS_${oneArgName}}\n") - endif() - endforeach() - foreach(multiArgName ${multiValueArgs}) - if(DEFINED CPM_ARGS_${multiArgName}) - if(${IS_IN_COMMENT}) - string(APPEND PRETTY_OUT_VAR "#") - endif() - string(APPEND PRETTY_OUT_VAR " ${multiArgName}\n") - foreach(singleOption ${CPM_ARGS_${multiArgName}}) - if(${IS_IN_COMMENT}) - string(APPEND PRETTY_OUT_VAR "#") - endif() - string(APPEND PRETTY_OUT_VAR " \"${singleOption}\"\n") - endforeach() - endif() - endforeach() - - if(NOT "${CPM_ARGS_UNPARSED_ARGUMENTS}" STREQUAL "") - if(${IS_IN_COMMENT}) - string(APPEND PRETTY_OUT_VAR "#") - endif() - string(APPEND PRETTY_OUT_VAR " ") - foreach(CPM_ARGS_UNPARSED_ARGUMENT ${CPM_ARGS_UNPARSED_ARGUMENTS}) - string(APPEND PRETTY_OUT_VAR " ${CPM_ARGS_UNPARSED_ARGUMENT}") - endforeach() - string(APPEND PRETTY_OUT_VAR "\n") - endif() - - set(${OUT_VAR} - ${PRETTY_OUT_VAR} - PARENT_SCOPE - ) - -endfunction() diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 2bec3ee..cb51bb6 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -1,26 +1,15 @@ # Dependencies.cmake — Third-party dependencies. # -# `modulo_find_dependencies()` resolves: -# - Homebrew binary libs: Qt 6.8+, libpqxx, libsodium -# - CPM-pinned source libs: Catch2 v3 +# `modulo_find_dependencies()` resolves every external dependency in one +# place. All of them are Homebrew binary libraries: Qt 6.8+, libpqxx, libsodium. +# There are no source-level dependencies (testing uses Qt Test). # # A macro so find_package results land in the caller's # directory scope. Called from the root CMakeLists.txt. include_guard(GLOBAL) -# Source dependencies are cached outside the build tree so wiping build/ -# does not re-download them (.cache/ is gitignored). Must be set BEFORE -# include(CPM): CPM initializes this cache variable itself on include and -# a later set(... CACHE ...) would not override the existing entry. -set(CPM_SOURCE_CACHE - "${CMAKE_SOURCE_DIR}/.cache/cpm" - CACHE PATH "Download cache for CPM source dependencies") - -include(CPM) - macro(modulo_find_dependencies) - # --- Homebrew binary libraries ------------------------------------------- # Qt path comes from CMAKE_PREFIX_PATH (set by the presets: /opt/homebrew/opt/qt). find_package( Qt6 6.8 REQUIRED @@ -50,9 +39,4 @@ macro(modulo_find_dependencies) PROPERTIES IMPORTED_LOCATION "${MODULO_SODIUM_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${MODULO_SODIUM_INCLUDE_DIR}") endif() - - # --- CPM source libraries (version-pinned) ------------------------------- - if(MODULO_BUILD_TESTS) - cpmaddpackage("gh:catchorg/Catch2@3.8.1") - endif() endmacro() diff --git a/cmake/ModuloTargets.cmake b/cmake/ModuloTargets.cmake index 5d77563..5879b02 100644 --- a/cmake/ModuloTargets.cmake +++ b/cmake/ModuloTargets.cmake @@ -12,8 +12,9 @@ # Application or tool binary. # # modulo_add_test( LABEL unit|integration SOURCES ... [DEPS ...]) -# Catch2 test binary, registered with CTest under the given label -# (labels drive the `unit` / `integration` / `all` test presets). +# Qt Test binary (one QObject test class, QTEST_GUILESS_MAIN), registered +# with CTest under the given label (labels drive the `unit` / +# `integration` / `all` test presets). # # modulo_add_qml_test( QML_DIR SOURCES ... [DEPS ...]) # Qt Quick Test binary running the tst_*.qml files in QML_DIR, @@ -48,6 +49,14 @@ function(_modulo_write_qt_conf target) return() endif() + # Executables land in the current binary dir; one qt.conf per directory + # serves every binary in it (generating the same file twice is an error). + get_property(_modulo_qt_conf_written DIRECTORY PROPERTY MODULO_QT_CONF_WRITTEN) + if(_modulo_qt_conf_written) + return() + endif() + set_property(DIRECTORY PROPERTY MODULO_QT_CONF_WRITTEN TRUE) + get_filename_component(_modulo_qt_root "${Qt6_DIR}/../../.." ABSOLUTE) if(EXISTS "${_modulo_qt_root}/share/qt/plugins") set(_modulo_qt_prefix "${_modulo_qt_root}/share/qt") # Homebrew layout @@ -57,10 +66,18 @@ function(_modulo_write_qt_conf target) file( GENERATE - OUTPUT "$/qt.conf" + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/qt.conf" CONTENT "[Paths]\nPrefix = ${_modulo_qt_prefix}\n") endfunction() +# Module convention: a target's tests live in ./tests and are picked up +# automatically when MODULO_BUILD_TESTS is ON — no per-module wiring needed. +function(_modulo_add_tests_subdirectory) + if(MODULO_BUILD_TESTS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt") + add_subdirectory(tests) + endif() +endfunction() + function(modulo_add_library name) cmake_parse_arguments(PARSE_ARGV 1 ARG "" "" "SOURCES;PUBLIC_DEPS;PRIVATE_DEPS") @@ -85,6 +102,7 @@ function(modulo_add_library name) endif() _modulo_apply_common_settings(${name}) + _modulo_add_tests_subdirectory() endfunction() function(modulo_add_executable name) @@ -144,6 +162,7 @@ function(modulo_add_qml_app name) _modulo_apply_common_settings(${name}) _modulo_write_qt_conf(${name}) + _modulo_add_tests_subdirectory() endfunction() function(modulo_add_test name) @@ -160,17 +179,23 @@ function(modulo_add_test name) message(FATAL_ERROR "modulo_add_test(${name}): SOURCES is required") endif() + # One Qt Test class per binary (QTEST_GUILESS_MAIN in the single source file). add_executable(${name} ${ARG_SOURCES}) - target_link_libraries(${name} PRIVATE Catch2::Catch2WithMain) + target_link_libraries(${name} PRIVATE Qt6::Test) if(ARG_DEPS) target_link_libraries(${name} PRIVATE ${ARG_DEPS}) endif() + # Shared test-support headers (): integration fixtures etc. + target_include_directories(${name} PRIVATE "${CMAKE_SOURCE_DIR}/tests/support/include") + _modulo_apply_common_settings(${name}) _modulo_write_qt_conf(${name}) add_test(NAME ${name} COMMAND ${name}) - set_tests_properties(${name} PROPERTIES LABELS ${ARG_LABEL}) + # QSKIP() prints "SKIP : ..." and exits 0; make CTest report the binary as + # skipped (e.g. integration tests without a database), not passed. + set_tests_properties(${name} PROPERTIES LABELS ${ARG_LABEL} SKIP_REGULAR_EXPRESSION "SKIP : ") endfunction() function(modulo_add_qml_test name) @@ -200,5 +225,6 @@ function(modulo_add_qml_test name) _modulo_write_qt_conf(${name}) add_test(NAME ${name} COMMAND ${name}) - set_tests_properties(${name} PROPERTIES LABELS ui) + # QML tests need a QPA platform but no display: run them offscreen. + set_tests_properties(${name} PROPERTIES LABELS ui ENVIRONMENT "QT_QPA_PLATFORM=offscreen") endfunction() diff --git a/libs/api/tests/CMakeLists.txt b/libs/api/tests/CMakeLists.txt new file mode 100644 index 0000000..4aba1bb --- /dev/null +++ b/libs/api/tests/CMakeLists.txt @@ -0,0 +1,17 @@ +modulo_add_test( + modulo_api_health_dto_tests + LABEL unit + SOURCES test_health_dto.cpp + DEPS modulo_api) + +modulo_add_test( + modulo_api_error_dto_tests + LABEL unit + SOURCES test_error_dto.cpp + DEPS modulo_api) + +modulo_add_test( + modulo_api_json_tests + LABEL unit + SOURCES test_json.cpp + DEPS modulo_api) diff --git a/libs/api/tests/test_error_dto.cpp b/libs/api/tests/test_error_dto.cpp new file mode 100644 index 0000000..fe503bd --- /dev/null +++ b/libs/api/tests/test_error_dto.cpp @@ -0,0 +1,55 @@ +#include + +#include +#include + +using modulo::api::ErrorResponse; + +class ErrorDtoTest : public QObject { + Q_OBJECT + +private slots: + + void serializesToTheUniformEnvelope() { + const ErrorResponse error{.code = QStringLiteral("not_found"), .message = QStringLiteral("resource not found")}; + + const QJsonObject json = error.toJson(); + QCOMPARE(json.size(), 1); // nothing outside the "error" envelope + const QJsonObject envelope = json.value(QLatin1StringView{"error"}).toObject(); + QCOMPARE(envelope.value(QLatin1StringView{"code"}).toString(), QStringLiteral("not_found")); + QCOMPARE(envelope.value(QLatin1StringView{"message"}).toString(), QStringLiteral("resource not found")); + + const auto parsed = ErrorResponse::fromJson(json); + QVERIFY(parsed.has_value()); + QCOMPARE(parsed->code, error.code); + QCOMPARE(parsed->message, error.message); + } + + void rejectsInvalidWireData_data() { + QTest::addColumn("json"); + QTest::addColumn("offendingField"); + + QTest::newRow("flat object without the envelope") + << QJsonObject{{QStringLiteral("code"), QStringLiteral("x")}, + {QStringLiteral("message"), QStringLiteral("y")}} + << QStringLiteral("error"); + QTest::newRow("envelope missing the code") + << QJsonObject{{QStringLiteral("error"), QJsonObject{{QStringLiteral("message"), QStringLiteral("y")}}}} + << QStringLiteral("code"); + QTest::newRow("envelope is not an object") + << QJsonObject{{QStringLiteral("error"), QStringLiteral("oops")}} << QStringLiteral("error"); + } + + void rejectsInvalidWireData() { + QFETCH(QJsonObject, json); + QFETCH(QString, offendingField); + + const auto result = ErrorResponse::fromJson(json); + QVERIFY(!result.has_value()); + QCOMPARE(result.error().code, QStringLiteral("api.invalid_field")); + QVERIFY(result.error().message.contains(offendingField)); + } +}; + +QTEST_GUILESS_MAIN(ErrorDtoTest) +#include "test_error_dto.moc" diff --git a/libs/api/tests/test_health_dto.cpp b/libs/api/tests/test_health_dto.cpp new file mode 100644 index 0000000..9912573 --- /dev/null +++ b/libs/api/tests/test_health_dto.cpp @@ -0,0 +1,50 @@ +#include + +#include +#include + +using modulo::api::HealthResponse; + +class HealthDtoTest : public QObject { + Q_OBJECT + +private slots: + + void roundTripsThroughJson() { + const HealthResponse original{.status = QStringLiteral("ok"), .version = QStringLiteral("1.2.3")}; + + const QJsonObject json = original.toJson(); + QCOMPARE(json.value(QLatin1StringView{"status"}).toString(), QStringLiteral("ok")); + QCOMPARE(json.value(QLatin1StringView{"version"}).toString(), QStringLiteral("1.2.3")); + + const auto parsed = HealthResponse::fromJson(json); + QVERIFY(parsed.has_value()); + QCOMPARE(parsed->status, original.status); + QCOMPARE(parsed->version, original.version); + } + + void rejectsInvalidWireData_data() { + QTest::addColumn("json"); + QTest::addColumn("offendingField"); + + QTest::newRow("missing version") << QJsonObject{{QStringLiteral("status"), QStringLiteral("ok")}} + << QStringLiteral("version"); + QTest::newRow("status is a number, not coerced") + << QJsonObject{{QStringLiteral("status"), 42}, {QStringLiteral("version"), QStringLiteral("1.0.0")}} + << QStringLiteral("status"); + QTest::newRow("empty object") << QJsonObject{} << QStringLiteral("status"); + } + + void rejectsInvalidWireData() { + QFETCH(QJsonObject, json); + QFETCH(QString, offendingField); + + const auto result = HealthResponse::fromJson(json); + QVERIFY(!result.has_value()); + QCOMPARE(result.error().code, QStringLiteral("api.invalid_field")); + QVERIFY(result.error().message.contains(offendingField)); + } +}; + +QTEST_GUILESS_MAIN(HealthDtoTest) +#include "test_health_dto.moc" diff --git a/libs/api/tests/test_json.cpp b/libs/api/tests/test_json.cpp new file mode 100644 index 0000000..d108bcd --- /dev/null +++ b/libs/api/tests/test_json.cpp @@ -0,0 +1,61 @@ +#include + +#include +#include +#include + +namespace json = modulo::api::json; + +class JsonHelpersTest : public QObject { + Q_OBJECT + +private slots: + + void requireStringReturnsPresentStrings() { + const QJsonObject object{{QStringLiteral("name"), QStringLiteral("modulo")}}; + + const auto value = json::requireString(object, QLatin1StringView{"name"}); + QVERIFY(value.has_value()); + QCOMPARE(*value, QStringLiteral("modulo")); + } + + void requireStringNeverUsesSilentDefaults_data() { + QTest::addColumn("key"); + + // Every non-string shape QJson would otherwise coerce to "". + QTest::newRow("missing key") << QStringLiteral("missing"); + QTest::newRow("number") << QStringLiteral("count"); + QTest::newRow("object") << QStringLiteral("nested"); + QTest::newRow("array") << QStringLiteral("list"); + QTest::newRow("null") << QStringLiteral("nothing"); + } + + void requireStringNeverUsesSilentDefaults() { + QFETCH(QString, key); + const QJsonObject object{{QStringLiteral("count"), 3}, + {QStringLiteral("nested"), QJsonObject{}}, + {QStringLiteral("list"), QJsonArray{}}, + {QStringLiteral("nothing"), QJsonValue::Null}}; + + const auto value = json::requireString(object, QLatin1StringView{key.toLatin1()}); + QVERIFY(!value.has_value()); + QCOMPARE(value.error().code, QStringLiteral("api.invalid_field")); + QVERIFY(value.error().message.contains(key)); + } + + void requireObjectDistinguishesObjects() { + const QJsonObject object{{QStringLiteral("inner"), QJsonObject{{QStringLiteral("k"), QStringLiteral("v")}}}, + {QStringLiteral("text"), QStringLiteral("not an object")}}; + + const auto inner = json::requireObject(object, QLatin1StringView{"inner"}); + QVERIFY(inner.has_value()); + QCOMPARE(inner->value(QLatin1StringView{"k"}).toString(), QStringLiteral("v")); + + const auto text = json::requireObject(object, QLatin1StringView{"text"}); + QVERIFY(!text.has_value()); + QCOMPARE(text.error().code, QStringLiteral("api.invalid_field")); + } +}; + +QTEST_GUILESS_MAIN(JsonHelpersTest) +#include "test_json.moc" diff --git a/libs/core/tests/CMakeLists.txt b/libs/core/tests/CMakeLists.txt new file mode 100644 index 0000000..50319af --- /dev/null +++ b/libs/core/tests/CMakeLists.txt @@ -0,0 +1,5 @@ +modulo_add_test( + modulo_core_tests + LABEL unit + SOURCES test_version.cpp + DEPS modulo_core) diff --git a/libs/core/tests/test_version.cpp b/libs/core/tests/test_version.cpp new file mode 100644 index 0000000..d6a56f8 --- /dev/null +++ b/libs/core/tests/test_version.cpp @@ -0,0 +1,23 @@ +#include + +#include +#include + +class VersionTest : public QObject { + Q_OBJECT + +private slots: + + void reportsTheCMakeProjectVersion() { + // Both the library and this test receive MODULO_VERSION from the toolkit. + QCOMPARE(modulo::core::version(), QStringLiteral(MODULO_VERSION)); + } + + void hasSemanticVersionShape() { + const QRegularExpression semver{QStringLiteral(R"(^\d+\.\d+\.\d+$)")}; + QVERIFY(semver.match(modulo::core::version()).hasMatch()); + } +}; + +QTEST_GUILESS_MAIN(VersionTest) +#include "test_version.moc" diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 25d8dd6..7c741e9 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -6,3 +6,6 @@ add_subdirectory(modules/http) add_subdirectory(app) add_subdirectory(migrate) + +# Cross-module integration tests (label: integration). +add_subdirectory(tests/integration) diff --git a/server/modules/config/tests/CMakeLists.txt b/server/modules/config/tests/CMakeLists.txt new file mode 100644 index 0000000..f002880 --- /dev/null +++ b/server/modules/config/tests/CMakeLists.txt @@ -0,0 +1,5 @@ +modulo_add_test( + modulo_server_config_tests + LABEL unit + SOURCES test_config.cpp + DEPS modulo_server_config) diff --git a/server/modules/config/tests/test_config.cpp b/server/modules/config/tests/test_config.cpp new file mode 100644 index 0000000..4ee0254 --- /dev/null +++ b/server/modules/config/tests/test_config.cpp @@ -0,0 +1,115 @@ +#include + +#include +#include +#include + +#include +#include + +using modulo::server::config::Config; + +namespace { + +/// Sets MODULO_* variables for one test and restores the previous environment +/// on destruction, so tests cannot leak state into each other. +class ScopedEnvironment { +public: + explicit ScopedEnvironment(std::initializer_list> variables) { + for (const auto& [name, value] : variables) { + saved_.insert(name, qgetenv(name)); + if (value == nullptr) { + qunsetenv(name); + } else { + qputenv(name, value); + } + } + } + + ~ScopedEnvironment() { + for (auto it = saved_.cbegin(); it != saved_.cend(); ++it) { + if (it.value().isNull()) { + qunsetenv(it.key()); + } else { + qputenv(it.key(), it.value()); + } + } + } + + ScopedEnvironment(const ScopedEnvironment&) = delete; + ScopedEnvironment& operator=(const ScopedEnvironment&) = delete; + +private: + QHash saved_; +}; + +} // namespace + +class ConfigTest : public QObject { + Q_OBJECT + +private slots: + + void fallsBackToDefaultsWhenNothingIsSet() { + const ScopedEnvironment env{ + {"MODULO_DB_URL", nullptr}, {"MODULO_HTTP_PORT", nullptr}, {"MODULO_DATA_DIR", nullptr}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(config.has_value()); + QVERIFY(config->databaseUrl.isEmpty()); + QCOMPARE(config->httpPort, quint16{8080}); + QCOMPARE(config->dataDir, QStringLiteral("./var/data")); + } + + void readsEveryVariable() { + const ScopedEnvironment env{{"MODULO_DB_URL", "postgresql://u:p@localhost:5433/db"}, + {"MODULO_HTTP_PORT", "9090"}, + {"MODULO_DATA_DIR", "/tmp/modulo-data"}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(config.has_value()); + QCOMPARE(config->databaseUrl, QStringLiteral("postgresql://u:p@localhost:5433/db")); + QCOMPARE(config->httpPort, quint16{9090}); + QCOMPARE(config->dataDir, QStringLiteral("/tmp/modulo-data")); + } + + void treatsExportedButEmptyVariableAsUnset() { + const ScopedEnvironment env{{"MODULO_HTTP_PORT", ""}, {"MODULO_DATA_DIR", ""}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(config.has_value()); + QCOMPARE(config->httpPort, quint16{8080}); + QCOMPARE(config->dataDir, QStringLiteral("./var/data")); + } + + void acceptsPortZeroForOsAssignedPorts() { + const ScopedEnvironment env{{"MODULO_HTTP_PORT", "0"}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(config.has_value()); + QCOMPARE(config->httpPort, quint16{0}); + } + + void rejectsMalformedPorts_data() { + QTest::addColumn("port"); + + QTest::newRow("letters") << QByteArray{"abc"}; + QTest::newRow("out of range") << QByteArray{"70000"}; + QTest::newRow("negative") << QByteArray{"-1"}; + QTest::newRow("trailing garbage") << QByteArray{"80x"}; + QTest::newRow("fractional") << QByteArray{"8080.5"}; + } + + void rejectsMalformedPorts() { + QFETCH(QByteArray, port); + const ScopedEnvironment env{{"MODULO_HTTP_PORT", port.constData()}}; + + const auto config = Config::fromEnvironment(); + QVERIFY(!config.has_value()); + QCOMPARE(config.error().code, QStringLiteral("config.invalid_port")); + QVERIFY(config.error().message.contains(QString::fromLatin1(port))); + } +}; + +QTEST_GUILESS_MAIN(ConfigTest) +#include "test_config.moc" diff --git a/server/tests/integration/CMakeLists.txt b/server/tests/integration/CMakeLists.txt new file mode 100644 index 0000000..d221a35 --- /dev/null +++ b/server/tests/integration/CMakeLists.txt @@ -0,0 +1,9 @@ +# Cross-module integration tests: real QHttpServer in-process, real HTTP +# client, and (from Increment 2) the real dockerized test database. +# Opt-in via MODULO_TEST_DB_URL — see tests/support/include/modulo/testing/integration.h. + +modulo_add_test( + modulo_integration_tests + LABEL integration + SOURCES test_health_endpoint.cpp + DEPS modulo_server_http Qt6::Network) diff --git a/server/tests/integration/test_health_endpoint.cpp b/server/tests/integration/test_health_endpoint.cpp new file mode 100644 index 0000000..e17fe0b --- /dev/null +++ b/server/tests/integration/test_health_endpoint.cpp @@ -0,0 +1,65 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +using namespace modulo; + +class HealthEndpointTest : public QObject { + Q_OBJECT + +private slots: + + /// Fresh server on an OS-assigned loopback port for every test function. + void init() { + server_ = std::make_unique(server::config::Config{.httpPort = 0}); + const auto port = server_->listen(); + QVERIFY(port.has_value()); + baseUrl_ = QUrl{QStringLiteral("http://127.0.0.1:%1").arg(*port)}; + } + + void cleanup() { server_.reset(); } + + void healthReportsOkAndTheServerVersion() { + MODULO_REQUIRE_TEST_DATABASE(); + + const auto response = testing::httpGet(url(QStringLiteral("/api/v1/health"))); + QCOMPARE(response.status, 200); + + const auto document = QJsonDocument::fromJson(response.body); + QVERIFY(document.isObject()); + const auto health = api::HealthResponse::fromJson(document.object()); + QVERIFY(health.has_value()); + QCOMPARE(health->status, QStringLiteral("ok")); + QCOMPARE(health->version, core::version()); + } + + void unknownRoutesAnswerWithTheJsonErrorEnvelope() { + MODULO_REQUIRE_TEST_DATABASE(); + + const auto response = testing::httpGet(url(QStringLiteral("/api/v1/does-not-exist"))); + QCOMPARE(response.status, 404); + + const auto document = QJsonDocument::fromJson(response.body); + QVERIFY(document.isObject()); + const auto error = api::ErrorResponse::fromJson(document.object()); + QVERIFY(error.has_value()); + QCOMPARE(error->code, QStringLiteral("not_found")); + } + +private: + QUrl url(const QString& path) const { return baseUrl_.resolved(QUrl{path}); } + + std::unique_ptr server_; + QUrl baseUrl_; +}; + +QTEST_GUILESS_MAIN(HealthEndpointTest) +#include "test_health_endpoint.moc" diff --git a/tests/support/include/modulo/testing/integration.h b/tests/support/include/modulo/testing/integration.h new file mode 100644 index 0000000..84e3740 --- /dev/null +++ b/tests/support/include/modulo/testing/integration.h @@ -0,0 +1,59 @@ +#pragma once + +// Fixtures shared by integration tests (server/tests/integration). +// +// Integration tests are OPT-IN: they run only when MODULO_TEST_DB_URL is set +// (see .env.example). Otherwise every test function QSKIPs, and CTest reports +// the binary as skipped (the toolkit maps Qt Test's "SKIP :" output line) — +// `ctest --preset unit` / `all` therefore never require Docker. +// +// Test binaries use QTEST_GUILESS_MAIN, which provides the QCoreApplication +// event loop that QHttpServer and QNetworkAccessManager need. + +#include +#include +#include +#include +#include +#include +#include +#include + +/// First statement of every integration test function: skips the test when +/// MODULO_TEST_DB_URL is unset. A macro because QSKIP must return from the +/// test function itself. +#define MODULO_REQUIRE_TEST_DATABASE() \ + if (modulo::testing::testDatabaseUrl().isEmpty()) { \ + QSKIP("MODULO_TEST_DB_URL is not set; integration tests are opt-in"); \ + } + +namespace modulo::testing { + +inline QString testDatabaseUrl() { + return qEnvironmentVariable("MODULO_TEST_DB_URL"); +} + +struct HttpResponse { + int status = 0; + QByteArray body; +}; + +/// Blocking HTTP GET against an in-process server, with a timeout so a dead +/// server fails the test instead of hanging it. +inline HttpResponse httpGet(const QUrl& url, int timeoutMs = 5000) { + QNetworkAccessManager network; + QNetworkReply* reply = network.get(QNetworkRequest{url}); + + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + QTimer::singleShot(timeoutMs, &loop, &QEventLoop::quit); + loop.exec(); + + HttpResponse response; + response.status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + response.body = reply->readAll(); + reply->deleteLater(); + return response; +} + +} // namespace modulo::testing From 4150a5804ca1e75dd6d7ec1561c19d165d721a97 Mon Sep 17 00:00:00 2001 From: Angelo Barbu <77395130+angelobarbu@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:31:34 +0300 Subject: [PATCH 8/9] Increment 1 - Step 7: Docs finalization (#7) --- README.md | 84 ++++++++++++++++++++++++++++++++++----- docs/high_level_design.md | 39 +++++++++++++++++- 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 96fc081..d3e72fc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,13 @@ authentication sessions and business logic; a Qt 6 / QML desktop client for macO the API. The backend is designed to be containerized later and future web/mobile clients can target the same API. -**Stack:** C++23 · Qt 6.8 · QML · PostgreSQL 16 · CMake ≥ 3.28 · libpqxx · libsodium · Testing: Qt Test / Qt Quick Test (Client UI) +**Stack:** C++23 · Qt 6.8 · QML · PostgreSQL 16 · CMake ≥ 3.28 · libpqxx · libsodium · +Qt Test / Qt Quick Test — no source-level dependencies. + +**Status:** pre-release, under active development. Increment 1 (foundations: build system, +database, migrations, REST skeleton, client shell, test scaffolding) is wrapping up with +public-repo readiness (license, CI); next up is authentication & RBAC. See the [Roadmap](#roadmap) and the +[Implementation log](#implementation-log). The project maximizes Qt framework usage — Qt is used everywhere unless it is clearly costly and an alternative is much more efficient: QJson wire format, `Q_GADGET` DTOs readable @@ -24,6 +30,35 @@ keeping the future container's migration entrypoint minimal. > Developed incrementally, one reviewed step at a time. This README grows with each step — > see [Repository layout](#repository-layout) for what exists today. +**Contents:** [Architecture](#architecture) · [Prerequisites](#prerequisites) · +[Building](#building) · [Running the stack](#running-the-stack) · +[Development database](#development-database) · [Testing](#testing) · +[Code style](#code-style) · [Development workflow](#development-workflow) · +[Repository layout](#repository-layout) · [Roadmap](#roadmap) · +[Implementation log](#implementation-log) + +## Architecture + +Diagrams (components, library dependency graph, runtime flows, test layout) live in +[`docs/high_level_design.md`](docs/high_level_design.md) — rendered natively by GitHub. +The key structural decisions: + +- **Per-module static libraries.** Every server-side concern (`config`, `db`, `http`, soon + `auth`, `transactions`, …) is its own static library under `server/modules//` with + public headers in `include/modulo/server//`, implementation in `src/`, and its own + `tests/`. Shared code lives in `libs/core` (foundations) and `libs/api` (DTOs used verbatim + by server and client, so both sides agree on the wire format). +- **Errors as values.** `core::Result` (`std::expected`) carries a stable + dotted error code (`config.invalid_port`, `http.bind_failed`, `api.invalid_field`) that + tests and clients match on; exceptions are reserved for genuinely exceptional paths. +- **One error envelope.** Every endpoint answers failures with + `{"error":{"code":"…","message":"…"}}` and the matching HTTP status. +- **Validated wire data.** DTOs are `Q_GADGET` structs with `toJson()` / `fromJson()`; parsing + goes through `api::json::require*`, which rejects missing or mistyped fields instead of + accepting QJson's silent defaults. +- **Loopback-only server.** The API binds to `127.0.0.1`; production exposure will go + through a reverse proxy when the backend is containerized. + ## Prerequisites One-time setup on macOS (Apple Silicon): @@ -35,11 +70,12 @@ brew install cmake ninja llvm libpqxx libsodium qt - **Qt 6.8+** is expected at `/opt/homebrew/opt/qt` (the CMake presets bake this path in). - **llvm** provides `clang-format`/`clang-tidy`; it is keg-only, so scripts and CMake reference `/opt/homebrew/opt/llvm/bin` by absolute path. -- **Docker Desktop** must be running for the development database. +- **Docker** (Docker Desktop or any `docker compose` v2) must be running for the + development database. - The local Homebrew PostgreSQL (if any) can run in parallel - the dockerized database uses port **5433** precisely to avoid clashing with a local server on 5432. -Two quirks of this machine are compensated for in the build (no action needed): +Two macOS/Homebrew quirks are compensated for in the build (no action needed): - The newest macOS SDK no longer ships the legacy `AGL` framework, but Qt's OpenGL CMake wrapper unconditionally links it - the `dev` presets pin `WrapOpenGL_AGL` to the stub in @@ -73,16 +109,16 @@ cmake --build --preset dev # build Build directories are generated in `build//`. All dependencies are Homebrew binary libraries — nothing is downloaded at configure time. -All build logic can be found in `modulo_*` functions under [`cmake/`](cmake/) module - -`modulo_add_library`, `modulo_add_executable`, `modulo_add_test`, `modulo_add_qml_test`. -Thus, `CMakeLists.txt` becomes a short declarative call. Each server-side module is its -own static library with public headers in `include/modulo/...` and implementation in -`src/`. +All build logic lives in `modulo_*` functions under [`cmake/`](cmake/) — +`modulo_add_library`, `modulo_add_executable`, `modulo_add_qml_app`, `modulo_add_test`, +`modulo_add_qml_test` — so every `CMakeLists.txt` is a short declarative call. The toolkit +applies C++23, the warning set, sanitizer/clang-tidy hooks, version injection, and +`qt.conf` generation uniformly, and auto-discovers each target's `tests/` directory. ## Running the stack ```sh -scripts/db-up.sh # 1. database (not needed by health yet) +scripts/db-up.sh # 1. database (the health endpoint does not need it yet) ./build/dev/server/app/modulo_server # 2. REST API on http://127.0.0.1:8080 ./build/dev/client/modulo_client # 3. desktop client (separate terminal) ``` @@ -181,14 +217,25 @@ scripts/format.sh # format all sources in place scripts/format.sh --check # verify only (CI mode) ``` +## Development workflow + +Work is organized in **increments** (a coherent feature area) made of small **steps**: + +- one branch per step (`increment-N-step-M`), one pull request per step into the + increment branch, **squash-merged** so each step is exactly one commit; +- the increment branch merges into `main` with a merge commit, preserving the per-step + history, and is tagged `v0..0` (matching the CMake project version); +- every step ships with its README update (see the [Implementation log](#implementation-log)) + and must pass a clean `-Werror` build, `scripts/format.sh --check`, and `ctest --preset all`. + ## Repository layout ``` cmake/ CMake toolkit: all build logic as modulo_* functions db/migrations/ append-only SQL schema migrations (NNNN_name.sql) -docs/ high_level_design.md (Architecture diagrams) +docs/ high_level_design.md (Mermaid architecture diagrams) docker/ docker-compose.yml (Postgres 16 on :5433) + one-time initdb scripts -libs/core/ modulo_core — Qt-free foundations (version, Result on std::expected) +libs/core/ modulo_core — foundations: version(), Result (std::expected + QString error codes) libs/api/ modulo_api — Q_GADGET DTOs + validating QJson mappings shared by server and client scripts/ db-up.sh, db-down.sh, migrate.sh, format.sh tests/support/ shared test fixtures () for integration tests @@ -204,6 +251,20 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release .env.example environment template (DB URLs, HTTP port, data dir) ``` +## Roadmap + +| Increment | Scope | +|---|---| +| 1 — Foundations (in progress, final step) | Build system, dockerized Postgres, migrations, REST skeleton with health endpoint, client shell, test scaffolding, public-repo readiness | +| 2 — Auth & RBAC | Users/roles/sessions schema, Argon2id password hashing (libsodium), opaque bearer tokens, `authed()` / `requireRole()` guards, login flow + dark theme system in the client | +| 3 — Transactions | BUY/SELL/SWAP records with server-side filtering & pagination; add/edit/delete dialog with price-per-unit ⇄ total-value derivation | +| 4 — Transfers | Bank ⇄ exchange IN/OUT transfers; shared bank-account / exchange reference data | +| 5 — Holdings & dashboards | Per-asset aggregation (amount, median buy/sell, net profit, portfolio share, value in USD/EUR) and the first Qt Charts dashboards | +| 6 — Exchange rates | Daily USD/EUR, crypto and stock prices (Frankfurter, CoinGecko, Twelve Data) with manual refresh | +| 7 — Documents | Upload, link and preview exchange/bank documents | +| 8 — Theming & UX | Full ultrasound.money-inspired design system; empty/loading/error states everywhere | +| 9 — Deployment | Containerized backend (multi-stage Linux image, compose production profile, TLS via reverse proxy) | + ## Implementation log | Increment / step | Delivered | @@ -218,3 +279,4 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release | 1.5c — Cleanup | Further code & comments cleanup; revisioned documentation | | 1.6 — Test scaffolding | One passing suite per layer: core, api (DTO + `require*` rejection paths), config, in-process HTTP integration (opt-in via `MODULO_TEST_DB_URL`, Skipped otherwise), QML smoke (offscreen); toolkit auto-discovers `tests/` dirs | | 1.6b — Qt Test everywhere | Decision: Qt Test replaces Catch2 (one framework for C++ and QML); Catch2 + CPM removed — the project now has zero source-level dependencies | +| 1.7 — Docs finalization | README restructured for a public audience (status, contents, architecture, workflow, roadmap); HLD gained the test-architecture view; local working agreement (CLAUDE.md) refreshed | diff --git a/docs/high_level_design.md b/docs/high_level_design.md index 59f2334..d08e8d6 100644 --- a/docs/high_level_design.md +++ b/docs/high_level_design.md @@ -91,7 +91,7 @@ created by the `modulo_*` CMake toolkit functions (warnings, sanitizers, clang-t injection, qt.conf generation applied uniformly). Coming next: `modulo_server_auth` (Increment 2), then transactions / transfers / holdings / rates / documents as sibling modules. -## 3. Runtime flow — health check (the pipe proven in Step 5) +## 3. Runtime flow — health check ```mermaid sequenceDiagram @@ -134,4 +134,39 @@ sequenceDiagram end end M-->>U: "N applied, M skipped" (exit code) -``` \ No newline at end of file +``` + +## 5. Test architecture + +```mermaid +flowchart LR + subgraph unit["label: unit — Qt Test, no Docker"] + t1["modulo_core_tests"] + t2["modulo_api_health_dto_tests +modulo_api_error_dto_tests +modulo_api_json_tests"] + t3["modulo_server_config_tests"] + end + subgraph integ["label: integration — opt-in"] + t4["modulo_integration_tests +in-process QHttpServer on port 0 ++ QNetworkAccessManager client"] + end + subgraph ui["label: ui — Qt Quick Test, offscreen"] + t5["modulo_client_qml_tests +tst_*.qml via QUICK_TEST_MAIN"] + end + + env["MODULO_TEST_DB_URL"] -. "unset → QSKIP → CTest Skipped" .-> t4 + support["tests/support/include/modulo/testing/ +integration.h: MODULO_REQUIRE_TEST_DATABASE(), httpGet()"] --> t4 + + presets["ctest --preset unit | integration | ui | all"] --> unit + presets --> integ + presets --> ui +``` + +Conventions: one `QObject` test class per binary (`QTEST_GUILESS_MAIN`), data-driven rows via +`_data()` slots; each target's `tests/` directory is auto-discovered by the CMake toolkit, which +links `Qt6::Test`, adds the shared support include dir, and maps Qt Test's `SKIP :` output to +CTest's *Skipped* status. Cross-module integration tests live only in `server/tests/integration/`. From 9f1f29f9bca0d257af977bf8d85a2a5213afe31c Mon Sep 17 00:00:00 2001 From: Angelo Barbu <77395130+angelobarbu@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:07:02 +0300 Subject: [PATCH 9/9] Increment 1 - Step 8: Public Repository Readiness (#8) * Increment 1 - Step 8: Public Repository Readiness * Increment 1 - Step 8: Fixed libpq dependencyh --- .github/workflows/ci.yml | 54 ++++++++++++++++++++++++++ CMakePresets.json | 82 +++++++++++++++++++++++++++++++++------- LICENSE | 21 ++++++++++ README.md | 21 +++++++++- 4 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..33efdbf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +# Continuous integration: configure + build (-Werror), formatting check, and the +# unit + UI test suites on a macOS runner (the project's primary platform). +# +# Integration tests need PostgreSQL, which macOS runners cannot provide via +# Docker; they are opt-in (MODULO_TEST_DB_URL) and therefore report as Skipped +# here. A Linux job with a Postgres service container arrives with the +# containerized backend (Increment 9). + +name: CI + +on: + push: + branches: [main, "increment-*"] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & test (macOS, Apple Silicon) + runs-on: macos-15 + timeout-minutes: 45 + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Install dependencies + # clang-format (small formula) instead of the full llvm keg; scripts/format.sh + # picks it up through CLANG_FORMAT. + run: brew install ninja qt libpq libpqxx libsodium clang-format + + - name: Show toolchain versions + run: | + cmake --version | head -1 + ninja --version + brew list --versions qt libpqxx libsodium clang-format + "$(brew --prefix clang-format)/bin/clang-format" --version + + - name: Configure + run: cmake --preset ci + + - name: Build + run: cmake --build --preset ci + + - name: Check formatting + env: + CLANG_FORMAT: /opt/homebrew/opt/clang-format/bin/clang-format + run: scripts/format.sh --check + + - name: Run tests (unit + ui; integration skips without a database) + run: ctest --preset ci diff --git a/CMakePresets.json b/CMakePresets.json index 28203e7..44d33ca 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -1,6 +1,10 @@ { "version": 6, - "cmakeMinimumRequired": { "major": 3, "minor": 28, "patch": 0 }, + "cmakeMinimumRequired": { + "major": 3, + "minor": 28, + "patch": 0 + }, "configurePresets": [ { "name": "base", @@ -10,7 +14,8 @@ "cacheVariables": { "CMAKE_PREFIX_PATH": "/opt/homebrew/opt/qt", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", - "WrapOpenGL_AGL": "/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk/System/Library/Frameworks/AGL.framework" + "WrapOpenGL_AGL": "/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk/System/Library/Frameworks/AGL.framework", + "PostgreSQL_ROOT": "/opt/homebrew/opt/libpq" } }, { @@ -45,37 +50,88 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" } + }, + { + "name": "ci", + "displayName": "Continuous integration (GitHub Actions macOS runner)", + "inherits": "dev", + "cacheVariables": { + "WrapOpenGL_AGL": "" + } } ], "buildPresets": [ - { "name": "dev", "configurePreset": "dev" }, - { "name": "dev-asan", "configurePreset": "dev-asan" }, - { "name": "dev-tidy", "configurePreset": "dev-tidy" }, - { "name": "release", "configurePreset": "release" } + { + "name": "dev", + "configurePreset": "dev" + }, + { + "name": "dev-asan", + "configurePreset": "dev-asan" + }, + { + "name": "dev-tidy", + "configurePreset": "dev-tidy" + }, + { + "name": "release", + "configurePreset": "release" + }, + { + "name": "ci", + "configurePreset": "ci" + } ], "testPresets": [ { "name": "unit", "configurePreset": "dev", - "filter": { "include": { "label": "^unit$" } }, - "output": { "outputOnFailure": true } + "filter": { + "include": { + "label": "^unit$" + } + }, + "output": { + "outputOnFailure": true + } }, { "name": "integration", "configurePreset": "dev", - "filter": { "include": { "label": "^integration$" } }, - "output": { "outputOnFailure": true } + "filter": { + "include": { + "label": "^integration$" + } + }, + "output": { + "outputOnFailure": true + } }, { "name": "ui", "configurePreset": "dev", - "filter": { "include": { "label": "^ui$" } }, - "output": { "outputOnFailure": true } + "filter": { + "include": { + "label": "^ui$" + } + }, + "output": { + "outputOnFailure": true + } }, { "name": "all", "configurePreset": "dev", - "output": { "outputOnFailure": true } + "output": { + "outputOnFailure": true + } + }, + { + "name": "ci", + "configurePreset": "ci", + "output": { + "outputOnFailure": true + } } ] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a3e8614 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Angelo Barbu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d3e72fc..4fee2bd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Modulo +[![CI](https://github.com/angelobarbu/Modulo/actions/workflows/ci.yml/badge.svg)](https://github.com/angelobarbu/Modulo/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-00ffa3.svg)](LICENSE) + A personal investment tracker for crypto and stock assets — transactions, bank↔exchange transfers, aggregated holdings with dashboards, uploaded documents, and daily exchange-rate updates. @@ -35,7 +38,7 @@ keeping the future container's migration entrypoint minimal. [Development database](#development-database) · [Testing](#testing) · [Code style](#code-style) · [Development workflow](#development-workflow) · [Repository layout](#repository-layout) · [Roadmap](#roadmap) · -[Implementation log](#implementation-log) +[Implementation log](#implementation-log) · [License](#license) ## Architecture @@ -70,6 +73,9 @@ brew install cmake ninja llvm libpqxx libsodium qt - **Qt 6.8+** is expected at `/opt/homebrew/opt/qt` (the CMake presets bake this path in). - **llvm** provides `clang-format`/`clang-tidy`; it is keg-only, so scripts and CMake reference `/opt/homebrew/opt/llvm/bin` by absolute path. +- **libpqxx** pulls in the keg-only `libpq`; the presets point CMake at it + (`PostgreSQL_ROOT=/opt/homebrew/opt/libpq`) so the build never depends on a stray + local PostgreSQL installation. - **Docker** (Docker Desktop or any `docker compose` v2) must be running for the development database. - The local Homebrew PostgreSQL (if any) can run in parallel - the dockerized database uses @@ -105,6 +111,7 @@ cmake --build --preset dev # build | `dev-asan` | `dev` + address & undefined-behavior sanitizers | | `dev-tidy` | `dev` + clang-tidy on every compile | | `release` | RelWithDebInfo | +| `ci` | `dev` without the local AGL SDK pin — used by GitHub Actions | Build directories are generated in `build//`. All dependencies are Homebrew binary libraries — nothing is downloaded at configure time. @@ -228,6 +235,13 @@ Work is organized in **increments** (a coherent feature area) made of small **st - every step ships with its README update (see the [Implementation log](#implementation-log)) and must pass a clean `-Werror` build, `scripts/format.sh --check`, and `ctest --preset all`. +**Continuous integration** ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs on +every push to `main`/`increment-*` and on pull requests: a macOS (Apple Silicon) runner +installs the Homebrew dependencies, configures with the `ci` preset (identical to `dev` +minus the machine-specific AGL pin), builds with `-Werror`, checks formatting, and runs +the unit and UI suites. Integration tests report as *Skipped* in CI until a Linux job with +a PostgreSQL service container arrives alongside the containerized backend. + ## Repository layout ``` @@ -280,3 +294,8 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release | 1.6 — Test scaffolding | One passing suite per layer: core, api (DTO + `require*` rejection paths), config, in-process HTTP integration (opt-in via `MODULO_TEST_DB_URL`, Skipped otherwise), QML smoke (offscreen); toolkit auto-discovers `tests/` dirs | | 1.6b — Qt Test everywhere | Decision: Qt Test replaces Catch2 (one framework for C++ and QML); Catch2 + CPM removed — the project now has zero source-level dependencies | | 1.7 — Docs finalization | README restructured for a public audience (status, contents, architecture, workflow, roadmap); HLD gained the test-architecture view; local working agreement (CLAUDE.md) refreshed | +| 1.8 — Public-repo readiness | MIT `LICENSE`; GitHub Actions CI (macOS runner: brew deps, `ci` preset, `-Werror` build, format check, unit + ui tests); README badges + License section; repository made public and tagged `v0.1.0` | + +## License + +Modulo is released under the [MIT License](LICENSE).