min boilerplate 1/2

This commit is contained in:
lovebird 2026-02-18 11:42:14 +01:00
parent e02c295dac
commit d383a4a543
17 changed files with 587 additions and 1 deletions

View File

@ -41,13 +41,15 @@ FetchContent_MakeAvailable(cli11 tomlplusplus Catch2)
add_subdirectory(packages/logger)
add_subdirectory(packages/html)
add_subdirectory(packages/postgres)
add_subdirectory(packages/http)
add_subdirectory(packages/json)
# Sources
add_executable(${PROJECT_NAME}
src/main.cpp
)
target_link_libraries(${PROJECT_NAME} PRIVATE CLI11::CLI11 tomlplusplus::tomlplusplus logger html postgres)
target_link_libraries(${PROJECT_NAME} PRIVATE CLI11::CLI11 tomlplusplus::tomlplusplus logger html postgres http json)
# Compiler warnings
if(MSVC)

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "mono-cpp",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "mono-cpp",
"version": "1.0.0",
"description": "Cross-platform C++ CLI built with CMake.",
"directories": {
"test": "tests"
},
"scripts": {
"config": "cmake --preset dev",
"config:release": "cmake --preset release",
"build": "cmake --build --preset dev",
"build:release": "cmake --build --preset release",
"test": "ctest --test-dir build/dev -C Debug --output-on-failure",
"test:release": "ctest --test-dir build/release -C Release --output-on-failure",
"clean": "cmake -E rm -rf build/dev",
"clean:release": "cmake -E rm -rf build/release",
"clean:all": "cmake -E rm -rf build",
"rebuild": "npm run clean && npm run config && npm run build",
"run": ".\\build\\dev\\Debug\\polymech-cli.exe"
},
"repository": {
"type": "git",
"url": "https://git.polymech.info/polymech/mono-cpp.git"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@ -0,0 +1,43 @@
include(FetchContent)
# Work around curl's old cmake_minimum_required for CMake 4.x
set(CMAKE_POLICY_VERSION_MINIMUM 3.5 CACHE STRING "" FORCE)
FetchContent_Declare(
CURL
URL https://github.com/curl/curl/releases/download/curl-8_12_1/curl-8.12.1.tar.xz
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)
# Minimal curl build static, SChannel TLS, no optional deps
set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
# TLS backend: Windows native SChannel
set(CURL_USE_OPENSSL OFF CACHE BOOL "" FORCE)
set(CURL_USE_SCHANNEL ON CACHE BOOL "" FORCE)
# Disable optional compression/protocol deps
set(CURL_ZLIB OFF CACHE BOOL "" FORCE)
set(CURL_BROTLI OFF CACHE BOOL "" FORCE)
set(CURL_ZSTD OFF CACHE BOOL "" FORCE)
set(USE_NGHTTP2 OFF CACHE BOOL "" FORCE)
set(CURL_USE_LIBSSH2 OFF CACHE BOOL "" FORCE)
set(CURL_USE_LIBPSL OFF CACHE BOOL "" FORCE)
set(CURL_DISABLE_LDAP ON CACHE BOOL "" FORCE)
set(CURL_DISABLE_LDAPS ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(CURL)
add_library(http STATIC
src/http.cpp
)
target_include_directories(http
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_link_libraries(http
PUBLIC CURL::libcurl
)

View File

@ -0,0 +1,19 @@
#pragma once
#include <string>
namespace http {
struct Response {
long status_code;
std::string body;
};
/// Perform an HTTP GET request. Returns the response body and status code.
Response get(const std::string &url);
/// Perform an HTTP POST request with a body. Returns the response and status.
Response post(const std::string &url, const std::string &body,
const std::string &content_type = "application/json");
} // namespace http

View File

@ -0,0 +1,77 @@
#include "http/http.h"
#include <curl/curl.h>
namespace http {
static size_t write_cb(void *contents, size_t size, size_t nmemb, void *userp) {
auto *out = static_cast<std::string *>(userp);
out->append(static_cast<char *>(contents), size * nmemb);
return size * nmemb;
}
Response get(const std::string &url) {
Response resp{};
CURL *curl = curl_easy_init();
if (!curl) {
resp.status_code = -1;
resp.body = "curl_easy_init failed";
return resp;
}
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
CURLcode res = curl_easy_perform(curl);
if (res != CURLE_OK) {
resp.status_code = -1;
resp.body = curl_easy_strerror(res);
} else {
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code);
}
curl_easy_cleanup(curl);
return resp;
}
Response post(const std::string &url, const std::string &body,
const std::string &content_type) {
Response resp{};
CURL *curl = curl_easy_init();
if (!curl) {
resp.status_code = -1;
resp.body = "curl_easy_init failed";
return resp;
}
struct curl_slist *headers = nullptr;
headers =
curl_slist_append(headers, ("Content-Type: " + content_type).c_str());
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
CURLcode res = curl_easy_perform(curl);
if (res != CURLE_OK) {
resp.status_code = -1;
resp.body = curl_easy_strerror(res);
} else {
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code);
}
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
return resp;
}
} // namespace http

View File

@ -0,0 +1,28 @@
include(FetchContent)
# RapidJSON use master for CMake 4.x compatibility (v1.1.0 is from 2016)
FetchContent_Declare(
rapidjson
GIT_REPOSITORY https://github.com/Tencent/rapidjson.git
GIT_TAG master
GIT_SHALLOW TRUE
)
set(RAPIDJSON_BUILD_DOC OFF CACHE BOOL "" FORCE)
set(RAPIDJSON_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(RAPIDJSON_BUILD_TESTS OFF CACHE BOOL "" FORCE)
FetchContent_GetProperties(rapidjson)
if(NOT rapidjson_POPULATED)
FetchContent_Populate(rapidjson)
# Don't add_subdirectory just use the headers
endif()
add_library(json STATIC
src/json.cpp
)
target_include_directories(json
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
PUBLIC ${rapidjson_SOURCE_DIR}/include
)

View File

@ -0,0 +1,23 @@
#pragma once
#include <string>
#include <vector>
namespace json {
/// Parse a JSON string and return a pretty-printed version.
std::string prettify(const std::string &json_str);
/// Extract a string value by key from a JSON object (top-level only).
std::string get_string(const std::string &json_str, const std::string &key);
/// Extract an int value by key from a JSON object (top-level only).
int get_int(const std::string &json_str, const std::string &key);
/// Check if a JSON string is valid.
bool is_valid(const std::string &json_str);
/// Get all top-level keys from a JSON object.
std::vector<std::string> keys(const std::string &json_str);
} // namespace json

View File

@ -0,0 +1,62 @@
#include "json/json.h"
#include <rapidjson/document.h>
#include <rapidjson/prettywriter.h>
#include <rapidjson/stringbuffer.h>
namespace json {
std::string prettify(const std::string &json_str) {
rapidjson::Document doc;
doc.Parse(json_str.c_str());
if (doc.HasParseError()) {
return {};
}
rapidjson::StringBuffer buffer;
rapidjson::PrettyWriter<rapidjson::StringBuffer> writer(buffer);
doc.Accept(writer);
return std::string(buffer.GetString(), buffer.GetSize());
}
std::string get_string(const std::string &json_str, const std::string &key) {
rapidjson::Document doc;
doc.Parse(json_str.c_str());
if (doc.HasParseError() || !doc.IsObject())
return {};
auto it = doc.FindMember(key.c_str());
if (it == doc.MemberEnd() || !it->value.IsString())
return {};
return std::string(it->value.GetString(), it->value.GetStringLength());
}
int get_int(const std::string &json_str, const std::string &key) {
rapidjson::Document doc;
doc.Parse(json_str.c_str());
if (doc.HasParseError() || !doc.IsObject())
return 0;
auto it = doc.FindMember(key.c_str());
if (it == doc.MemberEnd() || !it->value.IsInt())
return 0;
return it->value.GetInt();
}
bool is_valid(const std::string &json_str) {
rapidjson::Document doc;
doc.Parse(json_str.c_str());
return !doc.HasParseError();
}
std::vector<std::string> keys(const std::string &json_str) {
std::vector<std::string> result;
rapidjson::Document doc;
doc.Parse(json_str.c_str());
if (doc.HasParseError() || !doc.IsObject())
return result;
for (auto it = doc.MemberBegin(); it != doc.MemberEnd(); ++it) {
result.emplace_back(it->name.GetString(), it->name.GetStringLength());
}
return result;
}
} // namespace json

View File

@ -5,8 +5,11 @@
#include <toml++/toml.hpp>
#include "html/html.h"
#include "http/http.h"
#include "logger/logger.h"
#include "postgres/postgres.h"
#include "json/json.h"
#ifndef PROJECT_VERSION
#define PROJECT_VERSION "0.1.0"
@ -35,6 +38,17 @@ int main(int argc, char *argv[]) {
app.add_subcommand("config", "Read and display a TOML config file");
config_cmd->add_option("file", config_path, "Path to TOML file")->required();
// Subcommand: fetch — HTTP GET a URL
std::string fetch_url;
auto *fetch_cmd =
app.add_subcommand("fetch", "HTTP GET a URL and print the response");
fetch_cmd->add_option("url", fetch_url, "URL to fetch")->required();
// Subcommand: json — prettify JSON
std::string json_input;
auto *json_cmd = app.add_subcommand("json", "Prettify a JSON string");
json_cmd->add_option("input", json_input, "JSON string")->required();
CLI11_PARSE(app, argc, argv);
logger::init("polymech-cli");
@ -69,6 +83,27 @@ int main(int argc, char *argv[]) {
return 0;
}
if (fetch_cmd->parsed()) {
auto resp = http::get(fetch_url);
logger::info("HTTP " + std::to_string(resp.status_code) + " from " +
fetch_url);
if (json::is_valid(resp.body)) {
std::cout << json::prettify(resp.body) << "\n";
} else {
std::cout << resp.body << "\n";
}
return 0;
}
if (json_cmd->parsed()) {
if (!json::is_valid(json_input)) {
logger::error("Invalid JSON input");
return 1;
}
std::cout << json::prettify(json_input) << "\n";
return 0;
}
// Default: demo
auto status = postgres::connect("postgresql://localhost:5432/polymech");
logger::info("polymech-cli " + std::string(PROJECT_VERSION) +

29
tests/CMakeLists.txt Normal file
View File

@ -0,0 +1,29 @@
# Test targets
include(CTest)
include(Catch)
# Unit tests one per package
add_executable(test_logger unit/test_logger.cpp)
target_link_libraries(test_logger PRIVATE Catch2::Catch2WithMain logger)
catch_discover_tests(test_logger)
add_executable(test_html unit/test_html.cpp)
target_link_libraries(test_html PRIVATE Catch2::Catch2WithMain html)
catch_discover_tests(test_html)
add_executable(test_postgres unit/test_postgres.cpp)
target_link_libraries(test_postgres PRIVATE Catch2::Catch2WithMain postgres)
catch_discover_tests(test_postgres)
add_executable(test_json unit/test_json.cpp)
target_link_libraries(test_json PRIVATE Catch2::Catch2WithMain json)
catch_discover_tests(test_json)
add_executable(test_http unit/test_http.cpp)
target_link_libraries(test_http PRIVATE Catch2::Catch2WithMain http)
catch_discover_tests(test_http)
# Functional test end-to-end CLI
add_executable(test_functional functional/test_cli.cpp)
target_link_libraries(test_functional PRIVATE Catch2::Catch2WithMain CLI11::CLI11 tomlplusplus::tomlplusplus logger html postgres http json)
catch_discover_tests(test_functional)

View File

@ -0,0 +1,72 @@
#include <catch2/catch_test_macros.hpp>
#include <fstream>
#include <sstream>
#include <toml++/toml.hpp>
#include "html/html.h"
#include "logger/logger.h"
#include "postgres/postgres.h"
// ── Functional: full pipeline tests ─────────────────────────────────────────
TEST_CASE("Full pipeline: parse HTML and select", "[functional]") {
const std::string input =
"<html><body>"
"<h1>Title</h1>"
"<ul><li class=\"item\">A</li><li class=\"item\">B</li></ul>"
"</body></html>";
// Parse should find elements
auto elements = html::parse(input);
REQUIRE(!elements.empty());
// Select by class should find 2 items
auto items = html::select(input, ".item");
REQUIRE(items.size() == 2);
CHECK(items[0] == "A");
CHECK(items[1] == "B");
}
TEST_CASE("Full pipeline: TOML config round-trip", "[functional]") {
// Write a temp TOML file
const std::string toml_content = "[server]\n"
"host = \"localhost\"\n"
"port = 8080\n"
"\n"
"[database]\n"
"name = \"test_db\"\n";
std::string tmp_path = "test_config_tmp.toml";
{
std::ofstream out(tmp_path);
REQUIRE(out.is_open());
out << toml_content;
}
// Parse it
auto tbl = toml::parse_file(tmp_path);
CHECK(tbl["server"]["host"].value_or("") == std::string("localhost"));
CHECK(tbl["server"]["port"].value_or(0) == 8080);
CHECK(tbl["database"]["name"].value_or("") == std::string("test_db"));
// Serialize back
std::ostringstream ss;
ss << tbl;
auto serialized = ss.str();
CHECK(serialized.find("localhost") != std::string::npos);
// Cleanup
std::remove(tmp_path.c_str());
}
TEST_CASE("Full pipeline: logger + postgres integration", "[functional]") {
REQUIRE_NOTHROW(logger::init("functional-test"));
auto status = postgres::connect("postgresql://localhost:5432/functional");
REQUIRE(status == "ok");
REQUIRE_NOTHROW(
logger::info("Functional test passed with pg status: " + status));
}

63
tests/unit/test_html.cpp Normal file
View File

@ -0,0 +1,63 @@
#include <catch2/catch_test_macros.hpp>
#include "html/html.h"
TEST_CASE("html::parse returns elements from valid HTML", "[html]") {
auto elements =
html::parse("<html><body><h1>Title</h1><p>Body</p></body></html>");
REQUIRE(elements.size() >= 2);
bool found_h1 = false;
bool found_p = false;
for (const auto &el : elements) {
if (el.tag == "h1" && el.text == "Title")
found_h1 = true;
if (el.tag == "p" && el.text == "Body")
found_p = true;
}
CHECK(found_h1);
CHECK(found_p);
}
TEST_CASE("html::parse returns empty for empty input", "[html]") {
auto elements = html::parse("");
// Empty or minimal — parser may produce an empty body
REQUIRE(elements.empty());
}
TEST_CASE("html::parse handles nested elements", "[html]") {
auto elements = html::parse("<div><span>Nested</span></div>");
// Parent nodes (body, div) also get text "Nested" via node_text.
// Just verify that the span element is present among the results.
bool found_span = false;
for (const auto &el : elements) {
if (el.tag == "span" && el.text == "Nested") {
found_span = true;
}
}
CHECK(found_span);
}
TEST_CASE("html::select finds elements by CSS selector", "[html][select]") {
auto matches = html::select("<ul><li>A</li><li>B</li><li>C</li></ul>", "li");
REQUIRE(matches.size() == 3);
CHECK(matches[0] == "A");
CHECK(matches[1] == "B");
CHECK(matches[2] == "C");
}
TEST_CASE("html::select returns empty for no matches", "[html][select]") {
auto matches = html::select("<p>Hello</p>", "h1");
REQUIRE(matches.empty());
}
TEST_CASE("html::select works with class selector", "[html][select]") {
auto matches = html::select(
R"(<div><span class="a">X</span><span class="b">Y</span></div>)", ".a");
REQUIRE(matches.size() == 1);
CHECK(matches[0] == "X");
}

17
tests/unit/test_http.cpp Normal file
View File

@ -0,0 +1,17 @@
#include <catch2/catch_test_macros.hpp>
#include "http/http.h"
TEST_CASE("http::get returns a response", "[http]") {
// This test requires network, so we test error handling for invalid URL
auto resp = http::get("http://0.0.0.0:1/nonexistent");
// Should fail gracefully with status -1
CHECK(resp.status_code == -1);
CHECK(!resp.body.empty());
}
TEST_CASE("http::post returns a response", "[http]") {
auto resp = http::post("http://0.0.0.0:1/nonexistent", R"({"test": true})");
CHECK(resp.status_code == -1);
CHECK(!resp.body.empty());
}

46
tests/unit/test_json.cpp Normal file
View File

@ -0,0 +1,46 @@
#include <catch2/catch_test_macros.hpp>
#include "json/json.h"
TEST_CASE("json::is_valid accepts valid JSON", "[json]") {
CHECK(json::is_valid(R"({"key": "value"})"));
CHECK(json::is_valid("[]"));
CHECK(json::is_valid("123"));
CHECK(json::is_valid("\"hello\""));
}
TEST_CASE("json::is_valid rejects invalid JSON", "[json]") {
CHECK_FALSE(json::is_valid("{invalid}"));
CHECK_FALSE(json::is_valid("{key: value}"));
}
TEST_CASE("json::get_string extracts string values", "[json]") {
auto val =
json::get_string(R"({"name": "polymech", "version": "1.0"})", "name");
CHECK(val == "polymech");
}
TEST_CASE("json::get_string returns empty for missing key", "[json]") {
auto val = json::get_string(R"({"name": "polymech"})", "missing");
CHECK(val.empty());
}
TEST_CASE("json::get_int extracts int values", "[json]") {
auto val = json::get_int(R"({"port": 8080, "name": "test"})", "port");
CHECK(val == 8080);
}
TEST_CASE("json::keys lists top-level keys", "[json]") {
auto k = json::keys(R"({"a": 1, "b": 2, "c": 3})");
REQUIRE(k.size() == 3);
CHECK(k[0] == "a");
CHECK(k[1] == "b");
CHECK(k[2] == "c");
}
TEST_CASE("json::prettify formats JSON", "[json]") {
auto pretty = json::prettify(R"({"a":1})");
REQUIRE(!pretty.empty());
// Pretty output should contain newlines
CHECK(pretty.find('\n') != std::string::npos);
}

View File

@ -0,0 +1,22 @@
#include <catch2/catch_test_macros.hpp>
#include "logger/logger.h"
TEST_CASE("logger::init does not throw", "[logger]") {
REQUIRE_NOTHROW(logger::init("test"));
}
TEST_CASE("logger functions do not throw after init", "[logger]") {
logger::init("test");
REQUIRE_NOTHROW(logger::info("info message"));
REQUIRE_NOTHROW(logger::warn("warn message"));
REQUIRE_NOTHROW(logger::error("error message"));
REQUIRE_NOTHROW(logger::debug("debug message"));
}
TEST_CASE("logger::init can be called multiple times", "[logger]") {
REQUIRE_NOTHROW(logger::init("first"));
REQUIRE_NOTHROW(logger::init("second"));
REQUIRE_NOTHROW(logger::info("after re-init"));
}

View File

@ -0,0 +1,14 @@
#include <catch2/catch_test_macros.hpp>
#include "postgres/postgres.h"
TEST_CASE("postgres::connect returns ok for any input (stub)", "[postgres]") {
auto result = postgres::connect("postgresql://localhost:5432/test");
CHECK(result == "ok");
}
TEST_CASE("postgres::connect accepts different connection strings",
"[postgres]") {
CHECK(postgres::connect("postgresql://user:pass@host:5432/db") == "ok");
CHECK(postgres::connect("") == "ok");
}