cmake_minimum_required(VERSION 3.10)
# The single source of truth for the version. It flows from here into
# include/akstdlib_version.h (generated from the .in template next to it), the
# shared library's VERSION/SOVERSION, the Version: field in akstdlib.pc, and
# akstdlibConfigVersion.cmake. Nothing else should spell a version number.
#
# 0.x on purpose: TODO.md section 2.1 still records four confirmed defects whose
# fixes change documented behaviour (aksl_atoi's contract, aksl_realpath's
# signature), so this library is not promising a stable API yet.
project(akstdlib VERSION 0.1.0 LANGUAGES C)

# The granularity at which the ABI is allowed to break, and therefore the
# soname: libakstdlib.so.0.1. Pre-1.0 that is MAJOR.MINOR, because a 0.x library
# makes no compatibility promise across a minor bump. At 1.0 this becomes
# ${PROJECT_VERSION_MAJOR} alone -- change it here, and AKSL_VERSION_SONAME in
# the header template follows automatically because it is configured from this.
if(PROJECT_VERSION_MAJOR EQUAL 0)
  set(AKSL_SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
else()
  set(AKSL_SOVERSION "${PROJECT_VERSION_MAJOR}")
endif()

set(AKSL_GENERATED_INCLUDE_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated/include")
set(AKSL_VERSION_HEADER "${AKSL_GENERATED_INCLUDE_DIR}/akstdlib_version.h")
configure_file(
  "${CMAKE_CURRENT_SOURCE_DIR}/include/akstdlib_version.h.in"
  "${AKSL_VERSION_HEADER}"
  @ONLY
)

# This used to be three lines setting CMAKE_CXX_FLAGS and the linker flags to
# "-g -ggdb -pg". In a LANGUAGES C project the CXX variable reaches no compiler
# at all, so the debug flags never applied -- but -pg did reach the linkers, and
# every test binary that ran dropped a gmon.out in the working directory. Nobody
# was reading those profiles. CMAKE_BUILD_TYPE controls debug info now, which is
# what it is for; add -pg deliberately when you actually want to profile.
#
# Debug is the default for a bare `cmake -S . -B build` because this is a
# library under active development and a stripped -O3 build is not what anyone
# configuring it without an opinion is asking for.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
  set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE)
  message(STATUS "CMAKE_BUILD_TYPE was unset; defaulting to Debug")
endif()

# Warnings. The only warning in the tree when these went on was the unused
# `queue` parameter of aksl_tree_iterate, which TODO.md 2.2.8 had already
# recorded as a dead parameter -- so the cost of turning them on was one
# already-known defect, and the cost of leaving them off was every future one.
#
# -Wpedantic is deliberately not here. libakerror's FAIL_* macros take a message
# plus varargs, and a call with a bare message and no arguments trips "ISO C99
# requires at least one argument for the ...", which is a complaint about the
# macro's shape rather than about anything at this call site. Every FAIL_* in
# src/stdlib.c passes at least one argument regardless -- see the note in
# TODO.md 2.3 -- so the code is pedantic-clean; it is the expansion that is not.
if(CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$")
  add_compile_options(-Wall -Wextra)
  # CI turns this on. Locally it is off, because a warning that stops the build
  # while you are in the middle of something is a good way to teach people to
  # turn warnings off.
  option(AKSL_WERROR "Treat compiler warnings as errors" OFF)
  if(AKSL_WERROR)
    add_compile_options(-Werror)
  endif()
endif()

# Sanitizer build, off by default:
#   cmake -S . -B build-asan -DAKSL_SANITIZE=ON && ctest --test-dir build-asan
# Set before the dependency is added so libakerror is instrumented too --
# several of the defects in TODO.md section 2 (the uninitialised %s in
# aksl_realpath, the unbounded vsprintf in aksl_sprintf, the missing va_end in
# the printf family) only show up under ASan/UBSan.
option(AKSL_SANITIZE "Build the library and its tests with ASan + UBSan" OFF)
if(AKSL_SANITIZE)
  set(AKSL_SANITIZE_FLAGS "-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all")
  set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${AKSL_SANITIZE_FLAGS}")
  set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${AKSL_SANITIZE_FLAGS}")
  set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} ${AKSL_SANITIZE_FLAGS}")
  message(STATUS "AKSL_SANITIZE=ON: building with ASan + UBSan")
endif()

# Coverage build, off by default:
#   cmake -S . -B build-coverage -DAKSL_COVERAGE=ON
#   cmake --build build-coverage --target coverage
# Instrumentation is applied per target below (the library and the test
# binaries) rather than through CMAKE_C_FLAGS, so deps/libakerror is left
# uninstrumented -- it has its own suite, and its .gcda would only be noise in
# this project's report.
option(AKSL_COVERAGE "Build the library and its tests with gcov instrumentation" OFF)
# Minimum total line / branch coverage for the `coverage_report` CTest entry
# that a -DAKSL_COVERAGE=ON build adds. 0 disables the gate and reports only.
set(AKSL_COVERAGE_THRESHOLD 0 CACHE STRING
    "Fail the coverage_report test below this total line coverage percentage")
set(AKSL_COVERAGE_BRANCH_THRESHOLD 0 CACHE STRING
    "Fail the coverage_report test below this total branch coverage percentage")

if(AKSL_COVERAGE)
  if(NOT CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$")
    message(FATAL_ERROR
            "AKSL_COVERAGE=ON needs a gcov-compatible compiler; "
            "CMAKE_C_COMPILER_ID is ${CMAKE_C_COMPILER_ID}")
  endif()
  # -O0 because the optimizer folds and reorders lines until per-line counts
  # stop matching the source. -fprofile-abs-path makes gcov record absolute
  # source paths, which is what lets the report be read from any directory.
  set(AKSL_COVERAGE_COMPILE_FLAGS --coverage -O0 -g)
  if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
    list(APPEND AKSL_COVERAGE_COMPILE_FLAGS -fprofile-abs-path)
  endif()
  message(STATUS "AKSL_COVERAGE=ON: instrumenting akstdlib and its tests for gcov")
endif()

# Add gcov instrumentation to one target. No-op unless AKSL_COVERAGE is set.
function(aksl_target_coverage target)
  if(NOT AKSL_COVERAGE)
    return()
  endif()
  target_compile_options(${target} PRIVATE ${AKSL_COVERAGE_COMPILE_FLAGS})
  if(CMAKE_VERSION VERSION_LESS 3.13)
    set_property(TARGET ${target} APPEND_STRING PROPERTY LINK_FLAGS " --coverage")
  else()
    target_link_options(${target} PRIVATE --coverage)
  endif()
endfunction()

if(TARGET akerror::akerror)
  message(STATUS "FOUND akerror::akerror")
else()
  message(STATUS "MISSING akerror::akerror")
endif()

include(CTest)
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)

if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
  # libakerror registers its own CTest entries unconditionally, and because we
  # pull it in EXCLUDE_FROM_ALL its test binaries are never built -- so every
  # one of them used to show up in our suite as "Not Run" and fail. CMake has no
  # way to un-register a test, and set_tests_properties cannot reach across
  # directory scopes, so add_test() is shadowed for the duration of the
  # add_subdirectory() call. The dependency has its own CI; this project's suite
  # should only contain this project's tests.
  set(AKSL_SUPPRESS_ADD_TEST TRUE)
  function(add_test)
    if(NOT AKSL_SUPPRESS_ADD_TEST)
      _add_test(${ARGV})
    endif()
  endfunction()
  # libakerror also marks two of those tests WILL_FAIL, which would now be
  # setting properties on tests that no longer exist, so suppress that too.
  function(set_tests_properties)
    if(NOT AKSL_SUPPRESS_ADD_TEST)
      _set_tests_properties(${ARGV})
    endif()
  endfunction()

  # libakerror namespaces its `mutation` target when it is embedded but not its
  # `coverage` target (deps/libakerror/CMakeLists.txt:172 vs :189), so a
  # -DAKSL_COVERAGE=ON build hits "another target with the same name already
  # exists" against the `coverage` target this project adds, and fails to
  # configure at all. Rename the dependency's on the way past rather than
  # dropping it: its coverage script drives its own instrumented build tree, so
  # `cmake --build build-coverage --target akerror_coverage` still does the right
  # thing. Remove this once the dependency namespaces it upstream -- see TODO.md.
  function(add_custom_target _name)
    if(AKSL_SUPPRESS_ADD_TEST AND _name STREQUAL "coverage")
      _add_custom_target(akerror_coverage ${ARGN})
    else()
      _add_custom_target(${ARGV})
    endif()
  endfunction()

  add_subdirectory(deps/libakerror EXCLUDE_FROM_ALL)

  set(AKSL_SUPPRESS_ADD_TEST FALSE)
else()
  if(NOT TARGET akerror::akerror)
    find_package(PkgConfig REQUIRED)
    find_package(akerror REQUIRED)
  endif()
endif()

set(akstdlib_install_cmakedir "${CMAKE_INSTALL_LIBDIR}/cmake/akstdlib")
set(prefix      ${CMAKE_INSTALL_PREFIX})
set(exec_prefix "\${prefix}")
set(libdir      "\${exec_prefix}/lib")
set(includedir  "\${prefix}/include")
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/akstdlib.pc.in ${CMAKE_CURRENT_BINARY_DIR}/akstdlib.pc @ONLY)

add_library(akstdlib SHARED
  src/stdlib.c
)

add_library(akstdlib::akstdlib ALIAS akstdlib)

# Specify include directories for the library's headers (if applicable).
# The generated directory carries akstdlib_version.h, which akstdlib.h includes;
# it is PUBLIC because consumers building against the build tree need it too. On
# install both headers land side by side in ${CMAKE_INSTALL_INCLUDEDIR}, so the
# install interface needs no second entry.
target_include_directories(akstdlib PUBLIC
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<BUILD_INTERFACE:${AKSL_GENERATED_INCLUDE_DIR}>
  $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}/>
)

# VERSION gives the real file (libakstdlib.so.0.1.0); SOVERSION gives the symlink
# and the ELF soname recorded in every consumer (libakstdlib.so.0.1), so a
# consumer built against 0.1 will not silently load an ABI-incompatible 0.2.
set_target_properties(akstdlib PROPERTIES
  VERSION   ${PROJECT_VERSION}
  SOVERSION ${AKSL_SOVERSION}
)

target_link_libraries(akstdlib PUBLIC akerror::akerror)

aksl_target_coverage(akstdlib)

set(main_lib_dest "lib/my_library-${MY_LIBRARY_VERSION}")
install(TARGETS akstdlib
  EXPORT akstdlibTargets
  ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
  LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
  RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
  INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)

install(FILES "include/akstdlib.h" DESTINATION "include/")
install(FILES "${AKSL_VERSION_HEADER}" DESTINATION "include/")
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akstdlib.pc DESTINATION "lib/pkgconfig/")


install(EXPORT akstdlibTargets
    FILE akstdlibTargets.cmake
    NAMESPACE akstdlib::
    DESTINATION ${akstdlib_install_cmakedir}
)

configure_package_config_file(
    cmake/akstdlib.cmake.in
    "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfig.cmake"
    INSTALL_DESTINATION ${akstdlib_install_cmakedir}
)

# Without this, find_package(akstdlib 0.1 REQUIRED) is refused for want of a
# version file no matter what is installed -- which is exactly the gap that
# stops cmake/akstdlib.cmake.in from requesting a version of akerror.
#
# SameMinorVersion mirrors the soname: pre-1.0, 0.1 and 0.2 are different ABIs.
# It arrived in CMake 3.11 and this project declares 3.10, so fall back to
# ExactVersion on older CMake. That is stricter than the soname rule -- it pins
# the patch level too, so a 0.1.0 request refuses a compatible 0.1.1 -- but it is
# never laxer, and wrongly refusing a good pairing beats wrongly accepting a bad
# one. Drop the branch when the minimum moves past 3.11.
if(CMAKE_VERSION VERSION_LESS 3.11)
    set(AKSL_VERSION_COMPATIBILITY ExactVersion)
else()
    set(AKSL_VERSION_COMPATIBILITY SameMinorVersion)
endif()

write_basic_package_version_file(
    "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfigVersion.cmake"
    VERSION ${PROJECT_VERSION}
    COMPATIBILITY ${AKSL_VERSION_COMPATIBILITY}
)

install(FILES
    "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfig.cmake"
    "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfigVersion.cmake"
    DESTINATION ${akstdlib_install_cmakedir}
)

# Each test is one source file tests/test_<name>.c, built into the executable
# test_<name> and registered as the CTest test <name>. Shared helpers live in
# tests/aksl_capture.h.
#
#   AKSL_TESTS                must exit 0.
#   AKSL_WILL_FAIL_TESTS      expected to abort by design (an unhandled error
#                             reaching FINISH_NORETURN, a deliberate contract
#                             violation), so a non-zero exit is a pass.
#   AKSL_KNOWN_FAILING_TESTS  assert the *correct* behaviour of a confirmed
#                             defect from TODO.md section 2.1. They fail until
#                             the defect is fixed, and are marked WILL_FAIL so
#                             the suite stays green and the gap stays visible.
#                             When one is fixed CTest reports it as failed with
#                             "unexpectedly passed" -- that is the cue to move
#                             it up into AKSL_TESTS.
set(AKSL_TESTS
    convert
    format
    linkedlist
    memory
    path
    status_registry
    stream
    strhash
    strto
    tree
    version
)

set(AKSL_WILL_FAIL_TESTS
)

# Empty, and that is the news. It held four entries -- convert_strict,
# list_append_chain, list_iterate_head and tree_iterate_break -- one for each
# confirmed defect in TODO.md 2.1. All four are fixed, so each of those files
# was folded back into the test for the thing it was testing (tests/
# test_convert.c, tests/test_linkedlist.c and tests/test_tree.c) where it now
# has to keep passing rather than merely keep failing visibly.
set(AKSL_KNOWN_FAILING_TESTS
)

foreach(_test IN LISTS AKSL_TESTS AKSL_WILL_FAIL_TESTS AKSL_KNOWN_FAILING_TESTS)
  add_executable(test_${_test} tests/test_${_test}.c)
  target_include_directories(test_${_test} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests)
  target_link_libraries(test_${_test} PRIVATE akstdlib)
  # The test binaries are instrumented too. The default report filters them out
  # (only src/ and include/ are shown), but `scripts/coverage.py --include
  # tests` then answers a different question: whether every test case and
  # helper branch in tests/ actually runs, which is how a test function that was
  # written but never wired into AKSL_RUN shows up.
  aksl_target_coverage(test_${_test})
  add_test(NAME ${_test} COMMAND test_${_test})
  list(APPEND AKSL_TEST_TARGETS test_${_test})
endforeach()

# tests/test_memory.c asks for SIZE_MAX/2 bytes to check that a refused
# allocation reports ENOMEM and leaves *dst NULL. Plain malloc returns NULL and
# sets ENOMEM, which is the case under test; ASan's allocator instead treats a
# request that large as a bug in the caller and aborts the process before malloc
# ever returns. allocator_may_return_null=1 restores the libc behaviour for this
# one binary, so the sanitizer build tests the same contract as the normal one
# rather than skipping it.
if(AKSL_SANITIZE AND "memory" IN_LIST AKSL_TESTS)
  set_tests_properties(memory PROPERTIES
    ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1")
endif()

if(AKSL_WILL_FAIL_TESTS OR AKSL_KNOWN_FAILING_TESTS)
  set_tests_properties(
    ${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}
    PROPERTIES WILL_FAIL TRUE
  )
endif()

# Cap every test. The list and tree code is full of loops whose termination
# depends on a single condition, so a plausible bug -- or a mutant from the
# target below -- turns a test into an infinite loop. Without this, ctest waits
# forever; the mutation harness then kills ctest at its own timeout and the
# spinning test binary is left orphaned, holding the pipe open and burning a
# core. The whole suite runs in well under a second, so 30s is pure headroom.
set_tests_properties(
  ${AKSL_TESTS} ${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}
  PROPERTIES TIMEOUT 30
)

# Both the coverage report and the mutation harness are Python scripts.
find_package(Python3 COMPONENTS Interpreter)

# Code coverage. A -DAKSL_COVERAGE=ON build wires the report into the suite
# itself, so `ctest --test-dir build-coverage` both runs the tests and prints
# what they touched:
#
#   coverage_reset    deletes the accumulated .gcda counters. gcov counts are
#                     cumulative, so without this every report would fold in
#                     earlier runs and overstate coverage. FIXTURES_SETUP makes
#                     CTest run it before any test that needs the fixture, even
#                     under `ctest -j`.
#   coverage_report   aggregates gcov output and prints the summary plus the
#                     uncovered lines. FIXTURES_CLEANUP makes CTest run it after
#                     the last test in the fixture, which is exactly when the
#                     counters are complete.
#
# The report is a plain report unless AKSL_COVERAGE_THRESHOLD is set, in which
# case coverage_report fails below that percentage. Same ratchet idea as the
# mutation threshold: gate on the number you have, raise it as tests land.
#
# CTest hides the output of a passing test, so coverage_report also writes
# <build>/coverage-summary.txt and <build>/coverage.xml (Cobertura). The
# `coverage` target below builds, runs and prints in one step.
if(AKSL_COVERAGE AND Python3_FOUND)
  set(AKSL_COVERAGE_ARGS
      ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/coverage.py
      --build ${CMAKE_CURRENT_BINARY_DIR}
      --source-root ${CMAKE_CURRENT_SOURCE_DIR})

  add_test(NAME coverage_reset COMMAND ${AKSL_COVERAGE_ARGS} --zero)
  add_test(NAME coverage_report COMMAND ${AKSL_COVERAGE_ARGS}
           --threshold ${AKSL_COVERAGE_THRESHOLD}
           --branch-threshold ${AKSL_COVERAGE_BRANCH_THRESHOLD}
           --output ${CMAKE_CURRENT_BINARY_DIR}/coverage-summary.txt
           --cobertura ${CMAKE_CURRENT_BINARY_DIR}/coverage.xml)

  set_tests_properties(coverage_reset PROPERTIES FIXTURES_SETUP AKSL_GCDA)
  set_tests_properties(coverage_report PROPERTIES FIXTURES_CLEANUP AKSL_GCDA)
  set_tests_properties(
    ${AKSL_TESTS} ${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS}
    PROPERTIES FIXTURES_REQUIRED AKSL_GCDA
  )
  # gcov has to be spawned once per .gcda, so give the report more room than the
  # 30s the test binaries get.
  set_tests_properties(coverage_reset coverage_report PROPERTIES TIMEOUT 300)

  # Namespaced when embedded in another project, for the same reason the mutation
  # target below is: a sibling dependency may well ship a `coverage` target too.
  if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
    set(AKSL_COVERAGE_TARGET coverage)
  else()
    set(AKSL_COVERAGE_TARGET akstdlib_coverage)
  endif()

  # Convenience entry point that also builds the test binaries first. It runs
  # the suite rather than the script directly, because the two fixture tests
  # above already reset the counters and produce the report -- and CTest pulls a
  # required fixture back in even when it is filtered out, so there is no way to
  # run the tests without them. The second command re-reads the same counters to
  # print the report that CTest suppressed for the passing coverage_report test
  # (it only spawns gcov again, it does not re-run anything).
  add_custom_target(${AKSL_COVERAGE_TARGET}
    COMMAND ctest --test-dir ${CMAKE_CURRENT_BINARY_DIR} --output-on-failure
    COMMAND ${AKSL_COVERAGE_ARGS}
    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
    USES_TERMINAL
    COMMENT "Running the test suite under gcov and reporting coverage"
  )
  add_dependencies(${AKSL_COVERAGE_TARGET} ${AKSL_TEST_TARGETS})
elseif(AKSL_COVERAGE)
  message(WARNING "AKSL_COVERAGE=ON but Python3 was not found: "
                  "instrumenting the build, but the coverage report "
                  "(scripts/coverage.py) will not be wired into CTest")
endif()

# Mutation testing: break the library in small ways and confirm the test suite
# notices. This is a meta-check on the tests themselves, and it rebuilds and
# re-runs the whole suite once per mutant, so it is a manual target rather than
# a CTest test:
#   cmake --build build --target mutation
# When embedded in another project, use a namespaced target to avoid collisions
# with mutation targets provided by sibling dependencies.
# This target covers both src/stdlib.c and include/akstdlib.h. CI runs the
# narrower, faster src/stdlib.c set with a --threshold gate; see
# .gitea/workflows/ci.yaml.
if(Python3_FOUND)
  if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
    set(AKSL_MUTATION_TARGET mutation)
  else()
    set(AKSL_MUTATION_TARGET akstdlib_mutation)
  endif()
  add_custom_target(${AKSL_MUTATION_TARGET}
    COMMAND ${Python3_EXECUTABLE}
            ${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py
            --source-root ${CMAKE_CURRENT_SOURCE_DIR}
    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
    USES_TERMINAL
    COMMENT "Running mutation tests (breaks the library, expects tests to fail)"
  )
endif()


# pkgconfig
