diff --git a/CMakeLists.txt b/CMakeLists.txt index 2efffc8..7103d4c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1657a76 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "mono-cpp", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..da17795 --- /dev/null +++ b/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/http/CMakeLists.txt b/packages/http/CMakeLists.txt new file mode 100644 index 0000000..afcd299 --- /dev/null +++ b/packages/http/CMakeLists.txt @@ -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 +) diff --git a/packages/http/include/http/http.h b/packages/http/include/http/http.h new file mode 100644 index 0000000..3f4f764 --- /dev/null +++ b/packages/http/include/http/http.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +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 diff --git a/packages/http/src/http.cpp b/packages/http/src/http.cpp new file mode 100644 index 0000000..da848de --- /dev/null +++ b/packages/http/src/http.cpp @@ -0,0 +1,77 @@ +#include "http/http.h" + +#include + +namespace http { + +static size_t write_cb(void *contents, size_t size, size_t nmemb, void *userp) { + auto *out = static_cast(userp); + out->append(static_cast(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 diff --git a/packages/json/CMakeLists.txt b/packages/json/CMakeLists.txt new file mode 100644 index 0000000..e896bd6 --- /dev/null +++ b/packages/json/CMakeLists.txt @@ -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 +) diff --git a/packages/json/include/json/json.h b/packages/json/include/json/json.h new file mode 100644 index 0000000..30ce3e4 --- /dev/null +++ b/packages/json/include/json/json.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +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 keys(const std::string &json_str); + +} // namespace json diff --git a/packages/json/src/json.cpp b/packages/json/src/json.cpp new file mode 100644 index 0000000..422c1a7 --- /dev/null +++ b/packages/json/src/json.cpp @@ -0,0 +1,62 @@ +#include "json/json.h" + +#include +#include +#include + +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 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 keys(const std::string &json_str) { + std::vector 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 diff --git a/src/main.cpp b/src/main.cpp index d4cebe7..e08d5da 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,8 +5,11 @@ #include #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) + diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..6a61697 --- /dev/null +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/functional/test_cli.cpp b/tests/functional/test_cli.cpp new file mode 100644 index 0000000..1e7654b --- /dev/null +++ b/tests/functional/test_cli.cpp @@ -0,0 +1,72 @@ +#include +#include +#include + +#include + +#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 = + "" + "

Title

" + "
  • A
  • B
" + ""; + + // 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)); +} diff --git a/tests/unit/test_html.cpp b/tests/unit/test_html.cpp new file mode 100644 index 0000000..8263b73 --- /dev/null +++ b/tests/unit/test_html.cpp @@ -0,0 +1,63 @@ +#include + +#include "html/html.h" + +TEST_CASE("html::parse returns elements from valid HTML", "[html]") { + auto elements = + html::parse("

Title

Body

"); + + 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("
Nested
"); + + // 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("
  • A
  • B
  • C
", "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("

Hello

", "h1"); + REQUIRE(matches.empty()); +} + +TEST_CASE("html::select works with class selector", "[html][select]") { + auto matches = html::select( + R"(
XY
)", ".a"); + + REQUIRE(matches.size() == 1); + CHECK(matches[0] == "X"); +} diff --git a/tests/unit/test_http.cpp b/tests/unit/test_http.cpp new file mode 100644 index 0000000..3ef5e23 --- /dev/null +++ b/tests/unit/test_http.cpp @@ -0,0 +1,17 @@ +#include + +#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()); +} diff --git a/tests/unit/test_json.cpp b/tests/unit/test_json.cpp new file mode 100644 index 0000000..1b72660 --- /dev/null +++ b/tests/unit/test_json.cpp @@ -0,0 +1,46 @@ +#include + +#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); +} diff --git a/tests/unit/test_logger.cpp b/tests/unit/test_logger.cpp new file mode 100644 index 0000000..b2e8f4d --- /dev/null +++ b/tests/unit/test_logger.cpp @@ -0,0 +1,22 @@ +#include + +#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")); +} diff --git a/tests/unit/test_postgres.cpp b/tests/unit/test_postgres.cpp new file mode 100644 index 0000000..348c51c --- /dev/null +++ b/tests/unit/test_postgres.cpp @@ -0,0 +1,14 @@ +#include + +#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"); +}