From 684e0593ecdc5ced2e2591edd027acc7698947ee Mon Sep 17 00:00:00 2001 From: Arthur Vasseur Date: Sat, 11 Apr 2026 10:21:59 +0200 Subject: [PATCH] feat: implement thread-safe logging system using spdlog with category and channel support --- Src/Concerto/Core/Logger/LogMacros.hpp | 156 +++++++++++ Src/Concerto/Core/Logger/Logger.cpp | 343 ++++++++++++++++++++++++- Src/Concerto/Core/Logger/Logger.hpp | 171 ++++++------ Src/Tests/Logger.cpp | 320 +++++++++++++++++++++-- xmake.lua | 4 + 5 files changed, 878 insertions(+), 116 deletions(-) create mode 100644 Src/Concerto/Core/Logger/LogMacros.hpp diff --git a/Src/Concerto/Core/Logger/LogMacros.hpp b/Src/Concerto/Core/Logger/LogMacros.hpp new file mode 100644 index 0000000..3a2413e --- /dev/null +++ b/Src/Concerto/Core/Logger/LogMacros.hpp @@ -0,0 +1,156 @@ +#ifndef CONCERTO_CORE_LOGMACROS_HPP +#define CONCERTO_CORE_LOGMACROS_HPP + +#include +#include +#include +#include +#include + +#include "Concerto/Core/Logger/Logger.hpp" + +#ifndef CCT_LOG_LEVEL_MINIMUM +#define CCT_LOG_LEVEL_MINIMUM 0 +#endif + +#define CCT_LOG_IMPL(cctLevel, category, channel, fmtStr, ...) \ + do \ + { \ + if (auto* ctx = ::cct::Logger::GetContext(); \ + ctx && ctx->ShouldLog((category), (channel), ::cct::LogLevel::cctLevel)) \ + { \ + ctx->LogMessage( \ + (category), \ + (channel), \ + ::cct::LogLevel::cctLevel, \ + ::std::format(fmtStr __VA_OPT__(, ) __VA_ARGS__)); \ + } \ + } while (false) + +#if CCT_LOG_LEVEL_MINIMUM <= 0 +#define CCT_LOG_TRACE(category, channel, fmt, ...) \ + CCT_LOG_IMPL(Trace, category, channel, fmt __VA_OPT__(, ) __VA_ARGS__) +#else +#define CCT_LOG_TRACE(category, channel, fmt, ...) \ + do \ + { \ + } while (false) +#endif + +#if CCT_LOG_LEVEL_MINIMUM <= 1 +#define CCT_LOG_DEBUG(category, channel, fmt, ...) \ + do \ + { \ + if (auto* ctx = ::cct::Logger::GetContext(); \ + ctx && ctx->ShouldLog((category), (channel), ::cct::LogLevel::Debug)) \ + { \ + auto loc = ::std::source_location::current(); \ + ctx->LogMessage( \ + (category), \ + (channel), \ + ::cct::LogLevel::Debug, \ + ::std::format("{}:{}:{}: {}", \ + loc.file_name(), \ + loc.line(), \ + loc.column(), \ + ::std::format(fmt __VA_OPT__(, ) __VA_ARGS__))); \ + } \ + } while (false) +#else +#define CCT_LOG_DEBUG(category, channel, fmt, ...) \ + do \ + { \ + } while (false) +#endif + +#if CCT_LOG_LEVEL_MINIMUM <= 2 +#define CCT_LOG_INFO(category, channel, fmt, ...) \ + CCT_LOG_IMPL(Info, category, channel, fmt __VA_OPT__(, ) __VA_ARGS__) +#else +#define CCT_LOG_INFO(category, channel, fmt, ...) \ + do \ + { \ + } while (false) +#endif + +#if CCT_LOG_LEVEL_MINIMUM <= 3 +#define CCT_LOG_WARN(category, channel, fmt, ...) \ + CCT_LOG_IMPL(Warning, category, channel, fmt __VA_OPT__(, ) __VA_ARGS__) +#else +#define CCT_LOG_WARN(category, channel, fmt, ...) \ + do \ + { \ + } while (false) +#endif + +#if CCT_LOG_LEVEL_MINIMUM <= 4 +#define CCT_LOG_ERROR(category, channel, fmt, ...) \ + CCT_LOG_IMPL(Error, category, channel, fmt __VA_OPT__(, ) __VA_ARGS__) +#else +#define CCT_LOG_ERROR(category, channel, fmt, ...) \ + do \ + { \ + } while (false) +#endif + +#if CCT_LOG_LEVEL_MINIMUM <= 5 +#define CCT_LOG_CRITICAL(category, channel, fmt, ...) \ + CCT_LOG_IMPL(Critical, category, channel, fmt __VA_OPT__(, ) __VA_ARGS__) +#else +#define CCT_LOG_CRITICAL(category, channel, fmt, ...) \ + do \ + { \ + } while (false) +#endif + +namespace cct::detail +{ + class LogScope + { + public: + LogScope(const char* category, const char* channel, std::string_view name) : + m_category(category), + m_channel(channel), + m_name(name), + m_start(std::chrono::steady_clock::now()) + { + if (auto* ctx = Logger::GetContext(); ctx && ctx->ShouldLog(m_category, m_channel, LogLevel::Trace)) + { + ctx->LogMessage( + m_category, m_channel, LogLevel::Trace, std::format("Enter: {}", m_name)); + } + } + + ~LogScope() + { + if (auto* ctx = Logger::GetContext(); ctx && ctx->ShouldLog(m_category, m_channel, LogLevel::Trace)) + { + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - m_start); + ctx->LogMessage(m_category, + m_channel, + LogLevel::Trace, + std::format("Exit: {} ({} us)", m_name, elapsed.count())); + } + } + + LogScope(const LogScope&) = delete; + LogScope& operator=(const LogScope&) = delete; + LogScope(LogScope&&) = delete; + LogScope& operator=(LogScope&&) = delete; + + private: + const char* m_category; + const char* m_channel; + std::string m_name; + std::chrono::steady_clock::time_point m_start; + }; +} // namespace cct::detail + +#define CCT_LOG_SCOPE_CAT(a, b) CCT_LOG_SCOPE_CAT_IMPL(a, b) +#define CCT_LOG_SCOPE_CAT_IMPL(a, b) a##b + +#define CCT_LOG_SCOPE(category, channel, name) \ + ::cct::detail::LogScope CCT_LOG_SCOPE_CAT(cctLogScope_, __LINE__)((category), (channel), (name)) + +#endif // CONCERTO_CORE_LOGMACROS_HPP diff --git a/Src/Concerto/Core/Logger/Logger.cpp b/Src/Concerto/Core/Logger/Logger.cpp index ce7ca90..fe23a92 100644 --- a/Src/Concerto/Core/Logger/Logger.cpp +++ b/Src/Concerto/Core/Logger/Logger.cpp @@ -1,21 +1,348 @@ -// -// Created by arthur on 18/08/2025. -// - #include "Concerto/Core/Logger/Logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + #ifdef CCT_PLATFORM_WINDOWS #include #endif namespace cct { - void Logger::DebugString(std::string_view string) + namespace + { + struct ChannelState + { + LogLevel Level = LogLevel::Trace; + bool Enabled = true; + }; + + struct StringHash + { + using is_transparent = void; + + std::size_t operator()(std::string_view sv) const noexcept + { + return std::hash{}(sv); + } + std::size_t operator()(const std::string& s) const noexcept + { + return std::hash{}(s); + } + std::size_t operator()(const char* s) const noexcept + { + return std::hash{}(s); + } + }; + + constexpr const char* kDefaultCategory = "Core"; + + constexpr spdlog::level::level_enum ToSpdlogLevel(LogLevel level) noexcept + { + switch (level) + { + case LogLevel::Trace: + return spdlog::level::trace; + case LogLevel::Debug: + return spdlog::level::debug; + case LogLevel::Info: + return spdlog::level::info; + case LogLevel::Warning: + return spdlog::level::warn; + case LogLevel::Error: + return spdlog::level::err; + case LogLevel::Critical: + return spdlog::level::critical; + case LogLevel::Off: + return spdlog::level::off; + } + return spdlog::level::info; + } + + std::shared_ptr CreateLoggerLocked(Logger::LoggerState& state, std::string_view categoryName); + void InitializeLocked(Logger::LoggerState& state, const LogConfig& config); + } // namespace + + struct Logger::LoggerState + { + mutable std::shared_mutex Mutex; + std::unordered_map, StringHash, std::equal_to<>> Loggers; + std::unordered_map> ChannelStates; + std::vector Sinks; + LogConfig Config; + std::atomic FastPathGlobalLevel{LogLevel::Info}; + std::atomic Initialized{false}; + bool AsyncEnabled = false; + }; + + namespace + { + std::shared_ptr CreateLoggerLocked(Logger::LoggerState& state, std::string_view categoryName) + { + const std::string name(categoryName); + std::shared_ptr logger; + + if (state.AsyncEnabled) + { + logger = std::make_shared( + name, + state.Sinks.begin(), + state.Sinks.end(), + spdlog::thread_pool(), + spdlog::async_overflow_policy::block); + } + else + { + logger = std::make_shared(name, state.Sinks.begin(), state.Sinks.end()); + } + + logger->set_level(ToSpdlogLevel(state.Config.GlobalLevel)); + logger->flush_on(spdlog::level::err); + return logger; + } + + void InitializeLocked(Logger::LoggerState& state, const LogConfig& config) + { + if (state.Initialized.load(std::memory_order_acquire)) + return; + + state.Config = config; + state.FastPathGlobalLevel.store(config.GlobalLevel, std::memory_order_relaxed); + state.Sinks.clear(); + + if (config.EnableConsole) + { + auto consoleSink = std::make_shared(); + consoleSink->set_pattern(config.Pattern); + state.Sinks.push_back(std::move(consoleSink)); + } + + if (config.EnableFile) + { + std::error_code ec; + std::filesystem::create_directories(config.LogDir, ec); + + if (!ec) + { + const auto filePath = (config.LogDir / config.LogFileName).string(); + + auto fileSink = std::make_shared( + filePath, config.MaxFileSize, config.MaxFiles); + fileSink->set_pattern(config.Pattern); + state.Sinks.push_back(std::move(fileSink)); + } + } + + if (config.AsyncMode) + { + spdlog::init_thread_pool(config.AsyncQueueSize, config.AsyncThreadCount); + state.AsyncEnabled = true; + } + else + { + state.AsyncEnabled = false; + } + + spdlog::set_level(ToSpdlogLevel(config.GlobalLevel)); + state.Initialized.store(true, std::memory_order_release); + } + + spdlog::logger* GetOrCreateLoggerInternal(Logger::LoggerState& state, const char* category) + { + const char* name = (category != nullptr && *category != '\0') ? category : kDefaultCategory; + + { + std::shared_lock lock(state.Mutex); + auto it = state.Loggers.find(name); + if (it != state.Loggers.end()) + return it->second.get(); + } + + std::unique_lock lock(state.Mutex); + auto it = state.Loggers.find(name); + if (it != state.Loggers.end()) + return it->second.get(); + + auto logger = CreateLoggerLocked(state, name); + auto* raw = logger.get(); + state.Loggers.emplace(std::string(name), std::move(logger)); + return raw; + } + } // namespace + + static std::atomic s_Context{nullptr}; + + Logger* Logger::GetContext() + { + return s_Context.load(std::memory_order_acquire); + } + + void Logger::SetContext(Logger* context) + { + s_Context.store(context, std::memory_order_release); + } + + Logger::Logger(const LogConfig& config) : + m_State(std::make_unique()) + { + std::unique_lock lock(m_State->Mutex); + InitializeLocked(*m_State, config); + } + + Logger::~Logger() + { + std::unique_lock lock(m_State->Mutex); + for (auto& [_, logger] : m_State->Loggers) + { + if (logger) + logger->flush(); + } + m_State->Loggers.clear(); + m_State->ChannelStates.clear(); + m_State->Sinks.clear(); + m_State->Initialized.store(false, std::memory_order_release); + m_State->AsyncEnabled = false; + } + + void Logger::Flush() + { + std::shared_lock lock(m_State->Mutex); + for (auto& [_, logger] : m_State->Loggers) + { + if (logger) + logger->flush(); + } + } + + void Logger::SetGlobalLevel(LogLevel level) + { + std::unique_lock lock(m_State->Mutex); + m_State->Config.GlobalLevel = level; + m_State->FastPathGlobalLevel.store(level, std::memory_order_relaxed); + const auto spdLevel = ToSpdlogLevel(level); + for (auto& [_, logger] : m_State->Loggers) + { + if (logger) + logger->set_level(spdLevel); + } + } + + void Logger::SetCategoryLevel(std::string_view category, LogLevel level) + { + std::unique_lock lock(m_State->Mutex); + auto it = m_State->Loggers.find(category); + if (it == m_State->Loggers.end()) + { + auto logger = CreateLoggerLocked(*m_State, category); + logger->set_level(ToSpdlogLevel(level)); + m_State->Loggers.emplace(std::string(category), std::move(logger)); + return; + } + if (it->second) + it->second->set_level(ToSpdlogLevel(level)); + } + + void Logger::SetChannelLevel(std::string_view channel, LogLevel level) + { + std::unique_lock lock(m_State->Mutex); + auto it = m_State->ChannelStates.find(channel); + if (it == m_State->ChannelStates.end()) + { + ChannelState s; + s.Level = level; + m_State->ChannelStates.emplace(std::string(channel), s); + } + else + { + it->second.Level = level; + } + } + + void Logger::SetChannelEnabled(std::string_view channel, bool enabled) + { + std::unique_lock lock(m_State->Mutex); + auto it = m_State->ChannelStates.find(channel); + if (it == m_State->ChannelStates.end()) + { + ChannelState s; + s.Enabled = enabled; + m_State->ChannelStates.emplace(std::string(channel), s); + } + else + { + it->second.Enabled = enabled; + } + } + + bool Logger::ShouldLog(const char* category, const char* channel, LogLevel level) const noexcept + { + if (static_cast(level) < static_cast(m_State->FastPathGlobalLevel.load(std::memory_order_relaxed))) + return false; + + try + { + std::shared_lock lock(m_State->Mutex); + + if (category != nullptr) + { + auto it = m_State->Loggers.find(category); + if (it != m_State->Loggers.end() && it->second) + { + if (!it->second->should_log(ToSpdlogLevel(level))) + return false; + } + } + + if (channel != nullptr) + { + auto it = m_State->ChannelStates.find(channel); + if (it != m_State->ChannelStates.end()) + { + if (!it->second.Enabled) + return false; + if (static_cast(level) < static_cast(it->second.Level)) + return false; + } + } + } + catch (...) + { + return false; + } + return true; + } + + void Logger::LogMessage(const char* category, + const char* channel, + LogLevel level, + std::string_view message) + { + auto* logger = GetOrCreateLoggerInternal(*m_State, category); + if (logger == nullptr) + return; + + const char* chanName = (channel != nullptr) ? channel : ""; + logger->log(ToSpdlogLevel(level), "[{}] {}", chanName, message); + } + + void Logger::DebugString(const std::string& string) { #ifdef CCT_PLATFORM_WINDOWS - OutputDebugString(string.data()); + OutputDebugStringA(string.c_str()); #else - std::cerr << string; + (void)string; #endif } -} +} // namespace cct diff --git a/Src/Concerto/Core/Logger/Logger.hpp b/Src/Concerto/Core/Logger/Logger.hpp index e65b2fb..806fbf6 100644 --- a/Src/Concerto/Core/Logger/Logger.hpp +++ b/Src/Concerto/Core/Logger/Logger.hpp @@ -1,119 +1,130 @@ -// -// Created by arthur on 25/05/22. -// - #ifndef CONCERTO_CORE_LOGGER_HPP #define CONCERTO_CORE_LOGGER_HPP +#include +#include +#include #include -#include +#include #include +#include +#include #include "Concerto/Core/Types/Types.hpp" namespace cct { - namespace Terminal::Color + enum class LogLevel : std::uint8_t + { + Trace = 0, + Debug = 1, + Info = 2, + Warning = 3, + Error = 4, + Critical = 5, + Off = 6 + }; + + struct LogConfig { - static constexpr auto DEFAULT = "\x1B[0m"; - static constexpr auto RED = "\x1B[31m"; - static constexpr auto GREEN = "\x1B[32m"; - static constexpr auto YELLOW = "\x1B[33m"; - static constexpr auto BLUE = "\x1B[34m"; - static constexpr auto MAGENTA = "\x1B[35m"; - static constexpr auto CYAN = "\x1B[36m"; - }// namespace Terminal::Color + LogLevel GlobalLevel = LogLevel::Info; + bool EnableConsole = true; + bool EnableFile = false; + std::filesystem::path LogDir = "Logs"; + std::string LogFileName = "concerto.log"; + std::size_t MaxFileSize = 5 * 1024 * 1024; // 5 MB + std::size_t MaxFiles = 3; + bool AsyncMode = false; + std::size_t AsyncQueueSize = 8192; + std::size_t AsyncThreadCount = 1; + // NOTE: Pattern uses spdlog format specifiers (%e, %n, %^, %$). + std::string Pattern = "[%Y-%m-%d %H:%M:%S.%e] [%n] %^[%l] %v%$"; + }; + class CCT_CORE_PUBLIC_API Logger { - public: + public: + explicit Logger(const LogConfig& config = {}); + ~Logger(); + + Logger(const Logger&) = delete; + Logger& operator=(const Logger&) = delete; + Logger(Logger&&) = delete; + Logger& operator=(Logger&&) = delete; + + static Logger* GetContext(); + static void SetContext(Logger* context); + + void Flush(); + void SetGlobalLevel(LogLevel level); + void SetCategoryLevel(std::string_view category, LogLevel level); + void SetChannelLevel(std::string_view channel, LogLevel level); + void SetChannelEnabled(std::string_view channel, bool enabled); + + bool ShouldLog(const char* category, const char* channel, LogLevel level) const noexcept; + void LogMessage(const char* category, const char* channel, LogLevel level, std::string_view message); + template struct Debug { - explicit Debug(const std::format_string fmt, T&&... args, std::source_location loc = std::source_location::current()) + explicit Debug(const std::format_string fmt, + T&&... args, + std::source_location loc = std::source_location::current()) { - Log(std::vformat(fmt.get(), std::make_format_args(args...)), LogLevel::Debug, loc); + auto* ctx = Logger::GetContext(); + if (!ctx || !ctx->ShouldLog("Core", "Default", LogLevel::Debug)) + return; + auto msg = std::format("{}:{}: ", loc.file_name(), loc.line()); + msg += std::vformat(fmt.get(), std::make_format_args(args...)); + ctx->LogMessage("Core", "Default", LogLevel::Debug, msg); #ifdef CCT_PLATFORM_WINDOWS - DebugString(std::vformat(fmt.get(), std::make_format_args(args...))); + Logger::DebugString(msg); #endif } }; - enum class LogLevel - { - Debug, - Info, - Warning, - Error - }; - /** - * @brief Log a message with the DEBUG level = INFO - * @param fmt The format of the message to Log - * @param args The arguments of the message to Log - */ + template + Debug(std::format_string, Types&&...) -> Debug; + template static void Info(const std::format_string fmt, Types&&... args) { - Log(std::vformat(fmt.get(), std::make_format_args(args...)), LogLevel::Info); + auto* ctx = Logger::GetContext(); + if (!ctx || !ctx->ShouldLog("Core", "Default", LogLevel::Info)) + return; + const auto msg = std::vformat(fmt.get(), std::make_format_args(args...)); + ctx->LogMessage("Core", "Default", LogLevel::Info, msg); } - /** - * @brief Log a message with the DEBUG level = DEBUG - * @param fmt The format of the message to Log - * @param args The arguments of the message to Log - * @attention see https://cor3ntin.github.io/posts/variadic/ - */ - template - Debug(std::format_string, Types&&...) -> Debug; - - /** - * @brief Log a message with the DEBUG level = WARNING - * @param fmt The format of the message to Log - * @param args The arguments of the message to Log - */ template static void Warning(const std::format_string fmt, Types&&... args) { - Log(std::vformat(fmt.get(), std::make_format_args(args...)), LogLevel::Warning); + auto* ctx = Logger::GetContext(); + if (!ctx || !ctx->ShouldLog("Core", "Default", LogLevel::Warning)) + return; + const auto msg = std::vformat(fmt.get(), std::make_format_args(args...)); + ctx->LogMessage("Core", "Default", LogLevel::Warning, msg); } - /** - * @brief Log a message with the DEBUG level = ERROR - * @param fmt The format of the message to Log - * @param args The arguments of the message to Log - */ template static void Error(const std::format_string fmt, Types&&... args) { - Log(std::vformat(fmt.get(), std::make_format_args(args...)), LogLevel::Error); + auto* ctx = Logger::GetContext(); + if (!ctx || !ctx->ShouldLog("Core", "Default", LogLevel::Error)) + return; + const auto msg = std::vformat(fmt.get(), std::make_format_args(args...)); + ctx->LogMessage("Core", "Default", LogLevel::Error, msg); } - /** - * @brief Log a message - * @param level The level of the message - * @param message The message to Log - * @param location The location of the message - */ - template - static void Log(const T& message, LogLevel level, const std::source_location& location = std::source_location::current()) - { - switch (level) - { - case LogLevel::Debug: - std::cerr << Terminal::Color::CYAN << location.file_name() << ": " << location.line() << ": " << message << Terminal::Color::DEFAULT << '\n'; - break; - case LogLevel::Info: - std::cout << Terminal::Color::GREEN << message << Terminal::Color::DEFAULT << '\n'; - break; - case LogLevel::Warning: - std::cout << Terminal::Color::YELLOW << message << Terminal::Color::DEFAULT << '\n'; - break; - case LogLevel::Error: - std::cerr << Terminal::Color::RED << message << Terminal::Color::DEFAULT << '\n'; - break; - } - } + static void DebugString(const std::string& string); + + public: // Intentionally public: anonymous-namespace helpers in Logger.cpp + // need access to LoggerState. The type is incomplete for consumers. + struct LoggerState; - static void DebugString(std::string_view string); + private: + std::unique_ptr m_State; }; -}// namespace cct -#endif//CONCERTO_CORE_LOGGER_HPP \ No newline at end of file +} // namespace cct + +#endif // CONCERTO_CORE_LOGGER_HPP diff --git a/Src/Tests/Logger.cpp b/Src/Tests/Logger.cpp index e7376f9..dfc6306 100644 --- a/Src/Tests/Logger.cpp +++ b/Src/Tests/Logger.cpp @@ -1,48 +1,312 @@ -// -// Created by arthur on 02/03/2024. -// +/** + * @file Tests/Logger.cpp + * @brief Unit tests for Logger, categories and channels + * @date 2026-04-10 + */ -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include + +#include using namespace cct; namespace CCT_ANONYMOUS_NAMESPACE { - class ScoppedCoutRedirector + // Creates a file-backed Logger configuration in a unique temp directory. + // Returns both the config and the path to the produced log file so tests + // can flush and re-read the contents. + struct TempFileLoggerGuard { - public: - ScoppedCoutRedirector() : _old(nullptr) + std::filesystem::path Dir; + std::filesystem::path File; + + std::unique_ptr m_Logger; + + explicit TempFileLoggerGuard(const std::string& suffix) { - std::cout.clear(); - _old = std::cout.rdbuf(_buffer.rdbuf()); + Dir = std::filesystem::temp_directory_path() / ("cct_logger_test_" + suffix); + File = Dir / "concerto.log"; + std::error_code ec; + std::filesystem::remove_all(Dir, ec); + std::filesystem::create_directories(Dir, ec); + + LogConfig config; + config.GlobalLevel = LogLevel::Trace; + config.EnableConsole = false; + config.EnableFile = true; + config.LogDir = Dir; + config.LogFileName = "concerto.log"; + config.Pattern = "[%l] %v"; + + m_Logger = std::make_unique(config); + cct::Logger::SetContext(m_Logger.get()); } - ~ScoppedCoutRedirector() + ~TempFileLoggerGuard() { - std::cout.rdbuf(_old); + cct::Logger::SetContext(nullptr); + m_Logger.reset(); + std::error_code ec; + std::filesystem::remove_all(Dir, ec); } - const std::string& Str() const + TempFileLoggerGuard(const TempFileLoggerGuard&) = delete; + TempFileLoggerGuard& operator=(const TempFileLoggerGuard&) = delete; + + std::string ReadAll() const { - return _buffer.str(); + m_Logger->Flush(); + std::ifstream in(File, std::ios::binary); + std::stringstream ss; + ss << in.rdbuf(); + return ss.str(); } - private: - const std::ostringstream _buffer; - std::streambuf* _old; }; - //SCENARIO("Logger - Debug output") - //{ - // GIVEN("A redirected cout") - // { - // ScoppedCoutRedirector redirector; - // Logger::Debug("Test string {}", 25); - // - // auto currentLocation = std::source_location::current(); - // const std::string content = Terminal::Color::CYAN + std::string(currentLocation.function_name()) + ":" + std::to_string(currentLocation.line()) + " message: Test string 25" + Terminal::Color::DEFAULT; - // THEN("The output matches") { CHECK(redirector.Str() == "Test string 25"); } - // } - //} + SCENARIO("Logger - Instantiation is safe to call across pointers") + { + GIVEN("A context pointer config") + { + LogConfig cfg; + cct::Logger logger(cfg); + cct::Logger::SetContext(&logger); + + THEN("No crash occurs and logging is usable") + { + cct::Logger::Info("sanity {}", 1); + CCT_LOG_ERROR("Core", "Vulkan", "Error in Vulkan"); + CCT_LOG_INFO("Core", "Vulkan", "Info in Vulkan"); + cct::Logger::GetContext()->Flush(); + } + + cct::Logger::SetContext(nullptr); + } + } + + SCENARIO("Logger - CCT_LOG_INFO writes through category logger") + { + GIVEN("A file-backed logger and a captured Renderer category") + { + TempFileLoggerGuard guard("info"); + Logger::GetContext()->SetCategoryLevel("Renderer", LogLevel::Trace); + + WHEN("Logging an info message through the Vulkan channel") + { + CCT_LOG_INFO("Renderer", "Vulkan", "Frame {}", 42); + const auto out = guard.ReadAll(); + + THEN("The output contains the channel tag and message") + { + CHECK(out.find("[Vulkan]") != std::string::npos); + CHECK(out.find("Frame 42") != std::string::npos); + CHECK(out.find("info") != std::string::npos); + } + } + } + } + + SCENARIO("Logger - Per-category level filtering") + { + GIVEN("The Network category set to Warning level") + { + TempFileLoggerGuard guard("cat"); + Logger::GetContext()->SetCategoryLevel("Network", LogLevel::Warning); + + WHEN("Logging at Info level") + { + CCT_LOG_INFO("Network", "HTTP", "ignored info"); + THEN("Message is suppressed") + { + CHECK(guard.ReadAll().find("ignored info") == std::string::npos); + } + } + + WHEN("Logging at Error level") + { + CCT_LOG_ERROR("Network", "HTTP", "visible error"); + THEN("Message is emitted") + { + CHECK(guard.ReadAll().find("visible error") != std::string::npos); + } + } + } + } + + SCENARIO("Logger - Per-channel filtering") + { + GIVEN("A disabled channel on the Audio category") + { + TempFileLoggerGuard guard("chan_disable"); + Logger::GetContext()->SetCategoryLevel("Audio", LogLevel::Trace); + Logger::GetContext()->SetChannelEnabled("Muted", false); + + WHEN("Logging through the disabled channel") + { + CCT_LOG_INFO("Audio", "Muted", "should not appear"); + THEN("Message is suppressed") + { + CHECK(guard.ReadAll().find("should not appear") == std::string::npos); + } + } + + WHEN("Re-enabling the channel and logging") + { + Logger::GetContext()->SetChannelEnabled("Muted", true); + CCT_LOG_INFO("Audio", "Muted", "now visible"); + THEN("Message is emitted") + { + CHECK(guard.ReadAll().find("now visible") != std::string::npos); + } + } + } + + GIVEN("A channel with a Warning level filter") + { + TempFileLoggerGuard guard("chan_level"); + Logger::GetContext()->SetCategoryLevel("Audio", LogLevel::Trace); + Logger::GetContext()->SetChannelEnabled("NoisyChan", true); + Logger::GetContext()->SetChannelLevel("NoisyChan", LogLevel::Warning); + + WHEN("Logging Info and Warning messages") + { + CCT_LOG_INFO("Audio", "NoisyChan", "chan-info"); + CCT_LOG_WARN("Audio", "NoisyChan", "chan-warn"); + const auto out = guard.ReadAll(); + + THEN("Only the warning is emitted") + { + CHECK(out.find("chan-info") == std::string::npos); + CHECK(out.find("chan-warn") != std::string::npos); + } + } + } + } + + SCENARIO("Logger - ShouldLog fast path") + { + GIVEN("A global level of Warning") + { + TempFileLoggerGuard guard("fastpath"); + Logger::GetContext()->SetGlobalLevel(LogLevel::Warning); + THEN("Info messages are rejected") + { + CHECK_FALSE(Logger::GetContext()->ShouldLog("X", "Y", LogLevel::Info)); + } + THEN("Warning messages are accepted") + { + CHECK(Logger::GetContext()->ShouldLog("X", "Y", LogLevel::Warning)); + } + THEN("Error messages are accepted") + { + CHECK(Logger::GetContext()->ShouldLog("X", "Y", LogLevel::Error)); + } + } + } + + SCENARIO("Logger - Backward compatibility API still works") + { + GIVEN("A file-backed logger") + { + TempFileLoggerGuard guard("legacy"); + Logger::GetContext()->SetCategoryLevel("Core", LogLevel::Trace); + + WHEN("Calling Logger::Info") + { + Logger::Info("legacy info {}", 7); + THEN("Message is emitted") + { + CHECK(guard.ReadAll().find("legacy info 7") != std::string::npos); + } + } + + WHEN("Calling Logger::Warning") + { + Logger::Warning("legacy warn {}", 8); + THEN("Message is emitted") + { + CHECK(guard.ReadAll().find("legacy warn 8") != std::string::npos); + } + } + + WHEN("Calling Logger::Error") + { + Logger::Error("legacy error {}", 9); + THEN("Message is emitted") + { + CHECK(guard.ReadAll().find("legacy error 9") != std::string::npos); + } + } + } + } + + SCENARIO("Logger - Thread safety smoke test") + { + GIVEN("Multiple threads logging concurrently") + { + TempFileLoggerGuard guard("threads"); + Logger::GetContext()->SetCategoryLevel("Threaded", LogLevel::Trace); + + constexpr int kThreadCount = 8; + constexpr int kMessagesPerThread = 50; + + std::vector threads; + threads.reserve(kThreadCount); + std::atomic startGate{0}; + + for (int t = 0; t < kThreadCount; ++t) + { + threads.emplace_back([t, &startGate]() + { + startGate.fetch_add(1, std::memory_order_relaxed); + while (startGate.load(std::memory_order_relaxed) < kThreadCount) { /* spin */ } + for (int i = 0; i < kMessagesPerThread; ++i) + { + CCT_LOG_INFO("Threaded", "Worker", "t{} msg{}", t, i); + } }); + } + + for (auto& th : threads) + th.join(); + + const auto out = guard.ReadAll(); + + THEN("All messages are recorded without corruption") + { + const auto count = std::count(out.begin(), out.end(), '\n'); + CHECK(count == kThreadCount * kMessagesPerThread); + } + } + } + + SCENARIO("Logger - Scoped logging helper") + { + GIVEN("A CCT_LOG_SCOPE in a block") + { + TempFileLoggerGuard guard("scope"); + Logger::GetContext()->SetCategoryLevel("Profile", LogLevel::Trace); + + { + CCT_LOG_SCOPE("Profile", "Section", "DoWork"); + } + + const auto out = guard.ReadAll(); + + THEN("Enter and Exit markers are logged") + { + CHECK(out.find("Enter: DoWork") != std::string::npos); + CHECK(out.find("Exit: DoWork") != std::string::npos); + } + } + } } // namespace CCT_ANONYMOUS_NAMESPACE diff --git a/xmake.lua b/xmake.lua index cb74118..59ffaec 100644 --- a/xmake.lua +++ b/xmake.lua @@ -23,6 +23,8 @@ if has_config("enet") then add_requires("enet", {configs = {shared = false}}) end +add_requires("spdlog") + function add_files_to_target(p, hpp_as_files, install) for _, dir in ipairs(os.filedirs(p)) do relative_dir = path.relative(dir, "Src/") @@ -62,8 +64,10 @@ target("concerto-core") add_packages("enet", {public = true}) add_defines("CCT_ENABLE_ENET") end + add_packages("spdlog") add_defines("CCT_CORE_BUILD") add_cxxflags("cl::/Zc:preprocessor", { public = true }) + add_cxxflags("cl::/utf-8") add_includedirs("Src", {public = true}) add_files_to_target("Src/Concerto/Core/", false, true) add_files_to_target("Src/Concerto/Core/**", false, true)