diff --git a/.gitignore b/.gitignore index 0fea79a..36f66fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build directories -build/ +build*/ +coverage*/ cmake-build-*/ out/ gen_cpp_units/target/ diff --git a/CMakeLists.txt b/CMakeLists.txt index a788665..db77510 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,7 @@ set(TEST_FFI_SOURCES tests/test_dimension_safety.cpp tests/test_precision.cpp tests/test_serialization.cpp + tests/test_formatting.cpp ) add_executable(test_ffi ${TEST_FFI_SOURCES}) diff --git a/include/qtty/ffi_core.hpp b/include/qtty/ffi_core.hpp index c25daaa..aa25299 100644 --- a/include/qtty/ffi_core.hpp +++ b/include/qtty/ffi_core.hpp @@ -9,8 +9,10 @@ */ #include +#include #include #include +#include #include #include #include @@ -316,13 +318,80 @@ template class Quantity { Quantity operator-() const { return Quantity(-m_value); } Quantity abs() const { return Quantity(std::abs(m_value)); } + + // ======================================================================== + // String Formatting + // ======================================================================== + // Format the quantity as a human-readable string, mirroring Rust's format + // annotations. The mapping is: + // + // Rust C++ + // {} format() + // {:.2} format(2) + // {:e} format(-1, QTTY_FMT_LOWER_EXP) + // {:.4e} format(4, QTTY_FMT_LOWER_EXP) + // {:E} format(-1, QTTY_FMT_UPPER_EXP) + // {:.4E} format(4, QTTY_FMT_UPPER_EXP) + // + // The formatting logic lives in the Rust qtty-ffi library, so precision + // semantics are identical on both sides of the FFI boundary. + + /** + * @brief Format this quantity as a string. + * + * Delegates to the Rust qtty-ffi `qtty_quantity_format` function so that + * C++ and Rust produce identical output for the same parameters. + * + * @param precision Digits after the decimal point. Pass a negative value + * (default) for the shortest exact representation. + * @param flags Notation selector: + * - `QTTY_FMT_DEFAULT` (0): decimal (e.g. `"1234.57 m"`) + * - `QTTY_FMT_LOWER_EXP` (1): scientific lower-case `e` + * - `QTTY_FMT_UPPER_EXP` (2): scientific upper-case `E` + * @return Formatted string, e.g. `"1234.57 m"` or `"1.23e3 m"`. + * @throws QttyException on formatting failure. + */ + std::string format(int precision = -1, + uint32_t flags = QTTY_FMT_DEFAULT) const { + qtty_quantity_t qty; + int32_t make_status = qtty_quantity_make(m_value, unit_id(), &qty); + check_status(make_status, "format: creating quantity"); + + char buf[512]; + int32_t result = + qtty_quantity_format(qty, precision, flags, buf, sizeof(buf)); + if (result == QTTY_ERR_BUFFER_TOO_SMALL) { + // Retry with a generous large buffer (quantities should never need this) + char big_buf[4096]; + result = + qtty_quantity_format(qty, precision, flags, big_buf, sizeof(big_buf)); + if (result < 0) { + throw QttyException("format: buffer too small even at 4096 bytes"); + } + return std::string(big_buf); + } + if (result < 0) { + check_status(result, "format: formatting quantity"); + } + return std::string(buf); + } }; // ============================================================================ // Stream Insertion Operator // ============================================================================ -// Prints a quantity's value (with unit symbol support for units that define -// it). +// Prints a quantity with its unit symbol, e.g., "1500 m" or "42.5 km". +// +// Because this streams `q.value()` (a plain double) directly into the +// `std::ostream`, all standard stream format manipulators are respected: +// +// std::cout << std::fixed << std::setprecision(2) << qty; // "1234.57 m" +// std::cout << std::scientific << qty; // "1.23457e+003 +// m" std::cout << std::scientific << std::setprecision(4) +// << qty; // "1.2346e+003 +// m" +// +// For `std::format` (C++20) see the std::formatter specialisation below. template std::ostream &operator<<(std::ostream &os, const Quantity &q) { @@ -331,3 +400,48 @@ std::ostream &operator<<(std::ostream &os, const Quantity &q) { } } // namespace qtty + +// ============================================================================ +// C++20 std::formatter specialisation +// ============================================================================ +// Allows `std::format` and `std::print` to be used with any Quantity type, +// honouring the same format specifiers as std::formatter: +// +// std::format("{}", qty) → "1234.56789 s" +// std::format("{:.2f}", qty) → "1234.57 s" +// std::format("{:e}", qty) → "1.23457e+03 s" +// std::format("{:.4e}", qty) → "1.2346e+03 s" +// std::format("{:E}", qty) → "1.23457E+03 s" +// std::format("{:>15.2f}", qty) → " 1234.57 s" (number padded, not +// symbol) +// +// Note: width / fill / align specifications are applied to the numeric part +// only; the unit symbol is always appended directly after without padding. +// This mirrors the behaviour of the Rust Display/LowerExp/UpperExp impls. + +#if __cplusplus >= 202002L +#include + +namespace std { + +template struct formatter> { +private: + std::formatter double_fmt_; + +public: + /// Parse the format specification (e.g. ".2f", "e", ".4e"). + template constexpr auto parse(ParseContext &ctx) { + return double_fmt_.parse(ctx); + } + + /// Format the quantity: apply the parsed spec to the value, then append the + /// unit symbol. + template + auto format(const qtty::Quantity &qty, FormatContext &ctx) const { + auto out = double_fmt_.format(qty.value(), ctx); + return std::format_to(out, " {}", qtty::UnitTraits::symbol()); + } +}; + +} // namespace std +#endif // __cplusplus >= 202002L diff --git a/qtty b/qtty index b6b55aa..761c15d 160000 --- a/qtty +++ b/qtty @@ -1 +1 @@ -Subproject commit b6b55aac4eaaa5dc6553af73bf6ab3558483e604 +Subproject commit 761c15df2643e9c7656035e183476b9176d9dc8f diff --git a/run-ci-locally.sh b/run-ci-locally.sh new file mode 100755 index 0000000..4fa2077 --- /dev/null +++ b/run-ci-locally.sh @@ -0,0 +1,391 @@ +#!/usr/bin/env bash +set -euo pipefail + +# qtty-cpp C++ CI runner for local development +# This script mirrors the GitHub Actions CI workflow and can be run locally + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Options +RUN_ALL=false +RUN_LINT=false +RUN_BUILD=false +RUN_COVERAGE=false +SKIP_INSTALL=false +PARALLEL_LEVEL=2 + +# Default: run all +if [[ $# -eq 0 ]]; then + RUN_ALL=true +fi + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + all) + RUN_ALL=true + shift + ;; + lint) + RUN_LINT=true + shift + ;; + build) + RUN_BUILD=true + shift + ;; + test) + RUN_BUILD=true + shift + ;; + coverage) + RUN_COVERAGE=true + shift + ;; + --skip-install) + SKIP_INSTALL=true + shift + ;; + --parallel) + PARALLEL_LEVEL="$2" + shift 2 + ;; + --help|-h) + cat < /dev/null; then + return 1 + fi + return 0 +} + +# Check and install tools +if [[ "$SKIP_INSTALL" == "false" ]]; then + print_header "Checking/Installing build tools" + + # Check for cmake + if ! check_command cmake; then + print_warning "cmake not found, installing..." + sudo apt-get update + sudo apt-get install -y cmake + else + print_success "cmake found: $(cmake --version | head -1)" + fi + + # Check for ninja + if ! check_command ninja; then + print_warning "ninja-build not found, installing..." + sudo apt-get update + sudo apt-get install -y ninja-build + else + print_success "ninja found: $(ninja --version)" + fi + + # Check for clang-format (only needed for lint) + if [[ "$RUN_LINT" == "true" ]]; then + if ! check_command clang-format; then + print_warning "clang-format not found, installing clang-format-18..." + sudo apt-get update + wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh + sudo bash /tmp/llvm.sh 18 + sudo apt-get install -y clang-format-18 + sudo ln -sf /usr/bin/clang-format-18 /usr/local/bin/clang-format + rm -f /tmp/llvm.sh + else + print_success "clang-format found: $(clang-format --version | head -1)" + fi + + # Check for clang-tidy + if ! check_command clang-tidy; then + print_warning "clang-tidy not found, installing..." + sudo apt-get update + sudo apt-get install -y clang-tidy + else + print_success "clang-tidy found" + fi + fi + + # Check for gcovr (only needed for coverage) + if [[ "$RUN_COVERAGE" == "true" ]]; then + if ! check_command gcovr; then + print_warning "gcovr not found, installing..." + sudo apt-get update + sudo apt-get install -y gcovr + else + print_success "gcovr found: $(gcovr --version | head -1)" + fi + fi +fi + +# Check for Rust (needed for submodule validation) +if ! check_command rustc; then + print_error "Rust not found. Please install from https://rustup.rs/" + exit 1 +fi + +FAILED=() +PASSED=() + +# Show submodule status +print_header "Submodule Information" +git submodule status --recursive 2>/dev/null || true +if [[ -f qtty/Cargo.toml ]]; then + echo -e "${GREEN}✓${NC} qtty submodule OK" +else + print_error "qtty submodule missing (run: git submodule update --init --recursive)" + FAILED+=("submodules") +fi + +# Lint checks +if [[ "$RUN_LINT" == "true" ]]; then + print_header "Check: clang-format" + mapfile -t files < <(git ls-files '*.hpp' '*.cpp' 2>/dev/null || echo "") + + if [[ ${#files[@]} -eq 0 ]]; then + print_warning "No C++ files found (check git ls-files)" + else + if clang-format --dry-run --Werror "${files[@]}" 2>/dev/null; then + print_success "clang-format check passed" + PASSED+=("clang-format") + else + print_error "clang-format check failed" + print_warning "Run: clang-format -i \$(git ls-files '*.hpp' '*.cpp')" + FAILED+=("clang-format") + fi + fi + + print_header "Check: clang-tidy" + + # First configure to generate compile_commands.json + print_warning "Configuring CMake for compile commands..." + if cmake -S . -B build -G Ninja -DQTTY_BUILD_DOCS=OFF -DCMAKE_EXPORT_COMPILE_COMMANDS=ON >/dev/null 2>&1; then + mapfile -t cpp_files < <(git ls-files '*.cpp' 2>/dev/null || echo "") + + if [[ ${#cpp_files[@]} -eq 0 ]]; then + print_warning "No C++ source files found (check git ls-files)" + else + tidy_failed=false + for file in "${cpp_files[@]}"; do + echo " Checking: $file" + if ! clang-tidy -p build --warnings-as-errors='*' "$file" 2>/dev/null; then + tidy_failed=true + fi + done + + if [[ "$tidy_failed" == "false" ]]; then + print_success "clang-tidy check passed" + PASSED+=("clang-tidy") + else + print_error "clang-tidy check failed" + FAILED+=("clang-tidy") + fi + fi + else + print_error "CMake configuration failed" + FAILED+=("clang-tidy") + fi +fi + +# Build and test +if [[ "$RUN_BUILD" == "true" ]]; then + print_header "Configure: CMake (build directory)" + + if cmake -S . -B build -G Ninja -DQTTY_BUILD_DOCS=ON >/dev/null 2>&1; then + print_success "CMake configuration complete" + else + print_error "CMake configuration failed" + FAILED+=("cmake-config") + exit 1 + fi + + print_header "Build: test_ffi target" + + if CMAKE_BUILD_PARALLEL_LEVEL="$PARALLEL_LEVEL" cmake --build build --target test_ffi 2>&1 | tee /tmp/build.log | tail -20; then + if [[ -f build/test_ffi ]]; then + print_success "Build successful" + PASSED+=("build") + else + print_error "Build failed (test_ffi executable not found)" + FAILED+=("build") + fi + else + print_error "Build failed" + FAILED+=("build") + fi + + print_header "Test: ctest" + + if ctest --test-dir build --output-on-failure -L qtty_cpp; then + print_success "All tests passed" + PASSED+=("test") + else + print_error "Tests failed" + FAILED+=("test") + fi + + print_header "Build: Doxygen documentation" + if cmake --build build --target docs 2>&1 | tee /tmp/docs.log | tail -10; then + if [[ -d build/docs/html ]]; then + print_success "Documentation built (see build/docs/html/index.html)" + PASSED+=("docs") + else + print_warning "Documentation build skipped (doxygen may not be installed)" + fi + else + print_warning "Documentation build skipped or failed" + fi +fi + +# Coverage +if [[ "$RUN_COVERAGE" == "true" ]]; then + print_header "Configure: CMake (coverage build)" + + if cmake -S . -B build-coverage -G Ninja \ + -DQTTY_BUILD_DOCS=OFF \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="--coverage" \ + -DCMAKE_EXE_LINKER_FLAGS="--coverage" >/dev/null 2>&1; then + print_success "CMake configuration complete" + else + print_error "CMake configuration failed" + FAILED+=("coverage-config") + exit 1 + fi + + print_header "Build: test_ffi target (coverage)" + + if CMAKE_BUILD_PARALLEL_LEVEL="$PARALLEL_LEVEL" cmake --build build-coverage --target test_ffi >/dev/null 2>&1; then + print_success "Build complete" + else + print_error "Build failed" + FAILED+=("coverage-build") + exit 1 + fi + + print_header "Test: ctest (coverage)" + + if ctest --test-dir build-coverage --output-on-failure -L qtty_cpp >/dev/null 2>&1; then + print_success "All tests passed" + else + print_error "Tests failed" + FAILED+=("coverage-test") + exit 1 + fi + + print_header "Generate coverage reports" + + mkdir -p coverage_html + + if gcovr \ + --root . \ + --exclude 'build-coverage/.*' \ + --exclude 'qtty/.*' \ + --exclude 'tests/.*' \ + --exclude 'examples/.*' \ + --xml \ + --output coverage.xml >/dev/null 2>&1; then + print_success "Cobertura XML report generated" + else + print_error "Failed to generate Cobertura report" + FAILED+=("coverage-xml") + fi + + if gcovr \ + --root . \ + --exclude 'build-coverage/.*' \ + --exclude 'qtty/.*' \ + --exclude 'tests/.*' \ + --exclude 'examples/.*' \ + --html-details \ + --output coverage_html/index.html >/dev/null 2>&1; then + print_success "HTML coverage report generated (see coverage_html/index.html)" + PASSED+=("coverage") + else + print_error "Failed to generate HTML report" + FAILED+=("coverage-html") + fi +fi + +# Summary +print_header "CI Summary" +echo -e "${GREEN}Passed (${#PASSED[@]}):${NC}" +for check in "${PASSED[@]}"; do + echo " ✓ $check" +done + +if [[ ${#FAILED[@]} -gt 0 ]]; then + echo -e "\n${RED}Failed (${#FAILED[@]}):${NC}" + for check in "${FAILED[@]}"; do + echo " ✗ $check" + done + exit 1 +else + echo -e "\n${GREEN}All checks passed!${NC}\n" + exit 0 +fi diff --git a/tests/test_formatting.cpp b/tests/test_formatting.cpp new file mode 100644 index 0000000..e4212c5 --- /dev/null +++ b/tests/test_formatting.cpp @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Vallés Puig, Ramon + +/** + * @file test_formatting.cpp + * @brief Tests for Quantity string formatting: operator<<, format(), and + * std::formatter (C++20). + * + * These tests verify that every formatting annotation available in Rust + * (Display `{}`, `{:.N}`, `{:e}`, `{:.Ne}`, `{:E}`, `{:.NE}`) is matched + * by the corresponding C++ facility. + * + * Rust ↔ C++ mapping + * ------------------ + * Rust `{}` → operator<< (default) / format(-1, QTTY_FMT_DEFAULT) + * Rust `{:.2}` → std::setprecision(2) << std::fixed / format(2) + * Rust `{:e}` → std::scientific / format(-1, QTTY_FMT_LOWER_EXP) + * Rust `{:.4e}` → std::scientific << std::setprecision(4) / format(4, + * QTTY_FMT_LOWER_EXP) Rust `{:E}` → format(-1, QTTY_FMT_UPPER_EXP) Rust + * `{:.4E}` → format(4, QTTY_FMT_UPPER_EXP) + */ + +#include "fixtures.hpp" +#include +#include +#include + +// ─── Helper ───────────────────────────────────────────────────────────────── + +/// Stream a quantity with the given manipulators and return the string. +template static std::string stream_qty(const Q &q) { + std::ostringstream oss; + oss << q; + return oss.str(); +} + +template +static std::string stream_qty_with(const Q &q, Manips &&...manips) { + std::ostringstream oss; + // Apply all manipulators and then stream the quantity + (oss << ... << std::forward(manips)) << q; + return oss.str(); +} + +// Fixture used for all formatting tests +class FormattingTest : public QttyTest {}; + +// ───────────────────────────────────────────────────────────────────────────── +// operator<< tests (mirrors Rust Display `{}`) +// ───────────────────────────────────────────────────────────────────────────── + +TEST_F(FormattingTest, StreamDefaultDecimal) { + Second s(1234.56789); + // std::ostream default precision is 6 significant digits. + EXPECT_EQ(stream_qty(s), "1234.57 s"); +} + +TEST_F(FormattingTest, StreamPrecisionFixed) { + Second s(1234.56789); + std::ostringstream oss; + oss << std::fixed << std::setprecision(2) << s; + EXPECT_EQ(oss.str(), "1234.57 s"); +} + +TEST_F(FormattingTest, StreamScientificLower) { + Second s(1234.56789); + std::ostringstream oss; + oss << std::scientific << s; + // std::scientific produces at least 2-digit exponents on most platforms; + // just check the number part and the unit suffix. + std::string result = oss.str(); + // Must end with " s" + EXPECT_EQ(result.back(), 's'); + // Must contain 'e' + EXPECT_NE(result.find('e'), std::string::npos); +} + +TEST_F(FormattingTest, StreamScientificLowerWithPrecision) { + Second s(1234.56789); + std::ostringstream oss; + oss << std::scientific << std::setprecision(4) << s; + std::string result = oss.str(); + EXPECT_NE(result.find('e'), std::string::npos); + EXPECT_TRUE(result.find("1.2346e") != std::string::npos || + result.find("1.2346E") != std::string::npos) + << "Got: " << result; +} + +TEST_F(FormattingTest, StreamDefaultMeter) { + Meter m(42.0); + EXPECT_EQ(stream_qty(m), "42 m"); +} + +TEST_F(FormattingTest, StreamNegativeValue) { + Meter m(-42.5); + EXPECT_EQ(stream_qty(m), "-42.5 m"); +} + +TEST_F(FormattingTest, StreamKilometerConvertedValue) { + Kilometer km(1.5); + EXPECT_EQ(stream_qty(km), "1.5 km"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// format() method tests (mirrors Rust Display/LowerExp/UpperExp) +// ───────────────────────────────────────────────────────────────────────────── + +TEST_F(FormattingTest, FormatDefaultNoPrecision) { + Second s(1234.56789); + EXPECT_EQ(s.format(), "1234.56789 s"); +} + +TEST_F(FormattingTest, FormatDefaultTwoDecimalPlaces) { + Second s(1234.56789); + EXPECT_EQ(s.format(2), "1234.57 s"); +} + +TEST_F(FormattingTest, FormatDefaultZeroDecimalPlaces) { + Second s(1234.56789); + EXPECT_EQ(s.format(0), "1235 s"); +} + +TEST_F(FormattingTest, FormatDefaultFiveDecimalPlaces) { + Second s(1234.56789); + EXPECT_EQ(s.format(5), "1234.56789 s"); +} + +TEST_F(FormattingTest, FormatLowerExpNoPrecision) { + Second s(1234.56789); + // Rust {:e} → Rust compact form: 1.23456789e3 s + EXPECT_EQ(s.format(-1, QTTY_FMT_LOWER_EXP), "1.23456789e3 s"); +} + +TEST_F(FormattingTest, FormatLowerExpFourDecimalPlaces) { + Second s(1234.56789); + // Rust {:.4e} → 1.2346e3 s + EXPECT_EQ(s.format(4, QTTY_FMT_LOWER_EXP), "1.2346e3 s"); +} + +TEST_F(FormattingTest, FormatLowerExpZeroDecimalPlaces) { + Second s(1234.56789); + EXPECT_EQ(s.format(0, QTTY_FMT_LOWER_EXP), "1e3 s"); +} + +TEST_F(FormattingTest, FormatUpperExpNoPrecision) { + Second s(1234.56789); + EXPECT_EQ(s.format(-1, QTTY_FMT_UPPER_EXP), "1.23456789E3 s"); +} + +TEST_F(FormattingTest, FormatUpperExpFourDecimalPlaces) { + Second s(1234.56789); + EXPECT_EQ(s.format(4, QTTY_FMT_UPPER_EXP), "1.2346E3 s"); +} + +TEST_F(FormattingTest, FormatNegativeValue) { + Meter m(-42.5); + EXPECT_EQ(m.format(), "-42.5 m"); + EXPECT_EQ(m.format(1), "-42.5 m"); + EXPECT_EQ(m.format(2, QTTY_FMT_LOWER_EXP), "-4.25e1 m"); +} + +TEST_F(FormattingTest, FormatZeroValue) { + Second s(0.0); + EXPECT_EQ(s.format(), "0 s"); + EXPECT_EQ(s.format(2), "0.00 s"); +} + +TEST_F(FormattingTest, FormatMeterDefault) { + Meter m(42.0); + EXPECT_EQ(m.format(), "42 m"); +} + +TEST_F(FormattingTest, FormatKilometerDefault) { + Kilometer km(1.5); + EXPECT_EQ(km.format(), "1.5 km"); +} + +TEST_F(FormattingTest, FormatLargeValue) { + Meter m(1.5e12); + EXPECT_EQ(m.format(2, QTTY_FMT_LOWER_EXP), "1.50e12 m"); +} + +// Verify format() with precision=2 matches operator<< with +// fixed+setprecision(2). +TEST_F(FormattingTest, FormatMatchesStream) { + Second s(1234.56789); + std::ostringstream oss; + oss << std::fixed << std::setprecision(2) << s; + EXPECT_EQ(s.format(2), oss.str()); +}