diff --git a/ASWLog/ASWLog_CategoryLog.cpp b/ASWLog/ASWLog_CategoryLog.cpp index 24504a2..3271ed9 100644 --- a/ASWLog/ASWLog_CategoryLog.cpp +++ b/ASWLog/ASWLog_CategoryLog.cpp @@ -25,8 +25,12 @@ limitations under the License. #include "ASWLog_CategoryLog.h" //--------------------------------------------------------------------------- // System includes here +#include #include +#include +#include //--------------------------------------------------------------------------- +#include "ASWLog_Utils.h" #include "ASWLog_Version.h" //--------------------------------------------------------------------------- @@ -36,10 +40,71 @@ namespace ASWLog namespace { +// A level TASWCategoryLog::ApplyLevels() gives the categories its name covers +struct TLevelRule +{ + std::string Name; // "*", or a category's name + Level MinimumLevel = Level::Info; +}; + +// The categories that exist, and the rules TASWCategoryLog::ApplyLevels() set last +struct TCategoryRegistry +{ + std::mutex Mutex; + std::vector Categories; + std::vector Rules; +}; + +//--------------------------------------------------------------------------- + +const TLevelRule* FindLevelRule(const std::vector& rules, std::string_view name) noexcept; +TCategoryRegistry& GetCategoryRegistry(); std::string MakeCategoryName(std::string_view name, const IASWLog& log); +bool ParseLevelRules(std::string_view spec, std::vector& rules, std::string& error); //--------------------------------------------------------------------------- +/* + FindLevelRule + + The rule of 'rules' with the longest name that covers the category 'name' (its own name, the name of a category + above it, or "*"), or null if none covers it +*/ +const TLevelRule* FindLevelRule(const std::vector& rules, std::string_view name) noexcept +{ + const TLevelRule* found = nullptr; + std::size_t foundLength = 0; + + for (const auto& rule : rules) + { + const bool isAll = rule.Name == "*"; + const std::size_t length = isAll ? 0 : rule.Name.size(); + const bool covers = isAll || (name.size() >= length && Detail::EqualsIgnoringCase(name.substr(0, length), rule.Name) && + (name.size() == length || name[length] == '.')); + + if (covers && (found == nullptr || length > foundLength)) + { + found = &rule; + foundLength = length; + } + } + + return found; +} + +/* + GetCategoryRegistry + + The process's category registry. Never destroyed, so categories destroyed at exit (e.g. static ones in other files) + can still leave it. +*/ +TCategoryRegistry& GetCategoryRegistry() +{ + static auto* const registry = new TCategoryRegistry(); + + return *registry; +} + /* MakeCategoryName @@ -57,6 +122,69 @@ std::string MakeCategoryName(std::string_view name, const IASWLog& log) return fullName; } +/* + ParseLevelRules + + Adds the "name=level" items of 'spec' to 'rules' (see TASWCategoryLog::ApplyLevels()), a name given again replacing + its earlier level. Returns false at the first invalid item, describing it in 'error'. +*/ +bool ParseLevelRules(std::string_view spec, std::vector& rules, std::string& error) +{ + std::size_t start = 0; + + while (start <= spec.size()) + { + const auto comma = spec.find(',', start); + const auto end = comma == std::string_view::npos ? spec.size() : comma; + const auto item = Detail::TrimSpaces(spec.substr(start, end - start)); + start = end + 1; + + if (item.empty()) + continue; + + const auto equals = item.find('='); + if (equals == std::string_view::npos) + { + error = std::format("'{}' has no '=' (expected name=level)", item); + return false; + } + + const auto name = Detail::TrimSpaces(item.substr(0, equals)); + const auto levelText = Detail::TrimSpaces(item.substr(equals + 1)); + + if (name.empty()) + { + error = std::format("'{}' has no category name", item); + return false; + } + + if (name != "*" && name.find('*') != std::string_view::npos) + { + error = std::format("'{}': a name can't hold '*' (a name covers the categories under it; \"*\" covers all)", item); + return false; + } + + const auto level = Level_FromString(levelText); + if (!level) + { + error = std::format("'{}': '{}' isn't a level (Trace, Debug, Info, Warn, Error, Critical or Off)", item, levelText); + return false; + } + + const auto existing = std::find_if(rules.begin(), rules.end(), [name](const TLevelRule& rule) + { + return Detail::EqualsIgnoringCase(rule.Name, name); + }); + + if (existing != rules.end()) + existing->MinimumLevel = *level; + else + rules.push_back(TLevelRule{ std::string(name), *level }); + } + + return true; +} + } // namespace //--------------------------------------------------------------------------- @@ -70,6 +198,55 @@ TASWCategoryLog::TASWCategoryLog(std::string_view name, IASWLog& log) : m_Log(log), m_Name(MakeCategoryName(name, log)) { + auto& registry = GetCategoryRegistry(); + std::lock_guard lock(registry.Mutex); + + registry.Categories.push_back(this); + + if (const auto* rule = FindLevelRule(registry.Rules, m_Name)) + m_Level.store(static_cast(rule->MinimumLevel), std::memory_order_relaxed); +} + +//--------------------------------------------------------------------------- +TASWCategoryLog::~TASWCategoryLog() +{ + auto& registry = GetCategoryRegistry(); + std::lock_guard lock(registry.Mutex); + + std::erase(registry.Categories, this); +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::ApplyLevels(std::string_view spec) +{ + std::string error; + + return ApplyLevels(spec, error); +} + +//--------------------------------------------------------------------------- +bool TASWCategoryLog::ApplyLevels(std::string_view spec, std::string& error) +{ + error.clear(); + + std::vector rules; + if (!ParseLevelRules(spec, rules, error)) + return false; + + auto& registry = GetCategoryRegistry(); + std::lock_guard lock(registry.Mutex); + + registry.Rules = std::move(rules); + + for (auto* category : registry.Categories) + { + // Not through SetMinimumLevel(), a virtual call: a category of a derived class may be in its destructor, + // waiting for the lock to leave the registry + if (const auto* rule = FindLevelRule(registry.Rules, category->m_Name)) + category->m_Level.store(static_cast(rule->MinimumLevel), std::memory_order_relaxed); + } + + return true; } //--------------------------------------------------------------------------- diff --git a/ASWLog/ASWLog_CategoryLog.h b/ASWLog/ASWLog_CategoryLog.h index 6650e74..f674423 100644 --- a/ASWLog/ASWLog_CategoryLog.h +++ b/ASWLog/ASWLog_CategoryLog.h @@ -63,9 +63,12 @@ namespace ASWLog // a log the rest of the application uses. SetEnabled(false) silences only this category (and the categories wrapping // it); the wrapped logger must be enabled too. // -// Lock-free and thread-safe. The wrapped logger must outlive the category, and must not be a multi-log that holds the -// category. A logger that keeps an entry beyond the call (e.g. in its backtrace) copies the category's name, so the -// category may be destroyed meanwhile. +// The levels of several categories can also be set by name, e.g. from an environment variable, with ApplyLevels() (see +// TASWLogConfig::ApplyEnvironment()). +// +// Lock-free and thread-safe (making or destroying a category takes a process-wide lock, for ApplyLevels()). The wrapped +// logger must outlive the category, and must not be a multi-log that holds the category. A logger that keeps an entry +// beyond the call (e.g. in its backtrace) copies the category's name, so the category may be destroyed meanwhile. ///////////////////////////////////////////////////////////////////////////// class TASWCategoryLog : public IASWLog { @@ -86,10 +89,23 @@ class TASWCategoryLog : public IASWLog TASWLogRecord WithCategory(const TASWLogRecord& record) const noexcept; public: - // Shows 'name' as is, or, if 'log' is a category, after that category's name and a '.'. Throws std::bad_alloc if - // the name can't be copied. + // Sets the minimum level of the categories 'spec' covers, process-wide: of those that exist now, and of those made + // until the next call. 'spec' is a list of "name=level" items separated by commas, e.g. "*=Info,Net=Debug, + // Net.Http=Trace": a name covers the category of that name and those under it (Net covers Net and Net.Http, but + // not Network), "*" covers every category, and the longest name that covers a category gives its level. Names and + // levels ignore case and the spaces around them; the levels are those of Level_FromString(). The categories it + // doesn't cover keep their level, and a later SetMinimumLevel() or ResetMinimumLevel() changes a category's level + // as usual. An empty spec covers none. Returns false and changes nothing if an item is invalid (no '=', no name, a + // '*' in a name other than "*", or an unknown level), describing it in 'error'. Throws std::bad_alloc if out of + // memory. + static bool ApplyLevels(std::string_view spec); + static bool ApplyLevels(std::string_view spec, std::string& error); + +public: + // Shows 'name' as is, or, if 'log' is a category, after that category's name and a '.'. Takes its level from the + // levels ApplyLevels() set last, if they cover it. Throws std::bad_alloc if out of memory. TASWCategoryLog(std::string_view name, IASWLog& log); - ~TASWCategoryLog() override = default; + ~TASWCategoryLog() override; TASWCategoryLog(const TASWCategoryLog&) = delete; TASWCategoryLog& operator=(const TASWCategoryLog&) = delete; diff --git a/ASWLog/ASWLog_Config.cpp b/ASWLog/ASWLog_Config.cpp index bcc92e6..e1f4cef 100644 --- a/ASWLog/ASWLog_Config.cpp +++ b/ASWLog/ASWLog_Config.cpp @@ -25,14 +25,278 @@ limitations under the License. #include "ASWLog_Config.h" //--------------------------------------------------------------------------- // System includes here -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_CategoryLog.h" +#include "ASWLog_Formatter.h" +#include "ASWLog_Utils.h" //--------------------------------------------------------------------------- namespace ASWLog { +namespace +{ + +// The keys ApplyEnvironment() reads, in the order it applies them (PATTERN after FORMAT) +constexpr std::array SettingKeys{ + "LEVEL", "CATEGORIES", "FOLDER", "FILE", "FLUSH", "SYNC_AT_LEVEL", "ASYNC", "BACKTRACE", "TIME_ZONE", + "TIME_PRECISION", "MULTILINE", "SHOW_FUNCTION", "SHOW_SOURCE", "FORMAT", "PATTERN" +}; + +constexpr std::string_view BoolValues = "1, true, yes or on; 0, false, no or off"; +constexpr std::string_view LevelValues = "Trace, Debug, Info, Warn, Error, Critical or Off"; + +//--------------------------------------------------------------------------- + +bool ApplyNamedSetting(TASWLogConfig& config, std::string_view name, std::string_view key, std::string_view value); +template +std::string Assign(const std::optional& parsed, T& setting, std::string_view expected); +std::optional ParseBool(std::string_view text) noexcept; +std::optional ParseCount(std::string_view text) noexcept; +void ReportInvalidSetting(const TASWLogConfig& config, std::string message); +std::filesystem::path UTF8ToPath(std::string_view text); + +//--------------------------------------------------------------------------- + +/* + ApplyNamedSetting + + Sets the setting 'key' of 'config' from 'value' (see TASWLogConfig::ApplySetting()), naming it 'name' in a report +*/ +bool ApplyNamedSetting(TASWLogConfig& config, std::string_view name, std::string_view key, std::string_view value) +{ + const auto is = [key](std::string_view candidate) + { + return Detail::EqualsIgnoringCase(key, candidate); + }; + + if (std::none_of(SettingKeys.begin(), SettingKeys.end(), is)) + { + ReportInvalidSetting(config, std::format("'{}' isn't a setting", name)); + return false; + } + + const auto text = Detail::TrimSpaces(value); + if (text.empty()) + return true; + + std::string problem; // Why 'text' isn't a valid value, if it isn't + + if (is("LEVEL")) + { + problem = Assign(Level_FromString(text), config.InitialMinimumLevel, LevelValues); + } + else if (is("CATEGORIES")) + { + TASWCategoryLog::ApplyLevels(text, problem); + } + else if (is("FOLDER")) + { + config.File.FolderPath = UTF8ToPath(text); + } + else if (is("FILE")) + { + config.File.FilePath = UTF8ToPath(text); + } + else if (is("FLUSH")) + { + problem = Assign(FlushMode_FromString(text), config.File.Flush, "EveryWrite, OnNewLine, Manual or Periodic"); + } + else if (is("SYNC_AT_LEVEL")) + { + problem = Assign(Level_FromString(text), config.File.SyncToDiskAtLevel, LevelValues); + } + else if (is("ASYNC")) + { + problem = Assign(ParseBool(text), config.Async.Enabled, BoolValues); + } + else if (is("BACKTRACE")) + { + problem = Assign(ParseCount(text), config.Backtrace.Capacity, "a number of entries, 0 = none"); + } + else if (is("TIME_ZONE")) + { + problem = Assign(TimeZone_FromString(text), config.Line.TimestampZone, "UTC or Local"); + } + else if (is("TIME_PRECISION")) + { + problem = Assign(TimePrecision_FromString(text), config.Line.TimestampPrecision, + "Milliseconds, Microseconds or Nanoseconds (ms, us, ns)"); + } + else if (is("MULTILINE")) + { + problem = Assign(MultilineMode_FromString(text), config.Line.Multiline, "Preserve, Indent or Escape"); + } + else if (is("SHOW_FUNCTION")) + { + problem = Assign(ParseBool(text), config.Line.ShowFunctionName, BoolValues); + } + else if (is("SHOW_SOURCE")) + { + problem = Assign(ParseBool(text), config.Line.ShowSourceLine, BoolValues); + } + else if (is("FORMAT")) + { + if (Detail::EqualsIgnoringCase(text, "TEXT")) + config.Line.Formatter.reset(); + else if (Detail::EqualsIgnoringCase(text, "JSON")) + config.Line.Formatter = std::make_shared(); + else + problem = "expected Text or JSON"; + } + else if (is("PATTERN")) + { + try + { + config.Line.Formatter = std::make_shared(text); + } + catch (const std::invalid_argument& exception) + { + problem = exception.what(); + } + } + + if (problem.empty()) + return true; + + ReportInvalidSetting(config, std::format("{}='{}': {}", name, text, problem)); + + return false; +} + +/* + Assign + + Sets 'setting' to the parsed value and returns an empty text, or, if the value couldn't be parsed, leaves 'setting' + as it is and returns "expected " +*/ +template +std::string Assign(const std::optional& parsed, T& setting, std::string_view expected) +{ + if (!parsed) + return std::format("expected {}", expected); + + setting = *parsed; + + return {}; +} + +/* + ParseBool + + 1, true, yes or on as true; 0, false, no or off as false, ignoring case; otherwise nullopt +*/ +std::optional ParseBool(std::string_view text) noexcept +{ + for (const std::string_view yes : { "1", "true", "yes", "on" }) + { + if (Detail::EqualsIgnoringCase(text, yes)) + return true; + } + + for (const std::string_view no : { "0", "false", "no", "off" }) + { + if (Detail::EqualsIgnoringCase(text, no)) + return false; + } + + return std::nullopt; +} + +/* + ParseCount + + 'text' as a number of decimal digits that fits a std::size_t, or nullopt +*/ +std::optional ParseCount(std::string_view text) noexcept +{ + std::size_t count = 0; + const auto result = std::from_chars(text.data(), text.data() + text.size(), count); + if (result.ec != std::errc() || result.ptr != text.data() + text.size()) + return std::nullopt; + + return count; +} + +/* + ReportInvalidSetting + + Reports an invalid setting to the config's OnError, or, if it has none, writes it to stderr. Swallows exceptions. +*/ +void ReportInvalidSetting(const TASWLogConfig& config, std::string message) +{ + try + { + TASWLogError error; + error.Kind = ErrorKind::InvalidSetting; + error.Message = std::move(message); + + if (config.OnError != nullptr) + { + config.OnError(error); + } + else + { + const auto line = std::format("ASWLog: {}\n", error.ToString()); + std::fwrite(line.data(), 1, line.size(), stderr); + std::fflush(stderr); + } + } + catch (...) + { + } +} + +/* + UTF8ToPath + + The path named by 'text' (UTF-8), on Windows too, where a path made from a std::string reads it in the ANSI code page +*/ +std::filesystem::path UTF8ToPath(std::string_view text) +{ + return std::filesystem::path(std::u8string(text.begin(), text.end())); +} + +} // namespace + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWLogConfig +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +bool TASWLogConfig::ApplyEnvironment(std::string_view prefix) +{ + bool isValid = true; + std::string name; + + for (const auto key : SettingKeys) + { + name.assign(prefix); + name.append(key); + + const auto value = Detail::ReadEnvironmentVariable(name); + if (value && !ApplyNamedSetting(*this, name, key, *value)) + isValid = false; + } + + return isValid; +} + +//--------------------------------------------------------------------------- +bool TASWLogConfig::ApplySetting(std::string_view key, std::string_view value) +{ + return ApplyNamedSetting(*this, key, Detail::TrimSpaces(key), value); +} + //--------------------------------------------------------------------------- } // namespace ASWLog diff --git a/ASWLog/ASWLog_Config.h b/ASWLog/ASWLog_Config.h index b36610f..8920a8b 100644 --- a/ASWLog/ASWLog_Config.h +++ b/ASWLog/ASWLog_Config.h @@ -29,6 +29,7 @@ limitations under the License. #ifndef ASWLog_ConfigH #define ASWLog_ConfigH //--------------------------------------------------------------------------- +#include #include #include #include @@ -111,6 +112,21 @@ struct TASWBackupInfo }; +///////////////////////////////////////////////////////////////////////////// +// TASWEventLogConfig +// +// How a TASWWindowsEventLog reports its entries to the Windows Event Log (TASWLogConfig::EventLog). +///////////////////////////////////////////////////////////////////////////// +struct TASWEventLogConfig +{ + // The event source Event Viewer shows, in the Application log. Empty: the executable's file name without its + // extension. Register it once to get plain messages (see TASWWindowsEventLog::RegisterSource()). + std::string Source; + // The event ID of each level's entries, indexed by Level (Trace first): by default 1000 to 1005, Critical 1005 + std::array EventIds{ 1000, 1001, 1002, 1003, 1004, 1005 }; +}; + + ///////////////////////////////////////////////////////////////////////////// // TASWFileConfig // @@ -247,6 +263,20 @@ struct TASWLineConfig }; +///////////////////////////////////////////////////////////////////////////// +// TASWMemoryConfig +// +// How much a TASWMemoryLog keeps (TASWLogConfig::Memory). When a new line doesn't fit, the oldest lines go. +///////////////////////////////////////////////////////////////////////////// +struct TASWMemoryConfig +{ + std::size_t MaxLines = 1000; // 0 = no limit + // The most bytes of text kept, the lines' characters (not the memory each line takes beyond them); 0 = no limit. A + // line longer than this on its own is kept cut to fit, ending with " [cut]". + std::size_t MaxBytes = 0; +}; + + ///////////////////////////////////////////////////////////////////////////// // TASWShutdownConfig // @@ -280,6 +310,21 @@ struct TASWStartupConfig }; +///////////////////////////////////////////////////////////////////////////// +// TASWSyslogConfig +// +// How a TASWSyslogLog sends its entries to syslog (TASWLogConfig::Syslog). +///////////////////////////////////////////////////////////////////////////// +struct TASWSyslogConfig +{ + // The name syslog shows for each message, e.g. "myapp" in "myapp[1234]: ...". Empty: the executable's file name. + // Process-wide: the syslog logger initialized or reconfigured last sets it, and the application's own openlog() + // call replaces it (see TASWSyslogLog). + std::string Ident; + SyslogFacility Facility = SyslogFacility::User; // Sent with each message, so each syslog logger can have its own +}; + + ///////////////////////////////////////////////////////////////////////////// // TASWLogConfig // @@ -300,6 +345,9 @@ struct TASWLogConfig TASWFileConfig File; TASWAsyncConfig Async; TASWBacktraceConfig Backtrace; + TASWMemoryConfig Memory; + TASWSyslogConfig Syslog; + TASWEventLogConfig EventLog; // --- Pre-write Hook --- // A last chance for the application to drop or change each of its entries before the logger writes it, e.g. to @@ -337,6 +385,39 @@ struct TASWLogConfig // and counts the failures it leaves out in its next report of that kind (TASWLogError::SuppressedCount). Measured // on the logger's clock (see TASWLogBase::NowUTC()). 0 = report every failure. std::chrono::milliseconds ErrorReportInterval{ std::chrono::minutes(1) }; + + // --- Settings from the Environment --- + // Sets the settings below from environment variables named with 'prefix', e.g. ASWLOG_LEVEL=Debug, so that a + // deployed application can log more, or elsewhere, without being rebuilt. Nothing reads them unless the application + // calls this; call it on the config before Initialize(), after setting OnError. Their values replace what the code + // set; LEVEL seeds the minimum level at Initialize() only (Reconfigure() keeps the current level). Each logger can + // read its own variables, e.g. config.ApplyEnvironment("MYAPP_CONSOLE_"). A variable that isn't set, or is empty, + // leaves its setting as it is, and so does an invalid value, which is reported to OnError (or written to stderr if + // it's empty) as ErrorKind::InvalidSetting. Variables with the prefix that aren't keys are ignored. Returns false + // if a value was invalid. Keys and values (ignoring case and the spaces around a value): + // LEVEL Trace, Debug, Info, Warn, Error, Critical or Off InitialMinimumLevel + // CATEGORIES the category levels, e.g. "*=Info,Net=Debug"; process-wide, so set it under one prefix only + // (see TASWCategoryLog::ApplyLevels()) + // FOLDER a folder (UTF-8) File.FolderPath + // FILE a file name or path (UTF-8) File.FilePath + // FLUSH EveryWrite, OnNewLine, Manual or Periodic File.Flush + // SYNC_AT_LEVEL a level File.SyncToDiskAtLevel + // ASYNC 1, true, yes or on; 0, false, no or off Async.Enabled + // BACKTRACE how many entries to keep, 0 = none Backtrace.Capacity + // TIME_ZONE UTC or Local Line.TimestampZone + // TIME_PRECISION ms, us or ns (or Milliseconds, Microseconds...) Line.TimestampPrecision + // MULTILINE Preserve, Indent or Escape Line.Multiline + // SHOW_FUNCTION as ASYNC Line.ShowFunctionName + // SHOW_SOURCE as ASYNC Line.ShowSourceLine + // FORMAT Text (the built-in layout) or JSON Line.Formatter + // PATTERN a TASWPatternFormatter pattern, e.g. "{time} {message}", applied after FORMAT + // Throws std::bad_alloc if out of memory. + bool ApplyEnvironment(std::string_view prefix = "ASWLOG_"); + // Sets one of ApplyEnvironment()'s settings, 'key' (without a prefix, ignoring case), from 'value', e.g. from the + // application's command line or settings file. An empty value leaves the setting as it is. Reports an unknown key + // or an invalid value as ApplyEnvironment() does, and returns false for them. Throws std::bad_alloc if out of + // memory. + bool ApplySetting(std::string_view key, std::string_view value); }; } // namespace ASWLog diff --git a/ASWLog/ASWLog_ConsoleLog.cpp b/ASWLog/ASWLog_ConsoleLog.cpp index 902dd42..6395d77 100644 --- a/ASWLog/ASWLog_ConsoleLog.cpp +++ b/ASWLog/ASWLog_ConsoleLog.cpp @@ -38,6 +38,8 @@ limitations under the License. #include #endif //--------------------------------------------------------------------------- +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- namespace ASWLog { @@ -48,16 +50,11 @@ namespace constexpr std::string_view AnsiReset = "\x1b[0m"; // True if the environment variable 'name' is set to a non-empty value. -bool IsEnvironmentVariableSet(const char* name) noexcept +bool IsEnvironmentVariableSet(std::string_view name) noexcept { -#if defined(_WIN32) - // With no buffer, returns the size the value needs including its terminating null (1 for an empty value), or 0 - // if the variable isn't set. Avoids getenv(), which MSVC warns about. - return GetEnvironmentVariableA(name, nullptr, 0) > 1; -#else - const char* value = std::getenv(name); - return value != nullptr && *value != '\0'; -#endif + const auto value = Detail::ReadEnvironmentVariable(name); + + return value.has_value() && !value->empty(); } } // namespace diff --git a/ASWLog/ASWLog_DebuggerLog.cpp b/ASWLog/ASWLog_DebuggerLog.cpp new file mode 100644 index 0000000..2190822 --- /dev/null +++ b/ASWLog/ASWLog_DebuggerLog.cpp @@ -0,0 +1,155 @@ +/* ************************************************************************** +ASWLog_DebuggerLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_DebuggerLog.h" +//--------------------------------------------------------------------------- +// System includes here +#include +#include + +#if defined(_WIN32) +#include +#endif +//--------------------------------------------------------------------------- +#include "ASWLog_CrashHandler.h" +#include "ASWLog_Unicode.h" +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +namespace +{ + +// The platform has a debugger output to write to +#if defined(_WIN32) +constexpr bool HasDebuggerOutput = true; +#else +constexpr bool HasDebuggerOutput = false; +#endif + +} // namespace + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWDebuggerLog +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TASWDebuggerLog::~TASWDebuggerLog() +{ + Finalize(); +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::CloseUnlocked() +{ + m_IsOpen.store(false); + m_IsInitialized.store(false); + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::FlushUnlocked() +{ + return m_IsOpen.load(); +} + +//--------------------------------------------------------------------------- +TASWDebuggerLog& TASWDebuggerLog::GetInstance() +{ + // Never deleted, so the instance stays usable through static destruction. It is only finalized at exit, by a + // handler registered right after it is created, which runs where a static instance's destructor would have run. + static TASWDebuggerLog* const instance = new TASWDebuggerLog(); + [[maybe_unused]] static const int atExitResult = std::atexit([] { + instance->Finalize(); + }); + return *instance; +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::InitializeUnlocked() +{ + return OpenUnlocked(); +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::OpenUnlocked() +{ + m_IsOpen.store(true); + m_IsInitialized.store(true); + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::PrepareWriteUnlocked(std::chrono::system_clock::time_point /*now*/) +{ + // Before the line is formatted, so nothing is formatted where it would be dropped + return HasDebuggerOutput; +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::ShouldLog(Level level) const noexcept +{ + return HasDebuggerOutput && inherited::ShouldLog(level); +} + +//--------------------------------------------------------------------------- +bool TASWDebuggerLog::ShouldLog(const TASWLogRecord& record) const noexcept +{ + return HasDebuggerOutput && inherited::ShouldLog(record); +} + +//--------------------------------------------------------------------------- +void TASWDebuggerLog::WriteCrashLineDirect(std::string_view line) noexcept +{ +#if defined(_WIN32) + // Converted in a buffer on the stack, since the heap may be damaged in a crash + wchar_t wide[Detail::TCrashText::Capacity + 1]; + const int size = static_cast(line.size() < Detail::TCrashText::Capacity ? line.size() : Detail::TCrashText::Capacity); + const int wideSize = size > 0 ? MultiByteToWideChar(CP_UTF8, 0, line.data(), size, wide, static_cast(Detail::TCrashText::Capacity)) : 0; + wide[wideSize > 0 ? wideSize : 0] = L'\0'; + OutputDebugStringW(wide); +#else + static_cast(line); +#endif +} + +//--------------------------------------------------------------------------- +void TASWDebuggerLog::WriteLineUnlocked(Level /*level*/, std::string_view line, bool /*endsLine*/) +{ +#if defined(_WIN32) + OutputDebugStringW(UTF8ToWide(line).c_str()); +#else + static_cast(line); +#endif +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_DebuggerLog.h b/ASWLog/ASWLog_DebuggerLog.h new file mode 100644 index 0000000..5c5c6e6 --- /dev/null +++ b/ASWLog/ASWLog_DebuggerLog.h @@ -0,0 +1,93 @@ +/* ************************************************************************** +ASWLog_DebuggerLog.h +Author: Anthony S. West - ASW Software + +A light-weight logging tool. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_DebuggerLogH +#define ASWLog_DebuggerLogH +//--------------------------------------------------------------------------- +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_TextLogBase.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +///////////////////////////////////////////////////////////////////////////// +// TASWDebuggerLog +// +// Writes each line to the debugger's output: on Windows with OutputDebugStringW, so it shows in the RAD Studio Event +// Log, Visual Studio's Output window, and DebugView (which needs no debugger). With nobody listening, each line still +// costs some microseconds, so add it where it's wanted, e.g. in debug builds. Each line is formatted like a file +// logger's (TASWLogConfig::Line), including its line ending; the text is converted from UTF-8 to UTF-16. +// +// Other platforms have no debugger output, so there it writes nothing: the class builds everywhere, so the same code +// can add it on every platform, but there ShouldLog() is false and no line is formatted. (On Linux, see the syslog +// logger.) +// +// Takes the same TASWLogConfig as the other text loggers, ignoring its File settings. Thread-safe. +///////////////////////////////////////////////////////////////////////////// +class TASWDebuggerLog : public TASWTextLogBase +{ +private: + typedef TASWTextLogBase inherited; + +protected: // TASWTextLogBase hooks + bool CloseUnlocked() override; + bool FlushUnlocked() override; // Nothing is buffered: true while open + bool InitializeUnlocked() override; + bool OpenUnlocked() override; + bool PrepareWriteUnlocked(std::chrono::system_clock::time_point now) override; // False where it writes nothing + void WriteCrashLineDirect(std::string_view line) noexcept override; + void WriteLineUnlocked(Level level, std::string_view line, bool endsLine) override; + +protected: + std::string_view GetLoggerClassName() const noexcept override + { + return "TASWDebuggerLog"; + } + +public: // Static methods + // Singleton support for the common static instance. Never destroyed; finalized at exit in static destruction + // order, like TASWFileLog::GetInstance() (see there). + static TASWDebuggerLog& GetInstance(); + +public: + TASWDebuggerLog() = default; + // Writes the shutdown line (see TASWTextLogBase::Finalize()) + ~TASWDebuggerLog() override; + + TASWDebuggerLog(const TASWDebuggerLog&) = delete; + TASWDebuggerLog& operator=(const TASWDebuggerLog&) = delete; + + // As for the other loggers on Windows; always false where it writes nothing + bool ShouldLog(Level level) const noexcept override; + bool ShouldLog(const TASWLogRecord& record) const noexcept override; +}; + +} // namespace ASWLog + +#endif // #ifndef ASWLog_DebuggerLogH diff --git a/ASWLog/ASWLog_Interface.cpp b/ASWLog/ASWLog_Interface.cpp index 8d640ab..f36e96c 100644 --- a/ASWLog/ASWLog_Interface.cpp +++ b/ASWLog/ASWLog_Interface.cpp @@ -25,6 +25,7 @@ limitations under the License. #include "ASWLog_Interface.h" //--------------------------------------------------------------------------- // System includes here +#include //--------------------------------------------------------------------------- namespace ASWLog @@ -32,4 +33,40 @@ namespace ASWLog //--------------------------------------------------------------------------- +///////////////////////////////////////////////////////////////////////////// +// IASWLog +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +void IASWLog::WriteWide(Level level, TASWWideText message, const std::initializer_list* fields, + std::source_location loc, bool raw, bool forced) noexcept +{ + TASWLogRecord record = MakeRecord(level, {}, loc, raw, forced); + if (!ShouldLog(record)) + return; + + std::string utf8; + try + { + utf8 = message.ToUTF8(); + } + catch (...) + { + return; // Out of memory: the entry is dropped, as Write() drops one it can't write + } + + record.Message = utf8; + + std::span ownFields; + if (fields != nullptr) + { + ownFields = std::span(fields->begin(), fields->size()); + record.Fields = &ownFields; + } + + Write(record); +} + +//--------------------------------------------------------------------------- + } // namespace ASWLog diff --git a/ASWLog/ASWLog_Interface.h b/ASWLog/ASWLog_Interface.h index 5dc5439..e3cc936 100644 --- a/ASWLog/ASWLog_Interface.h +++ b/ASWLog/ASWLog_Interface.h @@ -40,6 +40,7 @@ limitations under the License. //--------------------------------------------------------------------------- #include "ASWLog_Config.h" #include "ASWLog_Types.h" +#include "ASWLog_Unicode.h" //--------------------------------------------------------------------------- namespace ASWLog @@ -55,16 +56,27 @@ struct TASWRuntimeFormat std::string_view Format; }; +// A wide format string known only at run time, made by RuntimeFormat() +struct TASWWideRuntimeFormat +{ + std::wstring_view Format; +}; + // Passes a format string that isn't a compile-time constant (e.g. one read from a file) to a *Fmt method, which checks // any other format string against its arguments at compile time. This one is checked when the entry is formatted // instead: a mismatch logs "[ASWLog format error: ] " (see TASWFormatString::FormatMessage()), // which isn't reported to TASWLogConfig::OnError, since the entry is still written. Like C++26's std::runtime_format, -// it holds a view of 'format', so the string must outlive the *Fmt call. +// it holds a view of 'format', so the string must outlive the *Fmt call. Also for a wide format string. [[nodiscard]] constexpr TASWRuntimeFormat RuntimeFormat(std::string_view format) noexcept { return TASWRuntimeFormat{ format }; } +[[nodiscard]] constexpr TASWWideRuntimeFormat RuntimeFormat(std::wstring_view format) noexcept +{ + return TASWWideRuntimeFormat{ format }; +} + ///////////////////////////////////////////////////////////////////////////// // TASWFormatString @@ -133,6 +145,70 @@ struct TASWFormatString }; +///////////////////////////////////////////////////////////////////////////// +// TASWWideFormatString +// +// A wide (wchar_t) format string plus the source location of the call that passed it, taken by the *Fmt methods like +// TASWFormatString: checked against the arguments at compile time, like std::wformat_string's, and formatted with +// std::format's wide functions, then converted to UTF-8. Its arguments must be formattable as wide text: numbers, wide +// strings, ASWLog::Wide() for char16_t text, and ASWLog::UTF8() for UTF-8 text (narrow strings aren't accepted). A +// format string known only at run time must be wrapped in RuntimeFormat(). +///////////////////////////////////////////////////////////////////////////// +template +struct TASWWideFormatString +{ + std::wstring_view Format; + std::source_location Location; + + template + requires std::convertible_to + consteval TASWWideFormatString(const T& format, std::source_location location = std::source_location::current()) noexcept + : Format(format), + Location(location) + { + // std::wformat_string's constructor fails to compile if the format string doesn't match Args + [[maybe_unused]] const std::wformat_string checkedFormat(Format); + } + + TASWWideFormatString(TASWWideRuntimeFormat format, std::source_location location = std::source_location::current()) noexcept + : Format(format.Format), + Location(location) + { + } + + // Formats the message from 'args', as UTF-8. Never throws: if formatting fails, returns "[ASWLog format error: + // ] " instead (see TASWFormatString::FormatMessage()). + [[nodiscard]] std::string FormatMessage(Args&... args) const noexcept + { + try + { + return WideToUTF8(std::vformat(Format, std::make_wformat_args(args ...))); + } + catch (const std::exception& error) + { + return DescribeFormatError(error.what()); + } + catch (...) + { + return DescribeFormatError("unknown exception"); + } + } + +private: + [[nodiscard]] std::string DescribeFormatError(std::string_view reason) const noexcept + { + try + { + return std::string("[ASWLog format error: ").append(reason).append("] ").append(WideToUTF8(Format)); + } + catch (...) + { + return {}; // Out of memory: log an empty entry rather than throw + } + } +}; + + ///////////////////////////////////////////////////////////////////////////// // IASWLog // @@ -160,6 +236,11 @@ class IASWLog return record; } + // Writes an entry whose message is wide text, converted to UTF-8 only if the logger would use the entry. 'fields' + // may be null. + void WriteWide(Level level, TASWWideText message, const std::initializer_list* fields, + std::source_location loc, bool raw, bool forced) noexcept; + // Writes an entry with fields: the record points to the list, which lives during the call void WriteWithFields(Level level, std::string_view message, std::initializer_list fields, std::source_location loc, bool raw, bool forced) noexcept @@ -357,6 +438,120 @@ class IASWLog WriteWithFields(Level::Critical, msg, fields, loc, false, false); } + // --- Non-virtual Inline Logging Methods With Wide Text --- + // As above, for a message of wide text: wchar_t (e.g. L"Opened", a std::wstring, or C++Builder's + // UnicodeString::c_str()) or char16_t (e.g. u"Opened"; see TASWWideText). The message is converted to UTF-8 only if + // the entry would be used (see ShouldLog()), so a filtered call converts nothing. Field keys and texts stay UTF-8. + inline void Log(Level level, TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(level, msg, nullptr, loc, false, false); + } + + inline void LogRaw(Level level, TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(level, msg, nullptr, loc, true, false); + } + + inline void LogForce(Level level, TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(level, msg, nullptr, loc, false, true); + } + + inline void LogForceRaw(Level level, TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(level, msg, nullptr, loc, true, true); + } + + inline void LogTrace(TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Trace, msg, nullptr, loc, false, false); + } + + inline void LogDebug(TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Debug, msg, nullptr, loc, false, false); + } + + inline void LogInfo(TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Info, msg, nullptr, loc, false, false); + } + + inline void LogWarn(TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Warn, msg, nullptr, loc, false, false); + } + + inline void LogError(TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Error, msg, nullptr, loc, false, false); + } + + inline void LogCritical(TASWWideText msg, std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Critical, msg, nullptr, loc, false, false); + } + + inline void Log(Level level, TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(level, msg, &fields, loc, false, false); + } + + inline void LogRaw(Level level, TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(level, msg, &fields, loc, true, false); + } + + inline void LogForce(Level level, TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(level, msg, &fields, loc, false, true); + } + + inline void LogForceRaw(Level level, TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(level, msg, &fields, loc, true, true); + } + + inline void LogTrace(TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Trace, msg, &fields, loc, false, false); + } + + inline void LogDebug(TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Debug, msg, &fields, loc, false, false); + } + + inline void LogInfo(TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Info, msg, &fields, loc, false, false); + } + + inline void LogWarn(TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Warn, msg, &fields, loc, false, false); + } + + inline void LogError(TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Error, msg, &fields, loc, false, false); + } + + inline void LogCritical(TASWWideText msg, std::initializer_list fields, + std::source_location loc = std::source_location::current()) noexcept + { + WriteWide(Level::Critical, msg, &fields, loc, false, false); + } + // --- Non-virtual Inline Template Format Methods --- // Each passes on the caller's source location, captured by TASWFormatString. The format string is checked against // the arguments at compile time. Use RuntimeFormat() to wrap run time format strings. A formatting error at run @@ -503,6 +698,148 @@ class IASWLog { LogFmt(Level::Critical, fields, fmt, std::forward(args) ...); } + + // --- Non-virtual Inline Template Format Methods With a Wide Format String --- + // As above, with a wchar_t format string, e.g. LogInfoFmt(L"Opened {} ({} bytes)", fileName, size), whose + // arguments are formatted as wide text (see TASWWideFormatString) and the message converted to UTF-8. Only when the + // entry would be written, as above. + template + inline void LogFmt(Level level, TASWWideFormatString...> fmt, Args&&... args) noexcept + { + if (ShouldLog(level)) + Log(level, fmt.FormatMessage(args ...), fmt.Location); + } + + template + inline void LogRawFmt(Level level, TASWWideFormatString...> fmt, Args&&... args) noexcept + { + if (ShouldLog(level)) + LogRaw(level, fmt.FormatMessage(args ...), fmt.Location); + } + + template + inline void LogForceFmt(Level level, TASWWideFormatString...> fmt, Args&&... args) noexcept + { + if (level != Level::Off && IsEnabled()) + LogForce(level, fmt.FormatMessage(args ...), fmt.Location); + } + + template + inline void LogForceRawFmt(Level level, TASWWideFormatString...> fmt, Args&&... args) noexcept + { + if (level != Level::Off && IsEnabled()) + LogForceRaw(level, fmt.FormatMessage(args ...), fmt.Location); + } + + template + inline void LogTraceFmt(TASWWideFormatString...> fmt, Args&&... args) noexcept + { + LogFmt(Level::Trace, fmt, std::forward(args) ...); + } + + template + inline void LogDebugFmt(TASWWideFormatString...> fmt, Args&&... args) noexcept + { + LogFmt(Level::Debug, fmt, std::forward(args) ...); + } + + template + inline void LogInfoFmt(TASWWideFormatString...> fmt, Args&&... args) noexcept + { + LogFmt(Level::Info, fmt, std::forward(args) ...); + } + + template + inline void LogWarnFmt(TASWWideFormatString...> fmt, Args&&... args) noexcept + { + LogFmt(Level::Warn, fmt, std::forward(args) ...); + } + + template + inline void LogErrorFmt(TASWWideFormatString...> fmt, Args&&... args) noexcept + { + LogFmt(Level::Error, fmt, std::forward(args) ...); + } + + template + inline void LogCriticalFmt(TASWWideFormatString...> fmt, Args&&... args) noexcept + { + LogFmt(Level::Critical, fmt, std::forward(args) ...); + } + + template + inline void LogFmt(Level level, std::initializer_list fields, TASWWideFormatString...> fmt, + Args&&... args) noexcept + { + if (ShouldLog(level)) + Log(level, fmt.FormatMessage(args ...), fields, fmt.Location); + } + + template + inline void LogRawFmt(Level level, std::initializer_list fields, + TASWWideFormatString...> fmt, Args&&... args) noexcept + { + if (ShouldLog(level)) + LogRaw(level, fmt.FormatMessage(args ...), fields, fmt.Location); + } + + template + inline void LogForceFmt(Level level, std::initializer_list fields, + TASWWideFormatString...> fmt, Args&&... args) noexcept + { + if (level != Level::Off && IsEnabled()) + LogForce(level, fmt.FormatMessage(args ...), fields, fmt.Location); + } + + template + inline void LogForceRawFmt(Level level, std::initializer_list fields, + TASWWideFormatString...> fmt, Args&&... args) noexcept + { + if (level != Level::Off && IsEnabled()) + LogForceRaw(level, fmt.FormatMessage(args ...), fields, fmt.Location); + } + + template + inline void LogTraceFmt(std::initializer_list fields, TASWWideFormatString...> fmt, + Args&&... args) noexcept + { + LogFmt(Level::Trace, fields, fmt, std::forward(args) ...); + } + + template + inline void LogDebugFmt(std::initializer_list fields, TASWWideFormatString...> fmt, + Args&&... args) noexcept + { + LogFmt(Level::Debug, fields, fmt, std::forward(args) ...); + } + + template + inline void LogInfoFmt(std::initializer_list fields, TASWWideFormatString...> fmt, + Args&&... args) noexcept + { + LogFmt(Level::Info, fields, fmt, std::forward(args) ...); + } + + template + inline void LogWarnFmt(std::initializer_list fields, TASWWideFormatString...> fmt, + Args&&... args) noexcept + { + LogFmt(Level::Warn, fields, fmt, std::forward(args) ...); + } + + template + inline void LogErrorFmt(std::initializer_list fields, TASWWideFormatString...> fmt, + Args&&... args) noexcept + { + LogFmt(Level::Error, fields, fmt, std::forward(args) ...); + } + + template + inline void LogCriticalFmt(std::initializer_list fields, TASWWideFormatString...> fmt, + Args&&... args) noexcept + { + LogFmt(Level::Critical, fields, fmt, std::forward(args) ...); + } }; } // namespace ASWLog diff --git a/ASWLog/ASWLog_Limiter.cpp b/ASWLog/ASWLog_Limiter.cpp new file mode 100644 index 0000000..42f1098 --- /dev/null +++ b/ASWLog/ASWLog_Limiter.cpp @@ -0,0 +1,102 @@ +/* ************************************************************************** +ASWLog_Limiter.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_Limiter.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +///////////////////////////////////////////////////////////////////////////// +// TASWLogLimiter +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +bool TASWLogLimiter::Allow() noexcept +{ + std::uint64_t refusedCount = 0; + + return Allow(refusedCount); +} + +//--------------------------------------------------------------------------- +bool TASWLogLimiter::Allow(std::uint64_t& refusedCount) noexcept +{ + // Only EveryInterval() needs the time + return Allow(refusedCount, m_Kind == Kind::EveryInterval ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point()); +} + +//--------------------------------------------------------------------------- +bool TASWLogLimiter::Allow(std::uint64_t& refusedCount, std::chrono::steady_clock::time_point now) noexcept +{ + refusedCount = 0; + + switch (m_Kind) + { + case Kind::Once: + // Without a write once it has allowed its call, so the calls after it don't contend for the cache line + return m_CallCount.load(std::memory_order_relaxed) == 0 && m_CallCount.exchange(1, std::memory_order_relaxed) == 0; + + case Kind::EveryN: + { + const std::uint64_t call = m_CallCount.fetch_add(1, std::memory_order_relaxed); + + if (call % m_N != 0) + return false; + + refusedCount = call > 0 ? m_N - 1 : 0; + + return true; + } + + case Kind::EveryInterval: + { + if (m_IntervalTicks <= 0) + return true; + + const auto nowTicks = now.time_since_epoch().count(); + auto nextAllowed = m_NextAllowedTicks.load(std::memory_order_relaxed); + constexpr auto MaxTicks = std::numeric_limits::max(); + // The time from which the call after this one is allowed, at most the clock's end + const auto newNextAllowed = nowTicks > 0 && m_IntervalTicks > MaxTicks - nowTicks ? MaxTicks : nowTicks + m_IntervalTicks; + + // Of the calls that see the interval over, only the one that moves the next allowed time on is allowed + if (nowTicks < nextAllowed || !m_NextAllowedTicks.compare_exchange_strong(nextAllowed, newNextAllowed, std::memory_order_relaxed)) + { + m_RefusedCount.fetch_add(1, std::memory_order_relaxed); + return false; + } + + refusedCount = m_RefusedCount.exchange(0, std::memory_order_relaxed); + + return true; + } + } + + return false; +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_Limiter.h b/ASWLog/ASWLog_Limiter.h new file mode 100644 index 0000000..8bcf342 --- /dev/null +++ b/ASWLog/ASWLog_Limiter.h @@ -0,0 +1,186 @@ +/* ************************************************************************** +ASWLog_Limiter.h +Author: Anthony S. West - ASW Software + +A logging library. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_LimiterH +#define ASWLog_LimiterH +//--------------------------------------------------------------------------- +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_Fields.h" +#include "ASWLog_Types.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +///////////////////////////////////////////////////////////////////////////// +// TASWLogLimiter +// +// Decides which of the calls at one place in the code may log, so that an entry that can repeat often (e.g. in a loop, +// or for every failed retry) doesn't flood the log: only the first call (Once()), every nth call (EveryN()), or at +// most one call per interval (EveryInterval()). Lock-free and thread-safe: calls from several threads share one +// limiter, and exactly the calls it allows are allowed. Usually kept in a static at the place it limits, which is what +// the ASWLOG_ONCE(), ASWLOG_EVERY_N() and ASWLOG_EVERY_INTERVAL() macros below do; it can also be used on its own: +// +// static ASWLog::TASWLogLimiter limiter = ASWLog::TASWLogLimiter::EveryInterval(std::chrono::seconds(5)); +// if (log.ShouldLog(ASWLog::Level::Warn) && limiter.Allow()) +// log.LogWarnFmt("Retrying {}", host); +// +// Check the level first, as above, so that calls the logger would drop don't count against the limit. +///////////////////////////////////////////////////////////////////////////// +class TASWLogLimiter +{ +private: + enum class Kind + { + Once, + EveryN, + EveryInterval, + }; + + Kind m_Kind; + std::uint64_t m_N; // EveryN's n + std::chrono::steady_clock::rep m_IntervalTicks; // EveryInterval's interval, in steady_clock ticks + std::atomic m_CallCount{ 0 }; // Once and EveryN: the calls so far + // EveryInterval: the time (in steady_clock ticks) from which the next call is allowed, and the calls refused since + // the last one allowed + std::atomic m_NextAllowedTicks{ std::numeric_limits::min() }; + std::atomic m_RefusedCount{ 0 }; + + constexpr TASWLogLimiter(Kind kind, std::uint64_t n, std::chrono::steady_clock::rep intervalTicks) noexcept + : m_Kind(kind), + m_N(n), + m_IntervalTicks(intervalTicks) + { + } + +public: // Static methods + // Allows at most one call per 'interval', measured on std::chrono::steady_clock from the call it allowed last: the + // first call, then the first one after the interval has passed. An interval of 0 or less allows every call. + [[nodiscard]] static constexpr TASWLogLimiter EveryInterval(std::chrono::steady_clock::duration interval) noexcept + { + return TASWLogLimiter(Kind::EveryInterval, 0, interval.count()); + } + + // Allows every nth call: the 1st, the n+1st, the 2n+1st, and so on. An n of 0 or 1 allows every call. + [[nodiscard]] static constexpr TASWLogLimiter EveryN(std::uint64_t n) noexcept + { + return TASWLogLimiter(Kind::EveryN, n > 0 ? n : 1, 0); + } + + // Allows the first call only + [[nodiscard]] static constexpr TASWLogLimiter Once() noexcept + { + return TASWLogLimiter(Kind::Once, 0, 0); + } + +public: + // Not copyable (the factories above are used to initialize one in place, e.g. 'static auto l = EveryN(10);') + TASWLogLimiter(const TASWLogLimiter&) = delete; + TASWLogLimiter& operator=(const TASWLogLimiter&) = delete; + + // True if this call may log + bool Allow() noexcept; + // The same; when it allows the call, 'refusedCount' gets how many calls it refused since the one it allowed before + // (0 for the first, and always 0 for Once()), otherwise 0. With EveryInterval(), a call refused on another thread + // at the moment this one is allowed may be counted here or with the next allowed call (each refused call once). + bool Allow(std::uint64_t& refusedCount) noexcept; + // The same at the time 'now' (for EveryInterval(); the others ignore it), e.g. for tests + bool Allow(std::uint64_t& refusedCount, std::chrono::steady_clock::time_point now) noexcept; +}; + +} // namespace ASWLog + +//--------------------------------------------------------------------------- +// Rate-limited logging at one place in the code. Each macro keeps a static TASWLogLimiter where it is written, and +// logs through logger.LogFmt(level, ...), so the arguments after 'level' are those of LogFmt(): a format string and its +// arguments, optionally preceded by fields, e.g. +// +// ASWLOG_ONCE(log, ASWLog::Level::Warn, "Config file {} not found, using defaults", path); +// ASWLOG_EVERY_N(log, 1000, ASWLog::Level::Debug, "Processed {} items", count); +// ASWLOG_EVERY_INTERVAL(log, std::chrono::seconds(5), ASWLog::Level::Warn, {{"host", host}}, "Retrying"); +// +// The message is a format string, so write a literal brace as {{ or }}. The entry keeps the caller's source location. +// Only calls the logger would write count against the limit: ShouldLog(level) is checked first, so a call the level +// filters out costs what a filtered LogFmt() call does, and e.g. ASWLOG_ONCE() at Debug logs the first time the +// logger's level lets Debug through. The arguments are only formatted for an entry that is written. +// ASWLOG_EVERY_INTERVAL() adds a field "suppressed" with the number of calls it skipped since its previous entry, if +// any. 'n' and 'interval' are read the first time the macro runs. In a template, each instantiation has a limiter of +// its own. +//--------------------------------------------------------------------------- + +// Logs the first time it would be written +#define ASWLOG_ONCE(logger, level, ...) \ + do \ + { \ + static ::ASWLog::TASWLogLimiter aswlogLimiter = ::ASWLog::TASWLogLimiter::Once(); \ + auto& aswlogLogger = (logger); \ + const ::ASWLog::Level aswlogLevel = (level); \ + if (aswlogLogger.ShouldLog(aswlogLevel) && aswlogLimiter.Allow()) \ + aswlogLogger.LogFmt(aswlogLevel, __VA_ARGS__); \ + } \ + while (false) + +// Logs every nth time it would be written, starting with the first +#define ASWLOG_EVERY_N(logger, n, level, ...) \ + do \ + { \ + static ::ASWLog::TASWLogLimiter aswlogLimiter = ::ASWLog::TASWLogLimiter::EveryN(n); \ + auto& aswlogLogger = (logger); \ + const ::ASWLog::Level aswlogLevel = (level); \ + if (aswlogLogger.ShouldLog(aswlogLevel) && aswlogLimiter.Allow()) \ + aswlogLogger.LogFmt(aswlogLevel, __VA_ARGS__); \ + } \ + while (false) + +// Logs at most once per 'interval' (a std::chrono duration), with a "suppressed" field after skipped calls +#define ASWLOG_EVERY_INTERVAL(logger, interval, level, ...) \ + do \ + { \ + static ::ASWLog::TASWLogLimiter aswlogLimiter = ::ASWLog::TASWLogLimiter::EveryInterval(interval); \ + auto& aswlogLogger = (logger); \ + const ::ASWLog::Level aswlogLevel = (level); \ + ::std::uint64_t aswlogSuppressed = 0; \ + if (aswlogLogger.ShouldLog(aswlogLevel) && aswlogLimiter.Allow(aswlogSuppressed)) \ + { \ + if (aswlogSuppressed == 0) \ + { \ + aswlogLogger.LogFmt(aswlogLevel, __VA_ARGS__); \ + } \ + else \ + { \ + const ::ASWLog::TASWLogScope aswlogScope{ { "suppressed", aswlogSuppressed } }; \ + aswlogLogger.LogFmt(aswlogLevel, __VA_ARGS__); \ + } \ + } \ + } \ + while (false) + +//--------------------------------------------------------------------------- +#endif // #ifndef ASWLog_LimiterH diff --git a/ASWLog/ASWLog_MemoryLog.cpp b/ASWLog/ASWLog_MemoryLog.cpp new file mode 100644 index 0000000..e387c00 --- /dev/null +++ b/ASWLog/ASWLog_MemoryLog.cpp @@ -0,0 +1,188 @@ +/* ************************************************************************** +ASWLog_MemoryLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_MemoryLog.h" +//--------------------------------------------------------------------------- +// System includes here +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWMemoryLog +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TASWMemoryLog::~TASWMemoryLog() +{ + Finalize(); +} + +//--------------------------------------------------------------------------- +/* + TASWMemoryLog::ApplyLimitsUnlocked + + Takes the limits from the current config (TASWLogConfig::Memory), dropping the oldest lines that no longer fit +*/ +void TASWMemoryLog::ApplyLimitsUnlocked() +{ + const auto& config = GetConfigUnlocked().Memory; + std::lock_guard lock(m_LinesMutex); + m_MaxLines = config.MaxLines; + m_MaxBytes = config.MaxBytes; + DropOldestLines(); +} + +//--------------------------------------------------------------------------- +void TASWMemoryLog::Clear() noexcept +{ + std::lock_guard lock(m_LinesMutex); + m_Lines.clear(); + m_ByteCount = 0; +} + +//--------------------------------------------------------------------------- +bool TASWMemoryLog::CloseUnlocked() +{ + m_IsOpen.store(false); + m_IsInitialized.store(false); + + return true; +} + +//--------------------------------------------------------------------------- +// Drops the oldest lines until the kept ones fit the limits +void TASWMemoryLog::DropOldestLines() noexcept +{ + while (!m_Lines.empty() && ((m_MaxLines > 0 && m_Lines.size() > m_MaxLines) || (m_MaxBytes > 0 && m_ByteCount > m_MaxBytes))) + { + m_ByteCount -= m_Lines.front().size(); + m_Lines.pop_front(); + } +} + +//--------------------------------------------------------------------------- +bool TASWMemoryLog::FlushUnlocked() +{ + return m_IsOpen.load(); +} + +//--------------------------------------------------------------------------- +std::size_t TASWMemoryLog::GetLineCount() const noexcept +{ + std::lock_guard lock(m_LinesMutex); + return m_Lines.size(); +} + +//--------------------------------------------------------------------------- +std::vector TASWMemoryLog::GetLines() const +{ + std::lock_guard lock(m_LinesMutex); + return std::vector(m_Lines.begin(), m_Lines.end()); +} + +//--------------------------------------------------------------------------- +TASWMemoryLines TASWMemoryLog::GetLinesSince(std::uint64_t sequence) const +{ + TASWMemoryLines result; + std::lock_guard lock(m_LinesMutex); + + // The first line ever kept is 1 + const std::uint64_t first = std::max(sequence, 1); + const std::uint64_t oldestKept = m_NextSequence - m_Lines.size(); + result.NextSequence = m_NextSequence; + + if (first < oldestKept) + result.MissedCount = oldestKept - first; + + if (first < m_NextSequence) + { + const auto skipped = static_cast(std::max(first, oldestKept) - oldestKept); + result.Lines.assign(m_Lines.begin() + static_cast(skipped), m_Lines.end()); + } + + return result; +} + +//--------------------------------------------------------------------------- +bool TASWMemoryLog::InitializeUnlocked() +{ + ApplyLimitsUnlocked(); + + return OpenUnlocked(); +} + +//--------------------------------------------------------------------------- +bool TASWMemoryLog::OpenUnlocked() +{ + m_IsOpen.store(true); + m_IsInitialized.store(true); + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWMemoryLog::ReconfigureUnlocked(const TASWLogConfig& /*previous*/) +{ + ApplyLimitsUnlocked(); + + return true; +} + +//--------------------------------------------------------------------------- +void TASWMemoryLog::WriteLineUnlocked(Level /*level*/, std::string_view line, bool endsLine) +{ + // Kept without the line ending the formatted line ends with + if (endsLine) + { + const std::string_view ending = GetConfigUnlocked().Line.Ending == LineEnding::CRLF ? "\r\n" : "\n"; + if (line.ends_with(ending)) + line.remove_suffix(ending.size()); + } + + // Made before taking the lock; a failed allocation drops the entry (see TASWTextLogBase::WriteRecord()) + std::string text(line); + std::lock_guard lock(m_LinesMutex); + + if (m_MaxBytes > 0) + Detail::CutToFit(text, m_MaxBytes); + + const std::size_t size = text.size(); + m_Lines.push_back(std::move(text)); + m_ByteCount += size; + ++m_NextSequence; + DropOldestLines(); +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_MemoryLog.h b/ASWLog/ASWLog_MemoryLog.h new file mode 100644 index 0000000..b0bc05c --- /dev/null +++ b/ASWLog/ASWLog_MemoryLog.h @@ -0,0 +1,130 @@ +/* ************************************************************************** +ASWLog_MemoryLog.h +Author: Anthony S. West - ASW Software + +A light-weight logging tool. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_MemoryLogH +#define ASWLog_MemoryLogH +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_TextLogBase.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +///////////////////////////////////////////////////////////////////////////// +// TASWMemoryLines struct +// +// The lines TASWMemoryLog::GetLinesSince() returns. +///////////////////////////////////////////////////////////////////////////// +struct TASWMemoryLines +{ + std::vector Lines; // Oldest first + std::uint64_t NextSequence = 1; // The sequence number to pass to the next GetLinesSince() + std::uint64_t MissedCount = 0; // Lines from the asked sequence number on that are no longer kept (dropped, cleared) +}; + + +///////////////////////////////////////////////////////////////////////////// +// TASWMemoryLog +// +// A text logger that keeps its newest lines in memory instead of writing them out, e.g. for an in-app log viewer, +// for a crash report (see HandleCrash()), or for tests of code that logs. Each line is formatted like a file logger's +// (TASWLogConfig::Line, including its formatter) and kept without its line ending; a LogRaw() entry written as is is +// kept as given. TASWLogConfig::Memory sets how many lines and bytes it keeps; the oldest go first. Like the other text +// loggers, it writes the startup lines (TASWLogConfig::Startup) and the shutdown line, and supports asynchronous +// writing, OnLogEntry, OnBeforeWrite and the backtrace. +// +// Each kept line has a sequence number, from 1, so a viewer can fetch only the new ones, e.g. on a timer: +// const auto newLines = memoryLog.GetLinesSince(nextSequence); +// nextSequence = newLines.NextSequence; +// +// Thread-safe. Close() keeps the lines (entries logged while closed are dropped); Clear() forgets them. +///////////////////////////////////////////////////////////////////////////// +class TASWMemoryLog : public TASWTextLogBase +{ +private: + typedef TASWTextLogBase inherited; + +private: + // The kept lines, guarded by m_LinesMutex (lock order: m_Mutex before it), so reading doesn't wait for a write + mutable Detail::TMutex m_LinesMutex; + std::deque m_Lines; // Oldest first + std::size_t m_ByteCount = 0; // Of the kept lines' text + std::uint64_t m_NextSequence = 1; // The next line's; the oldest kept line's is m_NextSequence - m_Lines.size() + std::size_t m_MaxLines = 0; // TASWMemoryConfig, as last applied; 0 = no limit + std::size_t m_MaxBytes = 0; + +private: + void ApplyLimitsUnlocked(); + void DropOldestLines() noexcept; // Holding m_LinesMutex + +protected: + bool CloseUnlocked() override; + bool FlushUnlocked() override; + + std::string_view GetLoggerClassName() const noexcept override + { + return "TASWMemoryLog"; + } + + bool InitializeUnlocked() override; + bool OpenUnlocked() override; + bool ReconfigureUnlocked(const TASWLogConfig& previous) override; + void WriteLineUnlocked(Level level, std::string_view line, bool endsLine) override; + +public: + TASWMemoryLog() = default; + // Writes the shutdown line (see TASWTextLogBase::Finalize()) + ~TASWMemoryLog() override; + + TASWMemoryLog(const TASWMemoryLog&) = delete; + TASWMemoryLog& operator=(const TASWMemoryLog&) = delete; + + // Forgets the kept lines. The sequence numbers go on (see GetLinesSince()). + void Clear() noexcept; + + // How many lines are kept + std::size_t GetLineCount() const noexcept; + + // A copy of the kept lines, oldest first. Throws std::bad_alloc if they can't be copied. + std::vector GetLines() const; + + // A copy of the kept lines whose sequence number is 'sequence' or higher (the first line ever kept is 1), the + // sequence number to pass next time, and how many lines from 'sequence' on are no longer kept. Throws + // std::bad_alloc if they can't be copied. + TASWMemoryLines GetLinesSince(std::uint64_t sequence) const; +}; + +} // namespace ASWLog + +#endif // #ifndef ASWLog_MemoryLogH diff --git a/ASWLog/ASWLog_NullLog.cpp b/ASWLog/ASWLog_NullLog.cpp new file mode 100644 index 0000000..506f7af --- /dev/null +++ b/ASWLog/ASWLog_NullLog.cpp @@ -0,0 +1,190 @@ +/* ************************************************************************** +ASWLog_NullLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_NullLog.h" +//--------------------------------------------------------------------------- +// System includes here +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_Version.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWNullLog +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TASWNullLog::TASWNullLog() + : m_Config(std::make_shared()) +{ +} + +//--------------------------------------------------------------------------- +bool TASWNullLog::Close() noexcept +{ + return true; +} + +//--------------------------------------------------------------------------- +void TASWNullLog::DumpBacktrace() noexcept +{ +} + +//--------------------------------------------------------------------------- +bool TASWNullLog::Flush() noexcept +{ + return true; +} + +//--------------------------------------------------------------------------- +std::shared_ptr TASWNullLog::GetConfig() const noexcept +{ + std::lock_guard lock(m_ConfigMutex); + return m_Config; +} + +//--------------------------------------------------------------------------- +std::string TASWNullLog::GetFullVersionStr() const +{ + return std::format("TASWNullLog - Base version {}", GetVersionStr()); +} + +//--------------------------------------------------------------------------- +TASWNullLog& TASWNullLog::GetInstance() +{ + // Never deleted, so the instance stays usable through static destruction (it has nothing to finalize) + static TASWNullLog* const instance = new TASWNullLog(); + return *instance; +} + +//--------------------------------------------------------------------------- +Level TASWNullLog::GetMinimumLevel() const noexcept +{ + return m_MinimumLevel.load(std::memory_order_relaxed); +} + +//--------------------------------------------------------------------------- +std::string_view TASWNullLog::GetVersionStr() const noexcept +{ + return Version; // See ASWLog_Version.h +} + +//--------------------------------------------------------------------------- +bool TASWNullLog::Initialize(const TASWLogConfig& config) noexcept +{ + if (!StoreConfig(config)) + return false; + + SetMinimumLevel(config.InitialMinimumLevel); + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWNullLog::IsEnabled() const noexcept +{ + return false; +} + +//--------------------------------------------------------------------------- +bool TASWNullLog::IsOpen() const noexcept +{ + return true; +} + +//--------------------------------------------------------------------------- +bool TASWNullLog::Open() noexcept +{ + return true; +} + +//--------------------------------------------------------------------------- +bool TASWNullLog::Reconfigure(const TASWLogConfig& config) noexcept +{ + return StoreConfig(config); +} + +//--------------------------------------------------------------------------- +void TASWNullLog::SetEnabled(bool /*enabled*/) noexcept +{ +} + +//--------------------------------------------------------------------------- +void TASWNullLog::SetMinimumLevel(Level level) noexcept +{ + m_MinimumLevel.store(level, std::memory_order_relaxed); +} + +//--------------------------------------------------------------------------- +bool TASWNullLog::ShouldLog(Level /*level*/) const noexcept +{ + return false; +} + +//--------------------------------------------------------------------------- +bool TASWNullLog::ShouldLog(const TASWLogRecord& /*record*/) const noexcept +{ + return false; +} + +//--------------------------------------------------------------------------- +/* + TASWNullLog::StoreConfig + + Replaces the kept config with a copy of 'config'. The previous one is released after the lock, since destroying it + can run other code (e.g. the destructors of its callbacks' captures). False if the copy can't be made. +*/ +bool TASWNullLog::StoreConfig(const TASWLogConfig& config) noexcept +{ + try + { + auto snapshot = std::make_shared(config); + + { + std::lock_guard lock(m_ConfigMutex); + m_Config.swap(snapshot); + } + + return true; + } + catch (...) + { + return false; + } +} + +//--------------------------------------------------------------------------- +void TASWNullLog::Write(const TASWLogRecord& /*record*/) noexcept +{ +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_NullLog.h b/ASWLog/ASWLog_NullLog.h new file mode 100644 index 0000000..52a654e --- /dev/null +++ b/ASWLog/ASWLog_NullLog.h @@ -0,0 +1,120 @@ +/* ************************************************************************** +ASWLog_NullLog.h +Author: Anthony S. West - ASW Software + +A light-weight logging tool. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_NullLogH +#define ASWLog_NullLogH +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_Interface.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +///////////////////////////////////////////////////////////////////////////// +// TASWNullLog +// +// A logger that writes nothing, e.g. to turn logging off behind an IASWLog& without changing the code that logs, or to +// pass to code under test: +// void Sync(ASWLog::IASWLog& log = ASWLog::TASWNullLog::GetInstance()); +// +// ShouldLog() and IsEnabled() are always false (SetEnabled() is ignored), so no logging call formats, stamps or copies +// anything, the LogForce*() ones included: a call costs one virtual call. Everything else behaves like a working +// logger, so code that manages its logger keeps working: Initialize(), Reconfigure(), Open(), Close() and Flush() +// return true, IsOpen() is true, GetConfig() returns the config last passed to Initialize() or Reconfigure() (a default +// config before), and the minimum level reads back as set (Initialize() seeds it from InitialMinimumLevel). +// +// Thread-safe. +///////////////////////////////////////////////////////////////////////////// +class TASWNullLog : public IASWLog +{ +private: + typedef IASWLog inherited; + +private: + mutable std::mutex m_ConfigMutex; + std::shared_ptr m_Config; // Never null; guarded by m_ConfigMutex + std::atomic m_MinimumLevel{ Level::Info }; + +private: + bool StoreConfig(const TASWLogConfig& config) noexcept; + +public: // Static methods + // A shared instance, e.g. for a default argument. Never destroyed, so it stays usable through static destruction. + static TASWNullLog& GetInstance(); + +public: + // Throws std::bad_alloc if the default config can't be made + TASWNullLog(); + ~TASWNullLog() override = default; + + TASWNullLog(const TASWNullLog&) = delete; + TASWNullLog& operator=(const TASWNullLog&) = delete; + + std::string_view GetVersionStr() const noexcept override; + std::string GetFullVersionStr() const override; + + // The config last passed to Initialize() or Reconfigure(), or a default config + std::shared_ptr GetConfig() const noexcept override; + + // Keep the config (see GetConfig()) and return true; false only if it can't be copied (out of memory). Initialize() + // also sets the minimum level to config.InitialMinimumLevel. + bool Initialize(const TASWLogConfig& config) noexcept override; + bool Reconfigure(const TASWLogConfig& config) noexcept override; + + // Do nothing and return true + bool Open() noexcept override; + bool Close() noexcept override; + bool Flush() noexcept override; + bool IsOpen() const noexcept override; + + // Does nothing (there is no backtrace) + void DumpBacktrace() noexcept override; + + // Always false: SetEnabled() is ignored, so the LogForce*Fmt() methods never format either + bool IsEnabled() const noexcept override; + void SetEnabled(bool enabled) noexcept override; + + // Read back as set, though nothing is written at any level + Level GetMinimumLevel() const noexcept override; + void SetMinimumLevel(Level level) noexcept override; + + // Always false + bool ShouldLog(Level level) const noexcept override; + bool ShouldLog(const TASWLogRecord& record) const noexcept override; + + // Does nothing + void Write(const TASWLogRecord& record) noexcept override; +}; + +} // namespace ASWLog + +#endif // #ifndef ASWLog_NullLogH diff --git a/ASWLog/ASWLog_SyslogLog.cpp b/ASWLog/ASWLog_SyslogLog.cpp new file mode 100644 index 0000000..cce8aca --- /dev/null +++ b/ASWLog/ASWLog_SyslogLog.cpp @@ -0,0 +1,269 @@ +/* ************************************************************************** +ASWLog_SyslogLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_SyslogLog.h" +//--------------------------------------------------------------------------- +// System includes here +#include + +#if !defined(_WIN32) +#include +#include +#include + +#include +#endif +//--------------------------------------------------------------------------- +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +namespace +{ + +// The platform has a syslog to send to +#if defined(_WIN32) +constexpr bool HasSyslog = false; +#else +constexpr bool HasSyslog = true; +#endif + +// The severities of RFC 5424 that the levels map to, the same values as 's LOG_CRIT to LOG_DEBUG +constexpr int SeverityCritical = 2; +constexpr int SeverityError = 3; +constexpr int SeverityWarning = 4; +constexpr int SeverityInfo = 6; +constexpr int SeverityDebug = 7; + +#if !defined(_WIN32) +static_assert(SeverityCritical == LOG_CRIT && SeverityError == LOG_ERR && SeverityWarning == LOG_WARNING); +static_assert(SeverityInfo == LOG_INFO && SeverityDebug == LOG_DEBUG); +static_assert(LOG_USER == static_cast(SyslogFacility::User) << 3); +static_assert(LOG_DAEMON == static_cast(SyslogFacility::Daemon) << 3); +static_assert(LOG_LOCAL0 == static_cast(SyslogFacility::Local0) << 3); +static_assert(LOG_LOCAL7 == static_cast(SyslogFacility::Local7) << 3); +#endif + +//--------------------------------------------------------------------------- + +int ToSyslogFacility(SyslogFacility facility) noexcept; +int ToSyslogPriority(Level level, SyslogFacility facility) noexcept; + +//--------------------------------------------------------------------------- + +/* + ToSyslogFacility + + The facility as syslog() and openlog() take it, e.g. LOG_USER +*/ +int ToSyslogFacility(SyslogFacility facility) noexcept +{ + return static_cast(facility) << 3; +} + +/* + ToSyslogPriority + + The priority syslog() takes for an entry at 'level': the facility and the level's severity combined +*/ +int ToSyslogPriority(Level level, SyslogFacility facility) noexcept +{ + int severity = SeverityDebug; // Trace and Debug (an entry at Off is never written) + + switch (level) + { + case Level::Info: + severity = SeverityInfo; + break; + + case Level::Warn: + severity = SeverityWarning; + break; + + case Level::Error: + severity = SeverityError; + break; + + case Level::Critical: + severity = SeverityCritical; + break; + + default: + break; + } + + return ToSyslogFacility(facility) | severity; +} + +} // namespace + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWSyslogLog +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TASWSyslogLog::~TASWSyslogLog() +{ + Finalize(); +} + +//--------------------------------------------------------------------------- +bool TASWSyslogLog::CloseUnlocked() +{ + m_IsOpen.store(false); + m_IsInitialized.store(false); + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWSyslogLog::FlushUnlocked() +{ + return m_IsOpen.load(); +} + +//--------------------------------------------------------------------------- +TASWSyslogLog& TASWSyslogLog::GetInstance() +{ + // Never deleted, so the instance stays usable through static destruction. It is only finalized at exit, by a + // handler registered right after it is created, which runs where a static instance's destructor would have run. + static TASWSyslogLog* const instance = new TASWSyslogLog(); + [[maybe_unused]] static const int atExitResult = std::atexit([] { + instance->Finalize(); + }); + return *instance; +} + +//--------------------------------------------------------------------------- +bool TASWSyslogLog::InitializeUnlocked() +{ + OpenSyslogFromConfigUnlocked(); + + return OpenUnlocked(); +} + +//--------------------------------------------------------------------------- +bool TASWSyslogLog::OpenUnlocked() +{ + m_IsOpen.store(true); + m_IsInitialized.store(true); + + return true; +} + +//--------------------------------------------------------------------------- +// Calls OpenSyslogUnlocked() with the current config's Syslog settings +void TASWSyslogLog::OpenSyslogFromConfigUnlocked() +{ + const auto& config = GetConfigUnlocked().Syslog; + const std::string ident = config.Ident.empty() ? PathToUTF8String(GetExecutablePath().filename()) : config.Ident; + + OpenSyslogUnlocked(ident, config.Facility); +} + +//--------------------------------------------------------------------------- +void TASWSyslogLog::OpenSyslogUnlocked(const std::string& ident, SyslogFacility facility) +{ +#if defined(_WIN32) + static_cast(ident); + static_cast(facility); +#else + // openlog() keeps the pointer, so the ident is copied and the copy kept until the next call here replaces it. The C + // library uses the ident only while it holds its syslog lock, as openlog() does, so once openlog() has returned + // with the new copy, the previous one is no longer used. The current copy and the lock are never destroyed, so + // syslog() can still use the ident at exit. + static std::mutex* const identMutex = new std::mutex(); + static std::string* identCopy = nullptr; + + auto* newCopy = new std::string(ident); + std::lock_guard lock(*identMutex); + openlog(newCopy->c_str(), LOG_PID, ToSyslogFacility(facility)); + delete std::exchange(identCopy, newCopy); +#endif +} + +//--------------------------------------------------------------------------- +bool TASWSyslogLog::PrepareWriteUnlocked(std::chrono::system_clock::time_point /*now*/) +{ + // Before the line is formatted, so nothing is formatted where it would be dropped + return HasSyslog; +} + +//--------------------------------------------------------------------------- +bool TASWSyslogLog::ReconfigureUnlocked(const TASWLogConfig& /*previous*/) +{ + OpenSyslogFromConfigUnlocked(); + + return true; +} + +//--------------------------------------------------------------------------- +void TASWSyslogLog::SendToSyslogUnlocked(int priority, std::string_view message) +{ +#if defined(_WIN32) + static_cast(priority); + static_cast(message); +#else + // As "%.*s", so the message isn't read as a format string and needs no terminating null + constexpr std::size_t maxSize = static_cast(std::numeric_limits::max()); + const int size = static_cast(message.size() < maxSize ? message.size() : maxSize); + syslog(priority, "%.*s", size, message.data()); +#endif +} + +//--------------------------------------------------------------------------- +bool TASWSyslogLog::ShouldLog(Level level) const noexcept +{ + return HasSyslog && inherited::ShouldLog(level); +} + +//--------------------------------------------------------------------------- +bool TASWSyslogLog::ShouldLog(const TASWLogRecord& record) const noexcept +{ + return HasSyslog && inherited::ShouldLog(record); +} + +//--------------------------------------------------------------------------- +void TASWSyslogLog::WriteLineUnlocked(Level level, std::string_view line, bool endsLine) +{ + const auto& config = GetConfigUnlocked(); + + // Sent without the line ending the formatted line ends with + if (endsLine) + { + const std::string_view ending = config.Line.Ending == LineEnding::CRLF ? "\r\n" : "\n"; + if (line.ends_with(ending)) + line.remove_suffix(ending.size()); + } + + SendToSyslogUnlocked(ToSyslogPriority(level, config.Syslog.Facility), line); +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_SyslogLog.h b/ASWLog/ASWLog_SyslogLog.h new file mode 100644 index 0000000..013dec8 --- /dev/null +++ b/ASWLog/ASWLog_SyslogLog.h @@ -0,0 +1,125 @@ +/* ************************************************************************** +ASWLog_SyslogLog.h +Author: Anthony S. West - ASW Software + +A light-weight logging tool. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_SyslogLogH +#define ASWLog_SyslogLogH +//--------------------------------------------------------------------------- +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_TextLogBase.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +///////////////////////////////////////////////////////////////////////////// +// TASWSyslogLog +// +// Sends each line to the system log with syslog() from the C library; on systemd hosts it lands in the journal +// (journalctl). The level becomes the message's priority: Trace and Debug LOG_DEBUG, Info LOG_INFO, Warn LOG_WARNING, +// Error LOG_ERR, Critical LOG_CRIT; the facility is TASWLogConfig::Syslog.Facility. Each line is formatted like a file +// logger's (TASWLogConfig::Line) and sent without its line ending; line breaks inside it are sent as Line.Multiline +// says (with the default, Preserve, journald keeps them, and rsyslog writes them as #012). ASWLog doesn't limit a +// message's size, but the syslog daemon may cut a very long one. +// +// syslog adds the time, the host, the ident and the process id to each message itself, so the default line repeats +// two of them. For syslog alone, consider: +// +// config.Line.ShowTimestamp = false; +// config.Line.ShowProcessId = false; +// +// The timestamp is still worth keeping where the moment of the call matters: syslog's time is when the line is sent, +// which with TASWLogConfig::Async is a little later, and has less precision in a classic syslog file. A multi-log +// passes one config to all its loggers, so a syslog logger behind one writes the same layout as the others. +// +// Initialize() and Reconfigure() call openlog() with Syslog.Ident (or the executable's file name) and LOG_PID; the +// logger never calls closelog(). The ident is process-wide, so with several syslog loggers the one initialized or +// reconfigured last sets it, and the application's own openlog() call replaces it. The facility is sent with each +// message, so each logger keeps its own. +// +// A crash line formatted in normal context (e.g. std::terminate, HandleCrash()) is sent as LOG_CRIT; syslog() isn't +// async-signal-safe, so nothing is sent from a POSIX signal handler, or if the logger's lock stays busy (see +// ASWLog_CrashHandler.h). +// +// Windows has no syslog, so there it writes nothing: the class builds everywhere, so the same code can add it on every +// platform, but there ShouldLog() is false and no line is formatted. (On Windows, see the debugger logger.) +// +// Takes the same TASWLogConfig as the other text loggers, ignoring its File settings. Thread-safe. +///////////////////////////////////////////////////////////////////////////// +class TASWSyslogLog : public TASWTextLogBase +{ +private: + typedef TASWTextLogBase inherited; + +private: + void OpenSyslogFromConfigUnlocked(); + +protected: // TASWTextLogBase hooks + bool CloseUnlocked() override; + bool FlushUnlocked() override; // Nothing is buffered: true while open + bool InitializeUnlocked() override; + bool OpenUnlocked() override; + bool PrepareWriteUnlocked(std::chrono::system_clock::time_point now) override; // False where it writes nothing + bool ReconfigureUnlocked(const TASWLogConfig& previous) override; + void WriteLineUnlocked(Level level, std::string_view line, bool endsLine) override; + +protected: + std::string_view GetLoggerClassName() const noexcept override + { + return "TASWSyslogLog"; + } + + // Called by Initialize() and Reconfigure() with the ident (Syslog.Ident, or the executable's file name if that is + // empty) and Syslog.Facility: calls openlog() with LOG_PID. Does nothing where there is no syslog. + virtual void OpenSyslogUnlocked(const std::string& ident, SyslogFacility facility); + + // Sends one message: 'priority' is the facility and the severity combined, as syslog() takes it (e.g. 14 for + // LOG_USER | LOG_INFO). Does nothing where there is no syslog. + virtual void SendToSyslogUnlocked(int priority, std::string_view message); + +public: // Static methods + // Singleton support for the common static instance. Never destroyed; finalized at exit in static destruction + // order, like TASWFileLog::GetInstance() (see there). + static TASWSyslogLog& GetInstance(); + +public: + TASWSyslogLog() = default; + // Writes the shutdown line (see TASWTextLogBase::Finalize()) + ~TASWSyslogLog() override; + + TASWSyslogLog(const TASWSyslogLog&) = delete; + TASWSyslogLog& operator=(const TASWSyslogLog&) = delete; + + // As for the other loggers where there is a syslog; always false on Windows + bool ShouldLog(Level level) const noexcept override; + bool ShouldLog(const TASWLogRecord& record) const noexcept override; +}; + +} // namespace ASWLog + +#endif // #ifndef ASWLog_SyslogLogH diff --git a/ASWLog/ASWLog_Types.cpp b/ASWLog/ASWLog_Types.cpp index fd8220f..f673f1b 100644 --- a/ASWLog/ASWLog_Types.cpp +++ b/ASWLog/ASWLog_Types.cpp @@ -25,8 +25,8 @@ limitations under the License. #include "ASWLog_Types.h" //--------------------------------------------------------------------------- #include -#include #include +#include #include #include #include @@ -39,32 +39,15 @@ limitations under the License. namespace ASWLog { -namespace -{ - -// Helper to check if two strings match case-insensitively without -// creating a new, modified string allocation on the heap. -bool iequals(std::string_view a, std::string_view b) noexcept -{ - return std::equal(a.begin(), a.end(), b.begin(), b.end(), - [](char char_a, char char_b) - { - // Cast to unsigned char before passing to tolower to avoid UB with negative values - return std::tolower(static_cast(char_a)) == std::tolower(static_cast(char_b)); - }); -} - -} // namespace - //--------------------------------------------------------------------------- //--------------------------------------------------------------------------- std::optional AsyncOverflowPolicy_FromString(std::string_view str) noexcept { - if (iequals(str, "BLOCK")) + if (Detail::EqualsIgnoringCase(str, "BLOCK")) return AsyncOverflowPolicy::Block; - if (iequals(str, "DROP_NEWEST") || iequals(str, "DROPNEWEST")) + if (Detail::EqualsIgnoringCase(str, "DROP_NEWEST") || Detail::EqualsIgnoringCase(str, "DROPNEWEST")) return AsyncOverflowPolicy::DropNewest; return std::nullopt; @@ -73,13 +56,13 @@ std::optional AsyncOverflowPolicy_FromString(std::string_vi //--------------------------------------------------------------------------- std::optional ColorMode_FromString(std::string_view str) noexcept { - if (iequals(str, "AUTO")) + if (Detail::EqualsIgnoringCase(str, "AUTO")) return ColorMode::Auto; - if (iequals(str, "ALWAYS")) + if (Detail::EqualsIgnoringCase(str, "ALWAYS")) return ColorMode::Always; - if (iequals(str, "NEVER")) + if (Detail::EqualsIgnoringCase(str, "NEVER")) return ColorMode::Never; return std::nullopt; // Return empty optional if the string is not recognized @@ -88,16 +71,16 @@ std::optional ColorMode_FromString(std::string_view str) noexcept //--------------------------------------------------------------------------- std::optional FlushMode_FromString(std::string_view str) noexcept { - if (iequals(str, "EVERY_WRITE") || iequals(str, "EVERYWRITE")) + if (Detail::EqualsIgnoringCase(str, "EVERY_WRITE") || Detail::EqualsIgnoringCase(str, "EVERYWRITE")) return FlushMode::EveryWrite; - if (iequals(str, "ON_NEW_LINE") || iequals(str, "ONNEWLINE")) + if (Detail::EqualsIgnoringCase(str, "ON_NEW_LINE") || Detail::EqualsIgnoringCase(str, "ONNEWLINE")) return FlushMode::OnNewLine; - if (iequals(str, "MANUAL")) + if (Detail::EqualsIgnoringCase(str, "MANUAL")) return FlushMode::Manual; - if (iequals(str, "PERIODIC")) + if (Detail::EqualsIgnoringCase(str, "PERIODIC")) return FlushMode::Periodic; return std::nullopt; @@ -106,25 +89,25 @@ std::optional FlushMode_FromString(std::string_view str) noexcept //--------------------------------------------------------------------------- std::optional Level_FromString(std::string_view str) noexcept { - if (iequals(str, "TRACE")) + if (Detail::EqualsIgnoringCase(str, "TRACE")) return Level::Trace; - if (iequals(str, "DEBUG")) + if (Detail::EqualsIgnoringCase(str, "DEBUG")) return Level::Debug; - if (iequals(str, "INFO")) + if (Detail::EqualsIgnoringCase(str, "INFO")) return Level::Info; - if (iequals(str, "WARN") || iequals(str, "WARNING")) + if (Detail::EqualsIgnoringCase(str, "WARN") || Detail::EqualsIgnoringCase(str, "WARNING")) return Level::Warn; - if (iequals(str, "ERROR")) + if (Detail::EqualsIgnoringCase(str, "ERROR")) return Level::Error; - if (iequals(str, "CRITICAL") || iequals(str, "FATAL")) + if (Detail::EqualsIgnoringCase(str, "CRITICAL") || Detail::EqualsIgnoringCase(str, "FATAL")) return Level::Critical; - if (iequals(str, "OFF") || iequals(str, "NONE")) + if (Detail::EqualsIgnoringCase(str, "OFF") || Detail::EqualsIgnoringCase(str, "NONE")) return Level::Off; return std::nullopt; // Return empty optional if the string is not recognized @@ -133,10 +116,10 @@ std::optional Level_FromString(std::string_view str) noexcept //--------------------------------------------------------------------------- std::optional LineEnding_FromString(std::string_view str) noexcept { - if (iequals(str, "LF") || iequals(str, "LINUX")) + if (Detail::EqualsIgnoringCase(str, "LF") || Detail::EqualsIgnoringCase(str, "LINUX")) return LineEnding::LF; - if (iequals(str, "CRLF") || iequals(str, "WINDOWS")) + if (Detail::EqualsIgnoringCase(str, "CRLF") || Detail::EqualsIgnoringCase(str, "WINDOWS")) return LineEnding::CRLF; return std::nullopt; // Return empty optional if the string is not recognized @@ -145,28 +128,40 @@ std::optional LineEnding_FromString(std::string_view str) noexcept //--------------------------------------------------------------------------- std::optional MultilineMode_FromString(std::string_view str) noexcept { - if (iequals(str, "PRESERVE")) + if (Detail::EqualsIgnoringCase(str, "PRESERVE")) return MultilineMode::Preserve; - if (iequals(str, "INDENT")) + if (Detail::EqualsIgnoringCase(str, "INDENT")) return MultilineMode::Indent; - if (iequals(str, "ESCAPE")) + if (Detail::EqualsIgnoringCase(str, "ESCAPE")) return MultilineMode::Escape; return std::nullopt; } +//--------------------------------------------------------------------------- +std::optional SyslogFacility_FromString(std::string_view str) noexcept +{ + for (const auto facility : { SyslogFacility::User, SyslogFacility::Daemon, SyslogFacility::Local0, SyslogFacility::Local1, SyslogFacility::Local2, SyslogFacility::Local3, SyslogFacility::Local4, SyslogFacility::Local5, SyslogFacility::Local6, SyslogFacility::Local7 }) + { + if (Detail::EqualsIgnoringCase(str, SyslogFacility_ToString(facility))) + return facility; + } + + return std::nullopt; +} + //--------------------------------------------------------------------------- std::optional TimePrecision_FromString(std::string_view str) noexcept { - if (iequals(str, "MILLISECONDS") || iequals(str, "MS")) + if (Detail::EqualsIgnoringCase(str, "MILLISECONDS") || Detail::EqualsIgnoringCase(str, "MS")) return TimePrecision::Milliseconds; - if (iequals(str, "MICROSECONDS") || iequals(str, "US")) + if (Detail::EqualsIgnoringCase(str, "MICROSECONDS") || Detail::EqualsIgnoringCase(str, "US")) return TimePrecision::Microseconds; - if (iequals(str, "NANOSECONDS") || iequals(str, "NS")) + if (Detail::EqualsIgnoringCase(str, "NANOSECONDS") || Detail::EqualsIgnoringCase(str, "NS")) return TimePrecision::Nanoseconds; return std::nullopt; @@ -175,10 +170,10 @@ std::optional TimePrecision_FromString(std::string_view str) noex //--------------------------------------------------------------------------- std::optional TimeZone_FromString(std::string_view str) noexcept { - if (iequals(str, "UTC")) + if (Detail::EqualsIgnoringCase(str, "UTC")) return TimeZone::UTC; - if (iequals(str, "LOCAL")) + if (Detail::EqualsIgnoringCase(str, "LOCAL")) return TimeZone::Local; return std::nullopt; diff --git a/ASWLog/ASWLog_Types.h b/ASWLog/ASWLog_Types.h index a96c3b0..95ea589 100644 --- a/ASWLog/ASWLog_Types.h +++ b/ASWLog/ASWLog_Types.h @@ -129,7 +129,8 @@ enum class ColorMode */ enum class ErrorKind { - // The log file couldn't be opened (or its folder created), so entries are dropped until it can be. + // The log file couldn't be opened (or its folder created), or another output (e.g. a Windows Event Log source), so + // entries are dropped until it can be. OpenFailed, // An entry couldn't be written to the output (e.g. the disk is full). @@ -157,10 +158,14 @@ enum class ErrorKind // An asynchronous logger's queue was full, so entries were dropped (see AsyncOverflowPolicy::DropNewest). The // message says how many. EntriesDropped, + + // A setting given as text had an invalid value, which was left unused (see TASWLogConfig::ApplyEnvironment()). The + // message names the setting and the value. + InvalidSetting, }; // Number of error kinds, e.g. for a table indexed by kind. Update this when a kind is added or removed. -constexpr std::size_t ErrorKindCount = 9; +constexpr std::size_t ErrorKindCount = 10; [[nodiscard]] constexpr std::string_view ErrorKind_ToString(ErrorKind errorKind) noexcept { @@ -192,6 +197,9 @@ constexpr std::size_t ErrorKindCount = 9; case ErrorKind::EntriesDropped: return "ENTRIES_DROPPED"; + + case ErrorKind::InvalidSetting: + return "INVALID_SETTING"; } return "UNKNOWN"; @@ -364,6 +372,64 @@ enum class MultilineMode //--------------------------------------------------------------------------- +// The syslog facility a TASWSyslogLog sends its entries with (see TASWSyslogConfig::Facility). The values are the +// facility codes of RFC 5424, the same as the LOG_* facilities of divided by 8. +enum class SyslogFacility +{ + User = 1, // LOG_USER: an application's messages + Daemon = 3, // LOG_DAEMON: a system service's + Local0 = 16, // LOG_LOCAL0 to LOG_LOCAL7: for the site's own use, e.g. to route an application's messages to a file + Local1 = 17, + Local2 = 18, + Local3 = 19, + Local4 = 20, + Local5 = 21, + Local6 = 22, + Local7 = 23, +}; + +[[nodiscard]] std::optional SyslogFacility_FromString(std::string_view str) noexcept; + +[[nodiscard]] constexpr std::string_view SyslogFacility_ToString(SyslogFacility facility) noexcept +{ + switch (facility) + { + case SyslogFacility::User: + return "USER"; + + case SyslogFacility::Daemon: + return "DAEMON"; + + case SyslogFacility::Local0: + return "LOCAL0"; + + case SyslogFacility::Local1: + return "LOCAL1"; + + case SyslogFacility::Local2: + return "LOCAL2"; + + case SyslogFacility::Local3: + return "LOCAL3"; + + case SyslogFacility::Local4: + return "LOCAL4"; + + case SyslogFacility::Local5: + return "LOCAL5"; + + case SyslogFacility::Local6: + return "LOCAL6"; + + case SyslogFacility::Local7: + return "LOCAL7"; + } + + return "UNKNOWN"; +} + +//--------------------------------------------------------------------------- + // How many digits of the second a timestamp shows (see TASWLineConfig::TimestampPrecision). The digits past the system // clock's resolution are 0: with nanoseconds, the last two with MSVC and MinGW on Windows (100 ns), the last three with // libc++ (1 us, e.g. RAD Studio). diff --git a/ASWLog/ASWLog_Unicode.cpp b/ASWLog/ASWLog_Unicode.cpp new file mode 100644 index 0000000..451048a --- /dev/null +++ b/ASWLog/ASWLog_Unicode.cpp @@ -0,0 +1,317 @@ +/* ************************************************************************** +ASWLog_Unicode.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_Unicode.h" +//--------------------------------------------------------------------------- +// System includes here +#include +#include +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +namespace +{ + +constexpr char32_t ReplacementCharacter = 0xFFFD; + +//--------------------------------------------------------------------------- + +void AppendUTF8(std::string& output, char32_t character); +template +void AppendUTF16(std::basic_string& output, char32_t character); +template +char32_t DecodeUTF16(std::basic_string_view text, std::size_t& index) noexcept; +char32_t DecodeUTF8(std::string_view text, std::size_t& index) noexcept; +template +std::basic_string ToUTF16(std::string_view text); +template +std::string ToUTF8(std::basic_string_view text); + +//--------------------------------------------------------------------------- + +/* + AppendUTF8 + + Appends 'character' (a Unicode scalar value) to 'output' as UTF-8 +*/ +void AppendUTF8(std::string& output, char32_t character) +{ + if (character < 0x80) + { + output += static_cast(character); + } + else if (character < 0x800) + { + output += static_cast(0xC0 | (character >> 6)); + output += static_cast(0x80 | (character & 0x3F)); + } + else if (character < 0x10000) + { + output += static_cast(0xE0 | (character >> 12)); + output += static_cast(0x80 | ((character >> 6) & 0x3F)); + output += static_cast(0x80 | (character & 0x3F)); + } + else + { + output += static_cast(0xF0 | (character >> 18)); + output += static_cast(0x80 | ((character >> 12) & 0x3F)); + output += static_cast(0x80 | ((character >> 6) & 0x3F)); + output += static_cast(0x80 | (character & 0x3F)); + } +} + +/* + AppendUTF16 + + Appends 'character' (a Unicode scalar value) to 'output' as UTF-16: one code unit, or a surrogate pair above U+FFFF +*/ +template +void AppendUTF16(std::basic_string& output, char32_t character) +{ + if (character < 0x10000) + { + output += static_cast(character); + return; + } + + character -= 0x10000; + output += static_cast(0xD800 + (character >> 10)); + output += static_cast(0xDC00 + (character & 0x3FF)); +} + +/* + DecodeUTF16 + + The character at 'index' in 'text' (UTF-16 code units of type TChar: char16_t, or wchar_t where it has 16 bits), + moving 'index' past it: a code unit, or a surrogate pair; U+FFFD for an unpaired surrogate, which is skipped +*/ +template +char32_t DecodeUTF16(std::basic_string_view text, std::size_t& index) noexcept +{ + const char32_t unit = static_cast(text[index++]); + if (unit < 0xD800 || unit > 0xDFFF) + return unit; + + if (unit <= 0xDBFF && index < text.size()) + { + const char32_t next = static_cast(text[index]); + if (next >= 0xDC00 && next <= 0xDFFF) + { + ++index; + return 0x10000 + ((unit - 0xD800) << 10) + (next - 0xDC00); + } + } + + return ReplacementCharacter; +} + +/* + DecodeUTF8 + + The character at 'index' in 'text' (UTF-8), moving 'index' past it. An invalid sequence is U+FFFD (see the Unicode + standard's table of well-formed byte sequences): its longest start that could begin a valid sequence, at least one + byte, is skipped, so the byte that broke it starts the next character. +*/ +char32_t DecodeUTF8(std::string_view text, std::size_t& index) noexcept +{ + const auto lead = static_cast(text[index++]); + if (lead < 0x80) + return lead; + + std::size_t length = 0; + char32_t character = 0; + unsigned char low = 0x80; // The range of the second byte, which some lead bytes narrow + unsigned char high = 0xBF; + + if (lead >= 0xC2 && lead <= 0xDF) + { + length = 2; + character = lead & 0x1F; + } + else if (lead >= 0xE0 && lead <= 0xEF) + { + length = 3; + character = lead & 0x0F; + low = lead == 0xE0 ? 0xA0 : 0x80; // No overlong forms + high = lead == 0xED ? 0x9F : 0xBF; // No surrogates + } + else if (lead >= 0xF0 && lead <= 0xF4) + { + length = 4; + character = lead & 0x07; + low = lead == 0xF0 ? 0x90 : 0x80; // No overlong forms + high = lead == 0xF4 ? 0x8F : 0xBF; // Nothing above U+10FFFF + } + else + { + return ReplacementCharacter; + } + + for (std::size_t count = 1; count < length; ++count) + { + if (index >= text.size()) + return ReplacementCharacter; + + const auto byte = static_cast(text[index]); + if (byte < low || byte > high) + return ReplacementCharacter; + + character = (character << 6) | (byte & 0x3F); + low = 0x80; + high = 0xBF; + ++index; + } + + return character; +} + +/* + ToUTF16 + + 'text' (UTF-8) as UTF-16 code units of type TChar (char16_t, or wchar_t where it has 16 bits) +*/ +template +std::basic_string ToUTF16(std::string_view text) +{ + std::basic_string output; + output.reserve(text.size()); + + for (std::size_t index = 0; index < text.size();) + AppendUTF16(output, DecodeUTF8(text, index)); + + return output; +} + +/* + ToUTF8 + + 'text' (UTF-16 code units of type TChar) as UTF-8 +*/ +template +std::string ToUTF8(std::basic_string_view text) +{ + std::string output; + output.reserve(text.size()); + + for (std::size_t index = 0; index < text.size();) + AppendUTF8(output, DecodeUTF16(text, index)); + + return output; +} + +} // namespace + +//--------------------------------------------------------------------------- +std::u16string UTF8ToUTF16(std::string_view text) +{ + return ToUTF16(text); +} + +//--------------------------------------------------------------------------- +std::wstring UTF8ToWide(std::string_view text) +{ + if constexpr (sizeof(wchar_t) == sizeof(char16_t)) + { + return ToUTF16(text); + } + else + { + std::wstring output; + output.reserve(text.size()); + + for (std::size_t index = 0; index < text.size();) + output += static_cast(DecodeUTF8(text, index)); + + return output; + } +} + +//--------------------------------------------------------------------------- +std::string WideToUTF8(std::wstring_view text) +{ + if constexpr (sizeof(wchar_t) == sizeof(char16_t)) + { + return ToUTF8(text); + } + else + { + std::string output; + output.reserve(text.size()); + + for (const wchar_t unit : text) + { + const auto character = static_cast(unit); + const bool isCharacter = character <= 0x10FFFF && (character < 0xD800 || character > 0xDFFF); + AppendUTF8(output, isCharacter ? character : ReplacementCharacter); + } + + return output; + } +} + +//--------------------------------------------------------------------------- +std::string WideToUTF8(std::u16string_view text) +{ + return ToUTF8(text); +} + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWWideText +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +std::wstring TASWWideText::ToWide() const +{ + if (m_UTF16.empty()) + return std::wstring(m_Wide); + + if constexpr (sizeof(wchar_t) == sizeof(char16_t)) + { + return std::wstring(m_UTF16.begin(), m_UTF16.end()); + } + else + { + std::wstring output; + output.reserve(m_UTF16.size()); + + for (std::size_t index = 0; index < m_UTF16.size();) + output += static_cast(DecodeUTF16(m_UTF16, index)); + + return output; + } +} + +//--------------------------------------------------------------------------- +std::string TASWWideText::ToUTF8() const +{ + return m_UTF16.empty() ? WideToUTF8(m_Wide) : WideToUTF8(m_UTF16); +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_Unicode.h b/ASWLog/ASWLog_Unicode.h new file mode 100644 index 0000000..e64cb93 --- /dev/null +++ b/ASWLog/ASWLog_Unicode.h @@ -0,0 +1,219 @@ +/* ************************************************************************** +ASWLog_Unicode.h +Author: Anthony S. West - ASW Software + +A logging library. + +Source for logging wide (UTF-16 and UTF-32) text: conversions to and from UTF-8, and the text wrappers the logging +methods and the *Fmt arguments take. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_UnicodeH +#define ASWLog_UnicodeH +//--------------------------------------------------------------------------- +#include +#include +#include +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +//--------------------------------------------------------------------------- + +/* + UTF8ToUTF16 + + 'text' (UTF-8) as UTF-16. Each invalid UTF-8 sequence becomes U+FFFD. Throws std::bad_alloc if out of memory. +*/ +[[nodiscard]] std::u16string UTF8ToUTF16(std::string_view text); + +/* + UTF8ToWide + + 'text' (UTF-8) as a wide string: UTF-16 where wchar_t has 16 bits (Windows, e.g. for the Windows API or a VCL + control), UTF-32 where it has 32 (Linux). Each invalid UTF-8 sequence becomes U+FFFD. Throws std::bad_alloc if out + of memory. +*/ +[[nodiscard]] std::wstring UTF8ToWide(std::string_view text); + +/* + WideToUTF8 + + 'text' as UTF-8: a wide string (UTF-16 where wchar_t has 16 bits, UTF-32 where it has 32), or UTF-16. Each + unpaired surrogate, or value that isn't a character (above U+10FFFF), becomes U+FFFD. Throws std::bad_alloc if out + of memory. +*/ +[[nodiscard]] std::string WideToUTF8(std::wstring_view text); +[[nodiscard]] std::string WideToUTF8(std::u16string_view text); + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWUTF8Text +// +// A view of UTF-8 text, made by UTF8(), that a *Fmt call with a wide format string formats as wide text (converted +// only if the entry is formatted), e.g. log.LogInfoFmt(L"{} from {}", name, ASWLog::UTF8(host)). A narrow *Fmt call +// formats it as it is. +///////////////////////////////////////////////////////////////////////////// +class TASWUTF8Text +{ +private: + std::string_view m_Text; + +public: + constexpr explicit TASWUTF8Text(std::string_view text) noexcept + : m_Text(text) + { + } + + [[nodiscard]] constexpr std::string_view GetText() const noexcept + { + return m_Text; + } +}; + +// Wraps UTF-8 text for a *Fmt call with a wide format string (see TASWUTF8Text). Holds a view of 'text', so the text +// must outlive the call. +[[nodiscard]] constexpr TASWUTF8Text UTF8(std::string_view text) noexcept +{ + return TASWUTF8Text(text); +} + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWWideText +// +// A view of wide text: wchar_t (UTF-16 on Windows, UTF-32 on Linux) or char16_t (UTF-16). The logging methods take it +// as a message, e.g. log.LogInfo(L"Opened") or, with C++Builder, log.LogInfo(caption.c_str()), and convert it to UTF-8 +// only if the entry is written. Wide() makes one for a *Fmt argument, which is converted only if the entry is +// formatted. Made implicitly from a null-terminated string (null counts as empty), a string view or a string; it holds +// a view, so the text must outlive the call. +///////////////////////////////////////////////////////////////////////////// +class TASWWideText +{ +private: + // At most one of them holds the text + std::wstring_view m_Wide; + std::u16string_view m_UTF16; + +public: + constexpr TASWWideText(const wchar_t* text) noexcept + : m_Wide(text != nullptr ? std::wstring_view(text) : std::wstring_view()) + { + } + + constexpr TASWWideText(const char16_t* text) noexcept + : m_UTF16(text != nullptr ? std::u16string_view(text) : std::u16string_view()) + { + } + + constexpr TASWWideText(std::wstring_view text) noexcept + : m_Wide(text) + { + } + + constexpr TASWWideText(std::u16string_view text) noexcept + : m_UTF16(text) + { + } + + // A string converts to a view through a conversion of its own, and an implicit conversion can't chain two + TASWWideText(const std::wstring& text) noexcept + : m_Wide(text) + { + } + + TASWWideText(const std::u16string& text) noexcept + : m_UTF16(text) + { + } + + // The text as a wide string (converted from UTF-16 if it is char16_t text where wchar_t has 32 bits). Throws + // std::bad_alloc if out of memory. + [[nodiscard]] std::wstring ToWide() const; + // The text as UTF-8 (see WideToUTF8()). Throws std::bad_alloc if out of memory. + [[nodiscard]] std::string ToUTF8() const; +}; + +// Wraps wide text for a *Fmt call with a narrow format string, e.g. log.LogInfoFmt("Opened {}", ASWLog::Wide(name)): +// converted to UTF-8 only if the entry is formatted (see TASWWideText) +[[nodiscard]] constexpr TASWWideText Wide(TASWWideText text) noexcept +{ + return text; +} + +} // namespace ASWLog + +//--------------------------------------------------------------------------- + +// The formatters of the text wrappers, each the string view formatter of its character type, so a width or precision +// applies too, e.g. "{:>20}" (counted in code units, e.g. UTF-8 bytes, not characters) +template<> +struct std::formatter : std::formatter +{ + template + auto format(const ASWLog::TASWUTF8Text& text, TContext& context) const + { + return std::formatter::format(text.GetText(), context); + } +}; + +template<> +struct std::formatter : std::formatter +{ + template + auto format(const ASWLog::TASWUTF8Text& text, TContext& context) const + { + const std::wstring wide = ASWLog::UTF8ToWide(text.GetText()); + + return std::formatter::format(wide, context); + } +}; + +template<> +struct std::formatter : std::formatter +{ + template + auto format(const ASWLog::TASWWideText& text, TContext& context) const + { + const std::string utf8 = text.ToUTF8(); + + return std::formatter::format(utf8, context); + } +}; + +template<> +struct std::formatter : std::formatter +{ + template + auto format(const ASWLog::TASWWideText& text, TContext& context) const + { + const std::wstring wide = text.ToWide(); + + return std::formatter::format(wide, context); + } +}; + +//--------------------------------------------------------------------------- +#endif // ASWLog_UnicodeH diff --git a/ASWLog/ASWLog_Utils.cpp b/ASWLog/ASWLog_Utils.cpp index d07828d..ca1688b 100644 --- a/ASWLog/ASWLog_Utils.cpp +++ b/ASWLog/ASWLog_Utils.cpp @@ -29,6 +29,8 @@ limitations under the License. #include #include #include +#include +#include #include #include #include @@ -47,6 +49,8 @@ limitations under the License. #include #endif //--------------------------------------------------------------------------- +#include "ASWLog_Unicode.h" +//--------------------------------------------------------------------------- namespace ASWLog { @@ -624,6 +628,9 @@ namespace Detail namespace { +// Ends a text CutToFit() cut +constexpr std::string_view CutMarker = " [cut]"; + // The line breaks MultilineMode::Escape writes constexpr std::string_view EscapedCR = "\\r"; constexpr std::string_view EscapedLF = "\\n"; @@ -778,6 +785,39 @@ void ApplyMultilineMode(std::string& line, MultilineMode mode, LineEnding ending line = std::move(rewritten); } +//--------------------------------------------------------------------------- +void CutToFit(std::string& text, std::size_t maxBytes) +{ + if (text.size() <= maxBytes) + return; + + const bool hasMarker = maxBytes >= CutMarker.size(); + std::size_t keptSize = hasMarker ? maxBytes - CutMarker.size() : maxBytes; + + // Back to the start of a UTF-8 character (a continuation byte is 10xxxxxx) + while (keptSize > 0 && (static_cast(text[keptSize]) & 0xC0) == 0x80) + --keptSize; + + text.resize(keptSize); + + if (hasMarker) + text.append(CutMarker); +} + +//--------------------------------------------------------------------------- +bool EqualsIgnoringCase(std::string_view a, std::string_view b) noexcept +{ + const auto toLower = [](char character) + { + return character >= 'A' && character <= 'Z' ? static_cast(character - 'A' + 'a') : character; + }; + + return std::equal(a.begin(), a.end(), b.begin(), b.end(), [&](char charA, char charB) + { + return toLower(charA) == toLower(charB); + }); +} + //--------------------------------------------------------------------------- TJSONPiece NextJSONPiece(std::string_view text, std::size_t& index, char (& escape)[6]) noexcept { @@ -911,6 +951,62 @@ TMultilinePiece NextMultilinePiece(std::string_view text, std::size_t& index, Mu return TMultilinePiece{ isNextLineEmpty ? indented.substr(0, indented.size() - 1) : indented, true }; } +//--------------------------------------------------------------------------- +std::optional ReadEnvironmentVariable(std::string_view name) noexcept +{ + try + { +#if defined(_WIN32) + const auto wideName = UTF8ToWide(name); + + // The size the value needs, including its terminating null (1 for an empty value), or 0 if it isn't set + DWORD size = GetEnvironmentVariableW(wideName.c_str(), nullptr, 0); + std::wstring value; + + while (size > 0) + { + value.resize(size); + SetLastError(ERROR_SUCCESS); // An empty value also returns 0, without an error + const DWORD length = GetEnvironmentVariableW(wideName.c_str(), value.data(), size); + if (length == 0 && GetLastError() == ERROR_ENVVAR_NOT_FOUND) + return std::nullopt; // Removed meanwhile + + if (length < size) + { + value.resize(length); + return WideToUTF8(value); + } + + size = length; // Another thread made it longer meanwhile: the size it needs now + } + + return std::nullopt; +#else + const char* value = std::getenv(std::string(name).c_str()); + if (value == nullptr) + return std::nullopt; + + return std::string(value); +#endif + } + catch (...) + { + return std::nullopt; + } +} + +//--------------------------------------------------------------------------- +std::string_view TrimSpaces(std::string_view text) noexcept +{ + const auto start = text.find_first_not_of(" \t"); + if (start == std::string_view::npos) + return {}; + + const auto end = text.find_last_not_of(" \t"); + + return text.substr(start, end - start + 1); +} + } // namespace Detail //--------------------------------------------------------------------------- diff --git a/ASWLog/ASWLog_Utils.h b/ASWLog/ASWLog_Utils.h index c6fcd9b..99d1bf5 100644 --- a/ASWLog/ASWLog_Utils.h +++ b/ASWLog/ASWLog_Utils.h @@ -33,6 +33,7 @@ limitations under the License. #include #include #include +#include #include #include #include @@ -163,6 +164,21 @@ struct TMultilinePiece */ void ApplyMultilineMode(std::string& line, MultilineMode mode, LineEnding ending); +/* + CutToFit + + Cuts 'text' to at most 'maxBytes' bytes, between UTF-8 characters, ending with " [cut]" if that fits too. Leaves a + text that fits as it is. +*/ +void CutToFit(std::string& text, std::size_t maxBytes); + +/* + EqualsIgnoringCase + + True if 'a' and 'b' are the same text, ignoring the case of ASCII letters +*/ +[[nodiscard]] bool EqualsIgnoringCase(std::string_view a, std::string_view b) noexcept; + /* NextJSONPiece @@ -184,6 +200,22 @@ void ApplyMultilineMode(std::string& line, MultilineMode mode, LineEnding ending [[nodiscard]] TMultilinePiece NextMultilinePiece(std::string_view text, std::size_t& index, MultilineMode mode, LineEnding ending) noexcept; +/* + ReadEnvironmentVariable + + The value of the environment variable 'name' in UTF-8 (read with the wide Windows API on Windows, which the C + runtime's getenv() doesn't use), or nullopt if it isn't set or can't be read. A variable set to an empty value + gives an empty string. Never throws. +*/ +[[nodiscard]] std::optional ReadEnvironmentVariable(std::string_view name) noexcept; + +/* + TrimSpaces + + 'text' without the spaces and tabs at its start and end +*/ +[[nodiscard]] std::string_view TrimSpaces(std::string_view text) noexcept; + } // namespace Detail namespace JSON diff --git a/ASWLog/ASWLog_WindowsEventLog.cpp b/ASWLog/ASWLog_WindowsEventLog.cpp new file mode 100644 index 0000000..17fb1ac --- /dev/null +++ b/ASWLog/ASWLog_WindowsEventLog.cpp @@ -0,0 +1,357 @@ +/* ************************************************************************** +ASWLog_WindowsEventLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "ASWLog_WindowsEventLog.h" +//--------------------------------------------------------------------------- +// System includes here +#include +#include +#include + +#if defined(_WIN32) +#include +#endif +//--------------------------------------------------------------------------- +#include "ASWLog_Unicode.h" +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +namespace +{ + +// The platform has a Windows Event Log to report to +#if defined(_WIN32) +constexpr bool HasEventLog = true; + +// The registry key that holds the Application log's sources +constexpr std::wstring_view ApplicationLogKey = L"SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\"; + +// A message file that ships with Windows (.NET Framework 4) and shows every event ID 0-65535 as its text ("%1") +constexpr std::wstring_view MessageFile = L"%SystemRoot%\\Microsoft.NET\\Framework\\v4.0.30319\\EventLogMessages.dll"; +#else +constexpr bool HasEventLog = false; +#endif + +//--------------------------------------------------------------------------- + +std::string ResolveSource(std::string_view source); +TASWWindowsEventLog::EventType ToEventType(Level level) noexcept; + +#if defined(_WIN32) +bool ToSourceKey(std::string_view source, std::wstring& key); +#endif + +//--------------------------------------------------------------------------- + +/* + ResolveSource + + The event source 'source' names: itself, or the executable's file name without its extension if it is empty +*/ +std::string ResolveSource(std::string_view source) +{ + return source.empty() ? PathToUTF8String(GetExecutablePath().stem()) : std::string(source); +} + +/* + ToEventType + + The type of an event for an entry at 'level' +*/ +TASWWindowsEventLog::EventType ToEventType(Level level) noexcept +{ + if (level >= Level::Error) + return TASWWindowsEventLog::EventType::Error; + + if (level == Level::Warn) + return TASWWindowsEventLog::EventType::Warning; + + return TASWWindowsEventLog::EventType::Information; +} + +#if defined(_WIN32) +/* + ToSourceKey + + Sets 'key' to the registry key (under HKEY_LOCAL_MACHINE) of 'source' (see ResolveSource()); false if the name can't + be a source's (empty, or holding a '\', which would name a key below another source's) +*/ +bool ToSourceKey(std::string_view source, std::wstring& key) +{ + const std::string name = ResolveSource(source); + + if (name.empty() || name.find('\\') != std::string::npos) + return false; + + key = std::wstring(ApplicationLogKey) + UTF8ToWide(name); + + return true; +} +#endif + +} // namespace + +//--------------------------------------------------------------------------- + +///////////////////////////////////////////////////////////////////////////// +// TASWWindowsEventLog +///////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TASWWindowsEventLog::~TASWWindowsEventLog() +{ + Finalize(); +} + +//--------------------------------------------------------------------------- +// Closes the source opened by OpenEventSourceUnlocked(), if any +void TASWWindowsEventLog::CloseEventSource() noexcept +{ +#if defined(_WIN32) + if (m_EventSource != nullptr) + DeregisterEventSource(static_cast(m_EventSource)); +#endif + + m_EventSource = nullptr; +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::CloseUnlocked() +{ + CloseEventSource(); + m_IsOpen.store(false); + m_IsInitialized.store(false); + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::FlushUnlocked() +{ + return m_IsOpen.load(); +} + +//--------------------------------------------------------------------------- +TASWWindowsEventLog& TASWWindowsEventLog::GetInstance() +{ + // Never deleted, so the instance stays usable through static destruction. It is only finalized at exit, by a + // handler registered right after it is created, which runs where a static instance's destructor would have run. + static TASWWindowsEventLog* const instance = new TASWWindowsEventLog(); + [[maybe_unused]] static const int atExitResult = std::atexit([] { + instance->Finalize(); + }); + return *instance; +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::InitializeUnlocked() +{ + return OpenUnlocked(); +} + +//--------------------------------------------------------------------------- +// Calls OpenEventSourceUnlocked() with the current config's source +bool TASWWindowsEventLog::OpenEventSourceFromConfigUnlocked() +{ + return OpenEventSourceUnlocked(ResolveSource(GetConfigUnlocked().EventLog.Source)); +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::OpenEventSourceUnlocked(const std::string& source) +{ +#if defined(_WIN32) + const HANDLE eventSource = RegisterEventSourceW(nullptr, UTF8ToWide(source).c_str()); + + if (eventSource == nullptr) + { + TASWLogError error; + error.Kind = ErrorKind::OpenFailed; + error.Code = std::error_code(static_cast(GetLastError()), std::system_category()); + error.Message = "Couldn't open the event source '" + source + "'"; + ReportErrorUnlocked(std::move(error)); + + return false; + } + + CloseEventSource(); + m_EventSource = eventSource; +#else + static_cast(source); +#endif + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::OpenUnlocked() +{ + if (!OpenEventSourceFromConfigUnlocked()) + return false; + + m_IsOpen.store(true); + m_IsInitialized.store(true); + + return true; +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::PrepareWriteUnlocked(std::chrono::system_clock::time_point /*now*/) +{ + // Before the line is formatted, so nothing is formatted where it would be dropped + return HasEventLog; +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::ReconfigureUnlocked(const TASWLogConfig& /*previous*/) +{ + return OpenEventSourceFromConfigUnlocked(); +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::RegisterSource(std::string_view source) noexcept +{ +#if defined(_WIN32) + try + { + std::wstring key; + HKEY sourceKey = nullptr; + + if (!ToSourceKey(source, key) || RegCreateKeyExW(HKEY_LOCAL_MACHINE, key.c_str(), 0, nullptr, REG_OPTION_NON_VOLATILE, + KEY_SET_VALUE, nullptr, &sourceKey, nullptr) != ERROR_SUCCESS) + return false; + + const DWORD typesSupported = EVENTLOG_ERROR_TYPE | EVENTLOG_WARNING_TYPE | EVENTLOG_INFORMATION_TYPE; + const bool isRegistered = RegSetValueExW(sourceKey, L"EventMessageFile", 0, REG_EXPAND_SZ, + reinterpret_cast(MessageFile.data()), static_cast((MessageFile.size() + 1) * sizeof(wchar_t))) == ERROR_SUCCESS && + RegSetValueExW(sourceKey, L"TypesSupported", 0, REG_DWORD, reinterpret_cast(&typesSupported), + sizeof(typesSupported)) == ERROR_SUCCESS; + RegCloseKey(sourceKey); + + return isRegistered; + } + catch (...) + { + return false; + } +#else + static_cast(source); + return false; +#endif +} + +//--------------------------------------------------------------------------- +void TASWWindowsEventLog::ReportEventUnlocked(EventType type, std::uint16_t eventId, std::string_view message) +{ +#if defined(_WIN32) + if (m_EventSource == nullptr) + return; + + const std::wstring text = UTF8ToWide(message); + const wchar_t* strings[] = { text.c_str() }; + + if (!ReportEventW(static_cast(m_EventSource), static_cast(type), 0, eventId, nullptr, 1, 0, strings, nullptr)) + { + TASWLogError error; + error.Kind = ErrorKind::WriteFailed; + error.Code = std::error_code(static_cast(GetLastError()), std::system_category()); + error.Message = "Couldn't report an event to the Windows Event Log"; + ReportErrorUnlocked(std::move(error)); + } +#else + static_cast(type); + static_cast(eventId); + static_cast(message); +#endif +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::ShouldLog(Level level) const noexcept +{ + return HasEventLog && inherited::ShouldLog(level); +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::ShouldLog(const TASWLogRecord& record) const noexcept +{ + return HasEventLog && inherited::ShouldLog(record); +} + +//--------------------------------------------------------------------------- +bool TASWWindowsEventLog::UnregisterSource(std::string_view source) noexcept +{ +#if defined(_WIN32) + try + { + std::wstring key; + + if (!ToSourceKey(source, key)) + return false; + + const LSTATUS status = RegDeleteKeyW(HKEY_LOCAL_MACHINE, key.c_str()); + + return status == ERROR_SUCCESS || status == ERROR_FILE_NOT_FOUND; + } + catch (...) + { + return false; + } +#else + static_cast(source); + return false; +#endif +} + +//--------------------------------------------------------------------------- +void TASWWindowsEventLog::WriteLineUnlocked(Level level, std::string_view line, bool endsLine) +{ + const auto& config = GetConfigUnlocked(); + + // Reported without the line ending the formatted line ends with + if (endsLine) + { + const std::string_view ending = config.Line.Ending == LineEnding::CRLF ? "\r\n" : "\n"; + if (line.ends_with(ending)) + line.remove_suffix(ending.size()); + } + + const auto levelIndex = static_cast(level); + const std::uint16_t eventId = config.EventLog.EventIds[levelIndex < LevelCount ? levelIndex : LevelCount - 1]; + + if (line.size() <= MaxMessageSize) + { + ReportEventUnlocked(ToEventType(level), eventId, line); + return; + } + + std::string text(line); + Detail::CutToFit(text, MaxMessageSize); + ReportEventUnlocked(ToEventType(level), eventId, text); +} + +//--------------------------------------------------------------------------- + +} // namespace ASWLog diff --git a/ASWLog/ASWLog_WindowsEventLog.h b/ASWLog/ASWLog_WindowsEventLog.h new file mode 100644 index 0000000..787075d --- /dev/null +++ b/ASWLog/ASWLog_WindowsEventLog.h @@ -0,0 +1,163 @@ +/* ************************************************************************** +ASWLog_WindowsEventLog.h +Author: Anthony S. West - ASW Software + +A logging library. + +Requires C++ 20 or higher. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef ASWLog_WindowsEventLogH +#define ASWLog_WindowsEventLogH +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWLog_TextLogBase.h" +//--------------------------------------------------------------------------- + +namespace ASWLog +{ + +///////////////////////////////////////////////////////////////////////////// +// TASWWindowsEventLog +// +// Reports each line to the Windows Event Log, in the Application log, under the event source +// TASWLogConfig::EventLog.Source (empty: the executable's file name without its extension). The level sets the event's +// type, as Event Viewer shows it: Trace, Debug and Info an Information event, Warn a Warning, Error and Critical an +// Error; and its event ID, from EventLog.EventIds (by default 1000 for Trace to 1005 for Critical). Each line is +// formatted like a file logger's (TASWLogConfig::Line) and reported without its line ending; a line longer than +// MaxMessageSize bytes is cut to fit, ending with " [cut]" (the Event Log refuses longer ones). +// +// Each event takes the Event Log service about a hundred microseconds, and the Application log has a limited size, so +// this logger suits warnings and errors, not a stream of details. Consider: +// +// ASWLog::TASWWindowsEventLog::GetInstance().SetMinimumLevel(ASWLog::Level::Warn); +// +// and turning off config.Line.ShowTimestamp, since Event Viewer shows each event's time (it doesn't show the process +// id). A multi-log passes one config to all its loggers, so an Event Log logger behind one writes the same layout as +// the others. +// +// Registering the source: until a source is registered, Event Viewer shows each of its events with "The description +// for Event ID ... cannot be found" before the text. RegisterSource() registers it, with the .NET Framework's +// EventLogMessages.dll (part of Windows) as its message file, which shows the text as it is. That needs administrator +// rights, once, e.g. in the application's installer; the registry entries it makes are the same as these two commands +// (the first is shown on two lines; in a .bat file, write each % as %%): +// +// reg add "HKLM\SYSTEM\CurrentControlSet\Services\EventLog\Application\MyApp" /v EventMessageFile +// /t REG_EXPAND_SZ /d "%SystemRoot%\Microsoft.NET\Framework\v4.0.30319\EventLogMessages.dll" +// reg add "HKLM\SYSTEM\CurrentControlSet\Services\EventLog\Application\MyApp" /v TypesSupported /t REG_DWORD /d 7 +// +// Initialize(), Open() and Reconfigure() open the source with RegisterEventSourceW() (which doesn't need the +// registration); Close() closes it. A crash line formatted in normal context (e.g. std::terminate, the unhandled +// exception filter, HandleCrash()) is reported as an Error event; nothing is reported if the logger's lock stays busy +// in a crash (see ASWLog_CrashHandler.h), since reporting an event allocates and could wait for a lock the crashed +// thread holds. +// +// Other platforms have no Windows Event Log, so there it writes nothing: the class builds everywhere, so the same code +// can add it on every platform, but there ShouldLog() is false and no line is formatted. (On Linux, see the syslog +// logger.) +// +// Takes the same TASWLogConfig as the other text loggers, ignoring its File settings. Thread-safe. +///////////////////////////////////////////////////////////////////////////// +class TASWWindowsEventLog : public TASWTextLogBase +{ +private: + typedef TASWTextLogBase inherited; + +public: + // An event's type, as Event Viewer shows it (the values of EVENTLOG_ERROR_TYPE etc.) + enum class EventType : std::uint16_t + { + Error = 1, + Warning = 2, + Information = 4, + }; + + // The longest message reported, in bytes of UTF-8: the Event Log's limit of 31,839 characters (UTF-16 code units), + // which a text of that many bytes never exceeds + static constexpr std::size_t MaxMessageSize = 31839; + +private: + void* m_EventSource = nullptr; // The HANDLE from RegisterEventSourceW(), or null + +private: + void CloseEventSource() noexcept; + bool OpenEventSourceFromConfigUnlocked(); + +protected: // TASWTextLogBase hooks + bool CloseUnlocked() override; + bool FlushUnlocked() override; // Nothing is buffered: true while open + bool InitializeUnlocked() override; + bool OpenUnlocked() override; + bool PrepareWriteUnlocked(std::chrono::system_clock::time_point now) override; // False where it writes nothing + bool ReconfigureUnlocked(const TASWLogConfig& previous) override; + void WriteLineUnlocked(Level level, std::string_view line, bool endsLine) override; + +protected: + std::string_view GetLoggerClassName() const noexcept override + { + return "TASWWindowsEventLog"; + } + + // Called by Initialize(), Open() and Reconfigure() with the source (EventLog.Source, or the executable's file name + // without its extension if that is empty): opens it with RegisterEventSourceW(), replacing the source opened + // before. Reports an ErrorKind::OpenFailed error and returns false if it can't, keeping the previous source. Does + // nothing and returns true where there is no Event Log. + virtual bool OpenEventSourceUnlocked(const std::string& source); + + // Reports one event with 'message' (UTF-8, at most MaxMessageSize bytes) as its text, through ReportEventW(). + // Reports an ErrorKind::WriteFailed error if that fails. Does nothing where there is no Event Log. + virtual void ReportEventUnlocked(EventType type, std::uint16_t eventId, std::string_view message); + +public: // Static methods + // Singleton support for the common static instance. Never destroyed; finalized at exit in static destruction + // order, like TASWFileLog::GetInstance() (see there). + static TASWWindowsEventLog& GetInstance(); + + // Registers 'source' (empty: the executable's file name without its extension) in the Application log, with + // EventLogMessages.dll as its message file (see above), replacing an earlier registration of that name. Needs + // administrator rights; returns false if it couldn't (or where there is no Event Log, or if 'source' holds a '\'). + static bool RegisterSource(std::string_view source = {}) noexcept; + + // Removes the registration of 'source' (empty: as for RegisterSource()). Needs administrator rights; returns true + // if the source isn't registered afterwards (also if it wasn't), false if it couldn't be removed (or where there is + // no Event Log, or if 'source' holds a '\'). + static bool UnregisterSource(std::string_view source = {}) noexcept; + +public: + TASWWindowsEventLog() = default; + // Writes the shutdown line (see TASWTextLogBase::Finalize()) + ~TASWWindowsEventLog() override; + + TASWWindowsEventLog(const TASWWindowsEventLog&) = delete; + TASWWindowsEventLog& operator=(const TASWWindowsEventLog&) = delete; + + // As for the other loggers on Windows; always false where there is no Event Log + bool ShouldLog(Level level) const noexcept override; + bool ShouldLog(const TASWLogRecord& record) const noexcept override; +}; + +} // namespace ASWLog + +#endif // #ifndef ASWLog_WindowsEventLogH diff --git a/CHANGELOG.md b/CHANGELOG.md index c69d801..d914505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,114 @@ see [0.26.1](#0261---2026-09-17) for the initial versioned baseline. logger's own lines (startup, shutdown, backtrace markers, crash lines) don't pass through it. A multi-log runs its hook once per entry and passes its config on without it. +- `TASWNullLog` (new `ASWLog_NullLog.h`): a logger that writes nothing, + e.g. to turn logging off behind an `IASWLog&` or to pass to code under + test; `TASWNullLog::GetInstance()` gives a shared one, e.g. for a default + argument. No logging call formats or copies anything (`ShouldLog()` and + `IsEnabled()` are always false), while the lifecycle methods succeed, + `GetConfig()` returns the config last passed in, and the minimum level + reads back as set. +- `TASWMemoryLog` (new `ASWLog_MemoryLog.h`): a text logger that keeps its + newest lines in memory, formatted like a file logger's and without the + line ending, e.g. for an in-app log viewer, a crash report or tests of + code that logs. The new `TASWLogConfig::Memory` group sets how much it + keeps: `MaxLines` (default 1000) and `MaxBytes` (default 0, no limit; a + single longer line is cut to fit, ending with " [cut]"); the oldest lines + go first. `GetLines()`, `GetLineCount()` and `Clear()` read and clear + them, and `GetLinesSince(n)` returns only the lines numbered n and up + (every kept line gets the next sequence number), with the number to ask + for next and how many lines were missed, so a viewer can poll for new + lines. +- `TASWDebuggerLog` (new `ASWLog_DebuggerLog.h`): writes each line to the + debugger's output with `OutputDebugStringW` on Windows, shown in the RAD + Studio Event Log, Visual Studio's Output window and DebugView (even + without a debugger; each line then still costs some microseconds, so add + it where wanted, e.g. in debug builds). `TASWDebuggerLog::GetInstance()` + gives the shared one. Other platforms have no debugger output: there the + class builds but writes nothing (`ShouldLog()` is false and no line is + formatted), so the same code can add it everywhere. +- `TASWSyslogLog` (new `ASWLog_SyslogLog.h`): sends each line to the system + log with `syslog()` on Linux (the journal on systemd hosts), without its + line ending, the level as the priority (Trace and Debug `LOG_DEBUG`, Info + `LOG_INFO`, Warn `LOG_WARNING`, Error `LOG_ERR`, Critical `LOG_CRIT`). + The new `TASWLogConfig::Syslog` group sets the ident (`Ident`, empty = + the executable's file name; passed to `openlog()` with `LOG_PID`, so it + is process-wide) and the facility (`Facility`, a new `SyslogFacility`: + `User`, the default, `Daemon` or `Local0` to `Local7`; sent with each + message). The line is formatted as configured, so consider turning off + `Line.ShowTimestamp` and `Line.ShowProcessId`, which syslog adds itself. + `TASWSyslogLog::GetInstance()` gives the shared one. Windows has no + syslog: there the class builds but writes nothing (`ShouldLog()` is false + and no line is formatted). +- `TASWWindowsEventLog` (new `ASWLog_WindowsEventLog.h`): reports each line + to the Windows Event Log (Application log), without its line ending: Trace, + Debug and Info as Information events, Warn as Warning, Error and Critical + as Error. The new `TASWLogConfig::EventLog` group sets the event source + (`Source`, empty = the executable's file name without its extension) and + the event ID per level (`EventIds`, by default 1000 for Trace to 1005 for + Critical). A line longer than `TASWWindowsEventLog::MaxMessageSize` + (31,839 bytes) is cut to fit, ending with ` [cut]`. Each event takes + about a hundred microseconds, so it suits warnings and errors. + `TASWWindowsEventLog::RegisterSource()` and `UnregisterSource()` register + a source (administrator rights, e.g. in an installer) with the .NET + Framework's `EventLogMessages.dll`, so that Event Viewer shows the text + without "The description for Event ID ... cannot be found". + `TASWWindowsEventLog::GetInstance()` gives the shared one. Other platforms + have no Event Log: there the class builds but writes nothing. +- Rate-limited logging (new `ASWLog_Limiter.h`): `ASWLOG_ONCE(log, level, + ...)`, `ASWLOG_EVERY_N(log, n, level, ...)` and `ASWLOG_EVERY_INTERVAL(log, + interval, level, ...)` log only the first, every nth, or at most one entry + per interval at the place they are written, e.g. + `ASWLOG_EVERY_INTERVAL(log, std::chrono::seconds(5), ASWLog::Level::Warn, + "Retrying {}", host);`. The arguments after the level are those of + `LogFmt()` (format string, arguments, optional fields). Only entries the + logger would write count, and only those are formatted. After skipped calls, + `ASWLOG_EVERY_INTERVAL` adds a `suppressed` field with their number. The + macros keep a lock-free `TASWLogLimiter` (`Once()`, `EveryN()`, + `EveryInterval()`) in a static where they are written; the class can also be + used on its own. +- Settings from environment variables, e.g. to turn on full verbosity in a + deployed release build without rebuilding it: + `config.ApplyEnvironment();` before `Initialize()` reads `ASWLOG_LEVEL`, + `ASWLOG_CATEGORIES`, `ASWLOG_FOLDER`, `ASWLOG_FILE`, `ASWLOG_FLUSH`, + `ASWLOG_SYNC_AT_LEVEL`, `ASWLOG_ASYNC`, `ASWLOG_BACKTRACE`, + `ASWLOG_TIME_ZONE`, `ASWLOG_TIME_PRECISION`, `ASWLOG_MULTILINE`, + `ASWLOG_SHOW_FUNCTION`, `ASWLOG_SHOW_SOURCE`, `ASWLOG_FORMAT` (`Text` or + `JSON`) and `ASWLOG_PATTERN` (a `TASWPatternFormatter` pattern), and their + values replace what the code set (see `TASWLogConfig::ApplyEnvironment()` + for the values; case doesn't matter). Nothing reads the environment unless + the application calls it, and each logger can have its own prefix, e.g. + `ApplyEnvironment("MYAPP_CONSOLE_")`. A variable that isn't set or is + empty changes nothing; an invalid value changes nothing either and is + reported to `OnError` (or stderr) as the new `ErrorKind::InvalidSetting`. + `ApplySetting(key, value)` sets one of the same settings, e.g. from a + command line or a settings file. `ASWLOG_CATEGORIES`, e.g. + `*=Info,Net=Debug,Net.Http=Trace`, sets category levels through the new + `TASWCategoryLog::ApplyLevels()`: a name covers that category and those + under it (`Net` covers `Net.Http`), `*` covers all, and the longest name + wins; it applies process-wide, to the categories that exist and to those + made later, and a spec with an invalid item changes nothing. On Windows, + the values are read as UTF-8 (paths included). Breaking only for code that + lists every `ErrorKind` (e.g. a `switch`) or relies on `ErrorKindCount` + being 9. +- Wide-string logging (new `ASWLog_Unicode.h`; add `ASWLog_Unicode.cpp` to + projects that list the ASWLog sources). Every logging method also takes a + message of wide text, `wchar_t` (e.g. `L"Opened"`, a `std::wstring`, or + C++Builder's `UnicodeString::c_str()`) or `char16_t` (e.g. `u"Opened"`), + converted to UTF-8 only if the entry is used, so a filtered call converts + nothing; field keys and texts stay UTF-8. Every `*Fmt` method also takes a + `wchar_t` format string, checked at compile time like a narrow one, e.g. + `log.LogInfoFmt(L"Opened {} ({} bytes)", fileName, size);` (wrap a + run-time one in `RuntimeFormat()`); its arguments are formatted as wide + text, so pass UTF-8 text as `ASWLog::UTF8(text)` (a narrow `std::string` + doesn't compile there). In a narrow `*Fmt` call, `ASWLog::Wide(text)` + formats wide text, converted only if the entry is formatted, e.g. + `log.LogInfoFmt("Opened {}", ASWLog::Wide(fileName));`. `WideToUTF8()` + (from `std::wstring_view` or `std::u16string_view`), `UTF8ToWide()` and + `UTF8ToUTF16()` convert on any platform (`wchar_t` text is UTF-16 on + Windows and UTF-32 on Linux); invalid input becomes U+FFFD. `std::format` + has no `char16_t` format strings, so `char16_t` text goes in as a message + or through `Wide()`. ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index 8ff3738..3443741 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,8 @@ add_library(ASWLog STATIC ASWLog/ASWLog_ConsoleLog.h ASWLog/ASWLog_CrashHandler.cpp ASWLog/ASWLog_CrashHandler.h + ASWLog/ASWLog_DebuggerLog.cpp + ASWLog/ASWLog_DebuggerLog.h ASWLog/ASWLog_Fields.cpp ASWLog/ASWLog_Fields.h ASWLog/ASWLog_FileLog.cpp @@ -36,16 +38,28 @@ add_library(ASWLog STATIC ASWLog/ASWLog_Formatter.h ASWLog/ASWLog_Interface.cpp ASWLog/ASWLog_Interface.h + ASWLog/ASWLog_Limiter.cpp + ASWLog/ASWLog_Limiter.h + ASWLog/ASWLog_MemoryLog.cpp + ASWLog/ASWLog_MemoryLog.h ASWLog/ASWLog_MultiLog.cpp ASWLog/ASWLog_MultiLog.h + ASWLog/ASWLog_NullLog.cpp + ASWLog/ASWLog_NullLog.h + ASWLog/ASWLog_SyslogLog.cpp + ASWLog/ASWLog_SyslogLog.h ASWLog/ASWLog_TextLogBase.cpp ASWLog/ASWLog_TextLogBase.h ASWLog/ASWLog_Types.cpp ASWLog/ASWLog_Types.h + ASWLog/ASWLog_Unicode.cpp + ASWLog/ASWLog_Unicode.h ASWLog/ASWLog_Utils.cpp ASWLog/ASWLog_Utils.h ASWLog/ASWLog_Version.cpp ASWLog/ASWLog_Version.h + ASWLog/ASWLog_WindowsEventLog.cpp + ASWLog/ASWLog_WindowsEventLog.h ) add_library(ASWLog::ASWLog ALIAS ASWLog) @@ -55,8 +69,9 @@ set_target_properties(ASWLog PROPERTIES CXX_EXTENSIONS OFF) target_include_directories(ASWLog PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/ASWLog") if(WIN32) - # ASWLog_Utils.cpp uses the process memory APIs from psapi. - target_link_libraries(ASWLog PRIVATE psapi) + # ASWLog_Utils.cpp uses the process memory APIs from psapi, and ASWLog_WindowsEventLog.cpp the Event Log and + # registry APIs from advapi32. + target_link_libraries(ASWLog PRIVATE psapi advapi32) endif() # TASWTextLogBase runs a std::thread (e.g. for FlushMode::Periodic). Public, since the thread is part of the class diff --git a/Deploy.bat b/Deploy.bat index 3428670..9edb618 100644 --- a/Deploy.bat +++ b/Deploy.bat @@ -84,6 +84,10 @@ call :CopyFile "ASWLog\ASWLog_CrashHandler.cpp" "%TARGET_DIR%\ASWLog\ASWLog_Cras if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_CrashHandler.h" "%TARGET_DIR%\ASWLog\ASWLog_CrashHandler.h" if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_DebuggerLog.cpp" "%TARGET_DIR%\ASWLog\ASWLog_DebuggerLog.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_DebuggerLog.h" "%TARGET_DIR%\ASWLog\ASWLog_DebuggerLog.h" +if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_Fields.cpp" "%TARGET_DIR%\ASWLog\ASWLog_Fields.cpp" if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_Fields.h" "%TARGET_DIR%\ASWLog\ASWLog_Fields.h" @@ -100,10 +104,26 @@ call :CopyFile "ASWLog\ASWLog_Interface.cpp" "%TARGET_DIR%\ASWLog\ASWLog_Interfa if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_Interface.h" "%TARGET_DIR%\ASWLog\ASWLog_Interface.h" if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_Limiter.cpp" "%TARGET_DIR%\ASWLog\ASWLog_Limiter.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_Limiter.h" "%TARGET_DIR%\ASWLog\ASWLog_Limiter.h" +if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_MemoryLog.cpp" "%TARGET_DIR%\ASWLog\ASWLog_MemoryLog.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_MemoryLog.h" "%TARGET_DIR%\ASWLog\ASWLog_MemoryLog.h" +if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_MultiLog.cpp" "%TARGET_DIR%\ASWLog\ASWLog_MultiLog.cpp" if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_MultiLog.h" "%TARGET_DIR%\ASWLog\ASWLog_MultiLog.h" if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_NullLog.cpp" "%TARGET_DIR%\ASWLog\ASWLog_NullLog.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_NullLog.h" "%TARGET_DIR%\ASWLog\ASWLog_NullLog.h" +if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_SyslogLog.cpp" "%TARGET_DIR%\ASWLog\ASWLog_SyslogLog.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_SyslogLog.h" "%TARGET_DIR%\ASWLog\ASWLog_SyslogLog.h" +if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_TextLogBase.cpp" "%TARGET_DIR%\ASWLog\ASWLog_TextLogBase.cpp" if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_TextLogBase.h" "%TARGET_DIR%\ASWLog\ASWLog_TextLogBase.h" @@ -112,6 +132,10 @@ call :CopyFile "ASWLog\ASWLog_Types.cpp" "%TARGET_DIR%\ASWLog\ASWLog_Types.cpp" if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_Types.h" "%TARGET_DIR%\ASWLog\ASWLog_Types.h" if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_Unicode.cpp" "%TARGET_DIR%\ASWLog\ASWLog_Unicode.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_Unicode.h" "%TARGET_DIR%\ASWLog\ASWLog_Unicode.h" +if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_Utils.cpp" "%TARGET_DIR%\ASWLog\ASWLog_Utils.cpp" if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_Utils.h" "%TARGET_DIR%\ASWLog\ASWLog_Utils.h" @@ -120,6 +144,10 @@ call :CopyFile "ASWLog\ASWLog_Version.cpp" "%TARGET_DIR%\ASWLog\ASWLog_Version.c if errorlevel 1 exit /b 1 call :CopyFile "ASWLog\ASWLog_Version.h" "%TARGET_DIR%\ASWLog\ASWLog_Version.h" if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_WindowsEventLog.cpp" "%TARGET_DIR%\ASWLog\ASWLog_WindowsEventLog.cpp" +if errorlevel 1 exit /b 1 +call :CopyFile "ASWLog\ASWLog_WindowsEventLog.h" "%TARGET_DIR%\ASWLog\ASWLog_WindowsEventLog.h" +if errorlevel 1 exit /b 1 call :CopyFile "example\cmake\CMakeLists.txt" "%TARGET_DIR%\example\cmake\CMakeLists.txt" if errorlevel 1 exit /b 1 diff --git a/README.md b/README.md index b406287..9280917 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,24 @@ ASWLog is a thread-safe light-weight C++ logging tool for Windows and Linux proj - Multi-line messages (e.g. an HTTP body) written as they are, or, with `TASWLogConfig::Line.Multiline`, each line after the first marked with ` | ` (`MultilineMode::Indent`) or the line breaks escaped as `\r` and `\n` (`MultilineMode::Escape`), so that every entry is recognizable as one. +- Rate-limited logging for entries that can repeat often: `ASWLOG_ONCE`, `ASWLOG_EVERY_N` and `ASWLOG_EVERY_INTERVAL` + (`ASWLog_Limiter.h`), e.g. `ASWLOG_EVERY_INTERVAL(log, std::chrono::seconds(5), ASWLog::Level::Warn, "Retrying {}", + host);`, which counts the skipped entries in a `suppressed` field. - A hook to drop or change entries before they are written (`TASWLogConfig::OnBeforeWrite`), e.g. to redact secrets or drop noisy entries by content, category or field. - Structured fields per entry, e.g. `logger.LogInfo("Order placed", {{"orderId", 17}})`, and scoped context that every entry on the thread carries, e.g. `ASWLog::TASWLogScope scope{{"requestId", id}};` (`ASWLog_Fields.h`). +- A debugger output logger (`ASWLog::TASWDebuggerLog`) for the RAD Studio Event Log, Visual Studio's Output window and + DebugView (Windows; it writes nothing elsewhere). +- A memory logger (`ASWLog::TASWMemoryLog`) that keeps the newest lines, limited by count and size + (`TASWLogConfig::Memory`), for an in-app log viewer (`GetLinesSince()` returns only the new ones), a crash report or + tests. +- A null logger (`ASWLog::TASWNullLog`) that writes nothing, to turn logging off behind an `IASWLog&` or to pass to + code under test. +- A syslog logger (`ASWLog::TASWSyslogLog`) for the Linux system log and the systemd journal, with the level as the + priority and the ident and facility in `TASWLogConfig::Syslog` (Linux; it writes nothing on Windows). +- A Windows Event Log logger (`ASWLog::TASWWindowsEventLog`) for warnings and errors in Event Viewer, with an event ID + per level and a helper to register the event source (Windows; it writes nothing elsewhere). - Extensible: assign your own formatter (`IASWLogFormatter`) to `TASWLogConfig::Line.Formatter` for your own line layout, or derive a new text logger from `TASWTextLogBase` and implement only its output. diff --git a/example/rad370/ASWLogExample.cbproj b/example/rad370/ASWLogExample.cbproj index 80045fc..db89dc1 100644 --- a/example/rad370/ASWLogExample.cbproj +++ b/example/rad370/ASWLogExample.cbproj @@ -155,6 +155,10 @@ ..\..\ASWLog\ASWLog_CrashHandler.h 13 + + ..\..\ASWLog\ASWLog_DebuggerLog.h + 18 + ..\..\ASWLog\ASWLog_Fields.h 15 @@ -171,10 +175,26 @@ ..\..\ASWLog\ASWLog_Interface.h 6 + + ..\..\ASWLog\ASWLog_Limiter.h + 21 + + + ..\..\ASWLog\ASWLog_MemoryLog.h + 17 + ..\..\ASWLog\ASWLog_MultiLog.h 7 + + ..\..\ASWLog\ASWLog_NullLog.h + 16 + + + ..\..\ASWLog\ASWLog_SyslogLog.h + 19 + ..\..\ASWLog\ASWLog_TextLogBase.h 10 @@ -183,6 +203,10 @@ ..\..\ASWLog\ASWLog_Types.h 8 + + ..\..\ASWLog\ASWLog_Unicode.h + 22 + ..\..\ASWLog\ASWLog_Utils.h 9 @@ -191,6 +215,10 @@ ..\..\ASWLog\ASWLog_Version.h 12 + + ..\..\ASWLog\ASWLog_WindowsEventLog.h + 20 + 0 true diff --git a/tests/cmake/CMakeLists.txt b/tests/cmake/CMakeLists.txt index c7bfdd6..69ca1e4 100644 --- a/tests/cmake/CMakeLists.txt +++ b/tests/cmake/CMakeLists.txt @@ -37,16 +37,25 @@ add_executable(ASWLogTests "${ASWLOG_TESTS_DIR}/Test_ASWLog_Config.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_ConsoleLog.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_CrashHandler.cpp" + "${ASWLOG_TESTS_DIR}/Test_ASWLog_DebuggerLog.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_Fields.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_FileLog.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_Formatter.cpp" + "${ASWLOG_TESTS_DIR}/Test_ASWLog_Limiter.cpp" + "${ASWLOG_TESTS_DIR}/Test_ASWLog_MemoryLog.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_MultiLog.cpp" + "${ASWLOG_TESTS_DIR}/Test_ASWLog_NullLog.cpp" + "${ASWLOG_TESTS_DIR}/Test_ASWLog_SyslogLog.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_TextLogBase.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_Types.cpp" + "${ASWLOG_TESTS_DIR}/Test_ASWLog_Unicode.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_Utils.cpp" "${ASWLOG_TESTS_DIR}/Test_ASWLog_Version.cpp" + "${ASWLOG_TESTS_DIR}/Test_ASWLog_WindowsEventLog.cpp" "${ASWLOG_TESTS_DIR}/UT_Helper_DateTime.cpp" + "${ASWLOG_TESTS_DIR}/UT_Helper_Environment.cpp" "${ASWLOG_TESTS_DIR}/UT_Helper_JSON.cpp" + "${ASWLOG_TESTS_DIR}/UT_Helper_StdErr.cpp" ) target_include_directories(ASWLogTests PRIVATE @@ -102,7 +111,7 @@ function(aswlog_check_format_string checkCase expectCompiles) endfunction() aswlog_check_format_string(0 TRUE) -foreach(checkCase 1 2 3) +foreach(checkCase 1 2 3 4 5 6) aswlog_check_format_string(${checkCase} FALSE) endforeach() message(STATUS "Format string compile-time checks: passed") diff --git a/tests/compile_checks/Check_FormatString.cpp b/tests/compile_checks/Check_FormatString.cpp index d856f6f..1eca521 100644 --- a/tests/compile_checks/Check_FormatString.cpp +++ b/tests/compile_checks/Check_FormatString.cpp @@ -40,6 +40,13 @@ void LogFormattedEntries(ASWLog::IASWLog& logger) logger.LogInfoFmt("{} {}", 1, "text"); logger.LogFmt(ASWLog::Level::Warn, "{:d}", 2); logger.LogErrorFmt(ASWLog::RuntimeFormat(runtimeFormat), 3, 4); + + // The same with wide format strings, and wide and UTF-8 text as arguments + const std::wstring wideRuntimeFormat = L"{} {}"; + logger.LogInfoFmt(L"{} {} {}", 1, L"text", ASWLog::UTF8("utf8")); + logger.LogFmt(ASWLog::Level::Warn, L"{:d}", 2); + logger.LogErrorFmt(ASWLog::RuntimeFormat(wideRuntimeFormat), 3, 4); + logger.LogInfoFmt("{} {}", ASWLog::Wide(L"wide"), ASWLog::Wide(u"utf16")); #elif ASWLOG_CHECK_CASE == 1 logger.LogInfoFmt("{} {}", 1); // Too few arguments #elif ASWLOG_CHECK_CASE == 2 @@ -47,6 +54,13 @@ void LogFormattedEntries(ASWLog::IASWLog& logger) #elif ASWLOG_CHECK_CASE == 3 const std::string runtimeFormat = "{} {}"; logger.LogErrorFmt(runtimeFormat, 3, 4); // A runtime format string without RuntimeFormat() +#elif ASWLOG_CHECK_CASE == 4 + logger.LogInfoFmt(L"{} {}", 1); // Too few arguments for a wide format string +#elif ASWLOG_CHECK_CASE == 5 + logger.LogInfoFmt(L"{}", std::string("text")); // Narrow text in a wide format string (use ASWLog::UTF8()) +#elif ASWLOG_CHECK_CASE == 6 + const std::wstring runtimeFormat = L"{} {}"; + logger.LogErrorFmt(runtimeFormat, 3, 4); // A wide runtime format string without RuntimeFormat() #else #error Unknown ASWLOG_CHECK_CASE #endif diff --git a/tests/rad370/ASWLogTests.cbproj b/tests/rad370/ASWLogTests.cbproj index 6f76ec1..8f84e31 100644 --- a/tests/rad370/ASWLogTests.cbproj +++ b/tests/rad370/ASWLogTests.cbproj @@ -170,6 +170,10 @@ ..\..\ASWLog\ASWLog_CrashHandler.h 33 + + ..\..\ASWLog\ASWLog_DebuggerLog.h + 45 + ..\..\ASWLog\ASWLog_Fields.h 39 @@ -186,10 +190,26 @@ ..\..\ASWLog\ASWLog_Interface.h 4 + + ..\..\ASWLog\ASWLog_Limiter.h + 51 + + + ..\..\ASWLog\ASWLog_MemoryLog.h + 43 + ..\..\ASWLog\ASWLog_MultiLog.h 5 + + ..\..\ASWLog\ASWLog_NullLog.h + 41 + + + ..\..\ASWLog\ASWLog_SyslogLog.h + 47 + ..\..\ASWLog\ASWLog_TextLogBase.h 26 @@ -198,6 +218,10 @@ ..\..\ASWLog\ASWLog_Types.h 6 + + ..\..\ASWLog\ASWLog_Unicode.h + 55 + ..\..\ASWLog\ASWLog_Utils.h 7 @@ -206,6 +230,10 @@ ..\..\ASWLog\ASWLog_Version.h 30 + + ..\..\ASWLog\ASWLog_WindowsEventLog.h + 49 + 0 true @@ -230,6 +258,10 @@ ..\tests\Test_ASWLog_CrashHandler.h 34 + + ..\tests\Test_ASWLog_DebuggerLog.h + 46 + ..\tests\Test_ASWLog_Fields.h 40 @@ -242,10 +274,26 @@ ..\tests\Test_ASWLog_Formatter.h 29 + + ..\tests\Test_ASWLog_Limiter.h + 52 + + + ..\tests\Test_ASWLog_MemoryLog.h + 44 + ..\tests\Test_ASWLog_MultiLog.h 23 + + ..\tests\Test_ASWLog_NullLog.h + 42 + + + ..\tests\Test_ASWLog_SyslogLog.h + 48 + ..\tests\Test_ASWLog_TextLogBase.h 27 @@ -254,6 +302,10 @@ ..\tests\Test_ASWLog_Types.h 24 + + ..\tests\Test_ASWLog_Unicode.h + 56 + ..\tests\Test_ASWLog_Utils.h 25 @@ -262,14 +314,26 @@ ..\tests\Test_ASWLog_Version.h 31 + + ..\tests\Test_ASWLog_WindowsEventLog.h + 50 + ..\tests\UT_Helper_DateTime.h 35 + + ..\tests\UT_Helper_Environment.h + 53 + ..\tests\UT_Helper_JSON.h 38 + + ..\tests\UT_Helper_StdErr.h + 54 + ..\..\third_party\asw-unit-tests\src\ASWUnitTests_CLI.h 8 diff --git a/tests/tests/Test_ASWLog_Base.cpp b/tests/tests/Test_ASWLog_Base.cpp index a025cb1..b688ea0 100644 --- a/tests/tests/Test_ASWLog_Base.cpp +++ b/tests/tests/Test_ASWLog_Base.cpp @@ -40,19 +40,14 @@ limitations under the License. #include #include #include - -#if defined(_WIN32) -#include -#include -#else -#include -#endif //--------------------------------------------------------------------------- #include "ASWUnitTests_Registry.h" //--------------------------------------------------------------------------- #include "ASWLog_Base.h" #include "ASWLog_Utils.h" //--------------------------------------------------------------------------- +#include "UT_Helper_StdErr.h" +//--------------------------------------------------------------------------- namespace { @@ -216,67 +211,6 @@ ASWLog::TASWLogConfig MakeBacktraceConfig(std::size_t capacity, ASWLog::Level lo return config; } -// While alive, sends what is written to stderr to 'file' instead, by pointing stderr's file descriptor at it. Inactive -// if stderr has no file descriptor (e.g. in a GUI application without a console). -class TStdErrRedirect -{ -private: - int m_SavedDescriptor = -1; - -public: - explicit TStdErrRedirect(const std::filesystem::path& file) - { - std::fflush(stderr); -#if defined(_WIN32) - std::FILE* target = _wfsopen(file.c_str(), L"wb", _SH_DENYNO); - if (target == nullptr) - return; - - m_SavedDescriptor = _dup(_fileno(stderr)); - if (m_SavedDescriptor >= 0 && _dup2(_fileno(target), _fileno(stderr)) != 0) - { - _close(m_SavedDescriptor); - m_SavedDescriptor = -1; - } -#else - std::FILE* target = std::fopen(file.c_str(), "wb"); - if (target == nullptr) - return; - - m_SavedDescriptor = dup(fileno(stderr)); - if (m_SavedDescriptor >= 0 && dup2(fileno(target), fileno(stderr)) < 0) - { - close(m_SavedDescriptor); - m_SavedDescriptor = -1; - } -#endif - std::fclose(target); - } - - ~TStdErrRedirect() - { - if (m_SavedDescriptor < 0) - return; - - std::fflush(stderr); -#if defined(_WIN32) - _dup2(m_SavedDescriptor, _fileno(stderr)); - _close(m_SavedDescriptor); -#else - dup2(m_SavedDescriptor, fileno(stderr)); - close(m_SavedDescriptor); -#endif - } - - TStdErrRedirect(const TStdErrRedirect&) = delete; - TStdErrRedirect& operator=(const TStdErrRedirect&) = delete; - - bool IsActive() const noexcept - { - return m_SavedDescriptor >= 0; - } -}; - } // namespace //--------------------------------------------------------------------------- diff --git a/tests/tests/Test_ASWLog_CategoryLog.cpp b/tests/tests/Test_ASWLog_CategoryLog.cpp index 5ce4df1..d8a7be3 100644 --- a/tests/tests/Test_ASWLog_CategoryLog.cpp +++ b/tests/tests/Test_ASWLog_CategoryLog.cpp @@ -24,6 +24,7 @@ limitations under the License. // Module header #include "Test_ASWLog_CategoryLog.h" //--------------------------------------------------------------------------- +#include #include #include #include @@ -44,6 +45,7 @@ limitations under the License. #include "ASWLog_CategoryLog.h" #include "ASWLog_FileLog.h" #include "ASWLog_MultiLog.h" +#include "ASWLog_NullLog.h" //--------------------------------------------------------------------------- namespace @@ -202,6 +204,19 @@ std::string ReadFileText(const std::filesystem::path& path) return std::string((std::istreambuf_iterator(stream)), std::istreambuf_iterator()); } +// Clears the category levels a test applied (they are process-wide) when it ends +struct TCategoryLevelsReset +{ + TCategoryLevelsReset() = default; + TCategoryLevelsReset(const TCategoryLevelsReset&) = delete; + TCategoryLevelsReset& operator=(const TCategoryLevelsReset&) = delete; + + ~TCategoryLevelsReset() + { + ASWLog::TASWCategoryLog::ApplyLevels(""); + } +}; + // Checks 'condition' until it is true, for at most WaitTimeout. Returns false if it never was. bool WaitUntil(const std::function& condition) { @@ -229,6 +244,11 @@ bool WaitUntil(const std::function& condition) TTest_ASWLog_CategoryLog::TTest_ASWLog_CategoryLog() : inherited("ASWLog_CategoryLog_Tests") { + RegisterTest(&TTest_ASWLog_CategoryLog::Test_ApplyLevels_AppliesToCategoriesMadeLater, "ApplyLevels_AppliesToCategoriesMadeLater"); + RegisterTest(&TTest_ASWLog_CategoryLog::Test_ApplyLevels_GivesTheLongestCoveringNamesLevel, "ApplyLevels_GivesTheLongestCoveringNamesLevel"); + RegisterTest(&TTest_ASWLog_CategoryLog::Test_ApplyLevels_IsSafeWhileCategoriesComeAndGo, "ApplyLevels_IsSafeWhileCategoriesComeAndGo"); + RegisterTest(&TTest_ASWLog_CategoryLog::Test_ApplyLevels_LeavesUncoveredCategories, "ApplyLevels_LeavesUncoveredCategories"); + RegisterTest(&TTest_ASWLog_CategoryLog::Test_ApplyLevels_RejectsAnInvalidSpec, "ApplyLevels_RejectsAnInvalidSpec"); RegisterTest(&TTest_ASWLog_CategoryLog::Test_Async_KeepsItsOwnCopyOfTheName, "Async_KeepsItsOwnCopyOfTheName"); RegisterTest(&TTest_ASWLog_CategoryLog::Test_Backtrace_KeepsEntriesBelowTheCategoryLevel, "Backtrace_KeepsEntriesBelowTheCategoryLevel"); RegisterTest(&TTest_ASWLog_CategoryLog::Test_Backtrace_KeepsItsOwnCopyOfTheName, "Backtrace_KeepsItsOwnCopyOfTheName"); @@ -276,6 +296,189 @@ void TTest_ASWLog_CategoryLog::TearDown_Test(ITestCase& testCase) // /////// Begin tests after this line /////////////////////// +//--------------------------------------------------------------------------- +void TTest_ASWLog_CategoryLog::Test_ApplyLevels_AppliesToCategoriesMadeLater() +{ + // Arrange + TCategoryLevelsReset resetLevels; + TRecordingLogger logger; + + // Act + const bool isValid = ASWLog::TASWCategoryLog::ApplyLevels("Later=Debug"); + ASWLog::TASWCategoryLog laterLog("Later", logger); + ASWLog::TASWCategoryLog httpLog("Http", laterLog); + ASWLog::TASWCategoryLog otherLog("Other", logger); + + // Assert + CheckTrue(isValid, "The spec should be valid"); + CheckEquals(ASWLog::Level::Debug, laterLog.GetMinimumLevel(), "A category made after ApplyLevels() should get its level"); + CheckTrue(laterLog.HasOwnMinimumLevel(), "The spec's level should be the category's own"); + CheckTrue(httpLog.HasOwnMinimumLevel(), "A category under a covered one should get the level too"); + CheckEquals(ASWLog::Level::Debug, httpLog.GetMinimumLevel(), "Later.Http should get Later's level"); + CheckFalse(otherLog.HasOwnMinimumLevel(), "A category the spec doesn't cover should get no level"); + + // Act: an empty spec covers no category made later + ASWLog::TASWCategoryLog::ApplyLevels(""); + ASWLog::TASWCategoryLog afterClearLog("Later", logger); + + // Assert + CheckFalse(afterClearLog.HasOwnMinimumLevel(), "After an empty spec, a new category should get no level"); + CheckEquals(ASWLog::Level::Debug, laterLog.GetMinimumLevel(), "An empty spec should leave the levels already set"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_CategoryLog::Test_ApplyLevels_GivesTheLongestCoveringNamesLevel() +{ + // Arrange + TCategoryLevelsReset resetLevels; + TRecordingLogger logger; + ASWLog::TASWCategoryLog dbLog("Db", logger); + ASWLog::TASWCategoryLog netLog("Net", logger); + ASWLog::TASWCategoryLog dnsLog("Dns", netLog); + ASWLog::TASWCategoryLog httpLog("Http", netLog); + ASWLog::TASWCategoryLog proxyLog("Proxy", httpLog); + ASWLog::TASWCategoryLog networkLog("Network", logger); + + // Act: broader names first, any case, spaces, an empty item, and Net given twice (the last wins) + const bool isValid = ASWLog::TASWCategoryLog::ApplyLevels(" * = Warn , net=Debug, NET.HTTP = trace,, Net=Info, "); + + // Assert + CheckTrue(isValid, "The spec should be valid"); + CheckEquals(ASWLog::Level::Warn, dbLog.GetMinimumLevel(), "* should cover Db"); + CheckEquals(ASWLog::Level::Info, netLog.GetMinimumLevel(), "Net should get its last level"); + CheckEquals(ASWLog::Level::Info, dnsLog.GetMinimumLevel(), "Net should cover Net.Dns"); + CheckEquals(ASWLog::Level::Trace, httpLog.GetMinimumLevel(), "Net.Http should win over Net for Net.Http"); + CheckEquals(ASWLog::Level::Trace, proxyLog.GetMinimumLevel(), "Net.Http should cover Net.Http.Proxy"); + CheckEquals(ASWLog::Level::Warn, networkLog.GetMinimumLevel(), "Net should not cover Network, only * should"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_CategoryLog::Test_ApplyLevels_IsSafeWhileCategoriesComeAndGo() +{ + // Arrange + constexpr int ThreadCount = 4; + constexpr int CategoriesPerThread = 2000; + + TCategoryLevelsReset resetLevels; + ASWLog::TASWNullLog logger; // Thread-safe + std::atomic isDone{ false }; + std::atomic withoutLevelCount{ 0 }; + ASWLog::TASWCategoryLog::ApplyLevels("Churn=Warn"); + + // Act: threads make, use and destroy categories while the levels change + std::vector threads; + for (int thread = 0; thread < ThreadCount; ++thread) + { + threads.emplace_back([&logger, &withoutLevelCount] + { + for (int index = 0; index < CategoriesPerThread; ++index) + { + ASWLog::TASWCategoryLog churnLog("Churn", logger); + const auto level = churnLog.GetMinimumLevel(); + if (!churnLog.HasOwnMinimumLevel() || (level != ASWLog::Level::Warn && level != ASWLog::Level::Debug)) + ++withoutLevelCount; + + churnLog.LogDebug("churn"); + } + }); + } + + const auto applyLevels = [&isDone] + { + for (int round = 0; !isDone.load(); ++round) + ASWLog::TASWCategoryLog::ApplyLevels(round % 2 == 0 ? "Churn=Warn" : "Churn=Debug"); + }; + std::thread applier(applyLevels); + + for (auto& thread : threads) + thread.join(); + + isDone.store(true); + applier.join(); + + // Assert + CheckEquals(0, withoutLevelCount.load(), "Every category made after the first spec should get a level"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_CategoryLog::Test_ApplyLevels_LeavesUncoveredCategories() +{ + // Arrange + TCategoryLevelsReset resetLevels; + TRecordingLogger logger; + ASWLog::TASWCategoryLog coveredLog("Covered", logger); + ASWLog::TASWCategoryLog freeLog("Free", logger); + ASWLog::TASWCategoryLog codedLog("Coded", logger); + codedLog.SetMinimumLevel(ASWLog::Level::Error); + + // Act + ASWLog::TASWCategoryLog::ApplyLevels("Covered=Debug"); + logger.SetMinimumLevel(ASWLog::Level::Warn); + + // Assert + CheckEquals(ASWLog::Level::Debug, coveredLog.GetMinimumLevel(), "The covered category should get the spec's level"); + CheckFalse(freeLog.HasOwnMinimumLevel(), "An uncovered category should get no level"); + CheckEquals(ASWLog::Level::Warn, freeLog.GetMinimumLevel(), "An uncovered category should still follow the wrapped logger"); + CheckEquals(ASWLog::Level::Error, codedLog.GetMinimumLevel(), "An uncovered category should keep the level the code set"); + + // Act: a spec that no longer covers it leaves its level; the code can still change it + ASWLog::TASWCategoryLog::ApplyLevels("Other=Info"); + const auto levelAfterOtherSpec = coveredLog.GetMinimumLevel(); + coveredLog.SetMinimumLevel(ASWLog::Level::Critical); + const auto levelAfterSet = coveredLog.GetMinimumLevel(); + coveredLog.ResetMinimumLevel(); + + // Assert + CheckEquals(ASWLog::Level::Debug, levelAfterOtherSpec, "A spec that doesn't cover the category should leave its level"); + CheckEquals(ASWLog::Level::Critical, levelAfterSet, "SetMinimumLevel() after ApplyLevels() should win"); + CheckFalse(coveredLog.HasOwnMinimumLevel(), "ResetMinimumLevel() should make the category follow again"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_CategoryLog::Test_ApplyLevels_RejectsAnInvalidSpec() +{ + // Arrange + struct TInvalidSpec + { + std::string_view Spec; + std::string_view Error; + }; + + const TInvalidSpec invalidSpecs[]{ + { "Spec=Trace,Net", "'Net' has no '=' (expected name=level)" }, + { "Spec=Trace, =Info", "'=Info' has no category name" }, + { "Spec=Trace,Net.*=Info", "'Net.*=Info': a name can't hold '*' (a name covers the categories under it; \"*\" covers all)" }, + { "Spec=Trace,Net=Loud", "'Net=Loud': 'Loud' isn't a level (Trace, Debug, Info, Warn, Error, Critical or Off)" }, + { "Spec=Trace,Net=", "'Net=': '' isn't a level (Trace, Debug, Info, Warn, Error, Critical or Off)" }, + }; + + TCategoryLevelsReset resetLevels; + TRecordingLogger logger; + ASWLog::TASWCategoryLog specLog("Spec", logger); + ASWLog::TASWCategoryLog::ApplyLevels("Spec=Debug"); + + for (const auto& invalidSpec : invalidSpecs) + { + // Act + std::string error = "previous"; + const bool isValid = ASWLog::TASWCategoryLog::ApplyLevels(invalidSpec.Spec, error); + const bool isValidWithoutError = ASWLog::TASWCategoryLog::ApplyLevels(invalidSpec.Spec); + ASWLog::TASWCategoryLog laterLog("Spec", logger); + + // Assert + const auto spec = std::string(invalidSpec.Spec); + CheckFalse(isValid, "The spec should be invalid: " + spec); + CheckFalse(isValidWithoutError, "The spec should be invalid without an error text too: " + spec); + CheckEquals(std::string(invalidSpec.Error), error, "The error should describe the bad item: " + spec); + CheckEquals(ASWLog::Level::Debug, specLog.GetMinimumLevel(), "An invalid spec should change no level: " + spec); + CheckEquals(ASWLog::Level::Debug, laterLog.GetMinimumLevel(), "An invalid spec should keep the levels for new categories: " + spec); + } + + // Act: a valid spec clears the error + std::string error = "previous"; + const bool isValid = ASWLog::TASWCategoryLog::ApplyLevels("Spec=Info", error); + + // Assert + CheckTrue(isValid, "The spec should be valid"); + CheckEmpty(error, "A valid spec should leave the error empty"); + CheckEquals(ASWLog::Level::Info, specLog.GetMinimumLevel(), "A valid spec should set the level"); +} //--------------------------------------------------------------------------- void TTest_ASWLog_CategoryLog::Test_Async_KeepsItsOwnCopyOfTheName() { diff --git a/tests/tests/Test_ASWLog_CategoryLog.h b/tests/tests/Test_ASWLog_CategoryLog.h index a2e5c37..d908ce9 100644 --- a/tests/tests/Test_ASWLog_CategoryLog.h +++ b/tests/tests/Test_ASWLog_CategoryLog.h @@ -38,6 +38,11 @@ class TTest_ASWLog_CategoryLog : public TTestGroupBase typedef TTestGroupBase inherited; private: // Test methods + void Test_ApplyLevels_AppliesToCategoriesMadeLater(); + void Test_ApplyLevels_GivesTheLongestCoveringNamesLevel(); + void Test_ApplyLevels_IsSafeWhileCategoriesComeAndGo(); + void Test_ApplyLevels_LeavesUncoveredCategories(); + void Test_ApplyLevels_RejectsAnInvalidSpec(); void Test_Async_KeepsItsOwnCopyOfTheName(); void Test_Backtrace_KeepsEntriesBelowTheCategoryLevel(); void Test_Backtrace_KeepsItsOwnCopyOfTheName(); diff --git a/tests/tests/Test_ASWLog_Config.cpp b/tests/tests/Test_ASWLog_Config.cpp index d669d59..7acea5a 100644 --- a/tests/tests/Test_ASWLog_Config.cpp +++ b/tests/tests/Test_ASWLog_Config.cpp @@ -27,19 +27,71 @@ limitations under the License. #include #include #include +#include +#include +#include +#include +#include +#include //--------------------------------------------------------------------------- #include "ASWUnitTests_Registry.h" //--------------------------------------------------------------------------- +#include "ASWLog_CategoryLog.h" #include "ASWLog_Config.h" +#include "ASWLog_Formatter.h" +#include "ASWLog_MemoryLog.h" +#include "ASWLog_NullLog.h" +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- +#include "UT_Helper_Environment.h" +#include "UT_Helper_StdErr.h" //--------------------------------------------------------------------------- namespace ASWUnitTests { +namespace +{ + +// The tests' own prefix, so that a developer's ASWLOG_* variables can't change their results +constexpr std::string_view TestPrefix = "ASWLOGTEST_CONFIG_"; + +// Clears the category levels a test applied (they are process-wide) when it ends +struct TCategoryLevelsReset +{ + TCategoryLevelsReset() = default; + TCategoryLevelsReset(const TCategoryLevelsReset&) = delete; + TCategoryLevelsReset& operator=(const TCategoryLevelsReset&) = delete; + + ~TCategoryLevelsReset() + { + ASWLog::TASWCategoryLog::ApplyLevels(""); + } +}; + +// The test variable named 'key' +std::string TestVariable(std::string_view key) +{ + return std::string(TestPrefix) + std::string(key); +} + +} // namespace + //--------------------------------------------------------------------------- TTest_ASWLog_Config::TTest_ASWLog_Config() : inherited("ASWLog_Config_Tests") { + RegisterTest(&TTest_ASWLog_Config::Test_ApplyEnvironment_AppliesPatternAfterFormat, "ApplyEnvironment_AppliesPatternAfterFormat"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplyEnvironment_AppliesTheCategoryLevels, "ApplyEnvironment_AppliesTheCategoryLevels"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplyEnvironment_IgnoresUnsetAndEmptyVariables, "ApplyEnvironment_IgnoresUnsetAndEmptyVariables"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplyEnvironment_ReadsTheDefaultPrefix, "ApplyEnvironment_ReadsTheDefaultPrefix"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplyEnvironment_ReadsUTF8Values, "ApplyEnvironment_ReadsUTF8Values"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplyEnvironment_ReportsInvalidValues, "ApplyEnvironment_ReportsInvalidValues"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplyEnvironment_SeedsTheLevelAtInitialize, "ApplyEnvironment_SeedsTheLevelAtInitialize"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplyEnvironment_SetsEachKey, "ApplyEnvironment_SetsEachKey"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplySetting_ReportsAnUnknownKey, "ApplySetting_ReportsAnUnknownKey"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplySetting_SetsOneSetting, "ApplySetting_SetsOneSetting"); + RegisterTest(&TTest_ASWLog_Config::Test_ApplySetting_WritesToStdErrWithoutHandler, "ApplySetting_WritesToStdErrWithoutHandler"); RegisterTest(&TTest_ASWLog_Config::Test_Defaults_MatchDocumentedValues, "Defaults_MatchDocumentedValues"); RegisterTest(&TTest_ASWLog_Config::Test_ResolveFolder_CustomFolder, "ResolveFolder_CustomFolder"); RegisterTest(&TTest_ASWLog_Config::Test_ResolvePath_AbsolutePath, "ResolvePath_AbsolutePath"); @@ -69,6 +121,399 @@ void TTest_ASWLog_Config::TearDown_Test(ITestCase& /*testCase*/) // /////// Begin tests after this line /////////////////////// +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplyEnvironment_AppliesPatternAfterFormat() +{ + // Arrange + TScopedEnvironmentVariable format(TestVariable("FORMAT"), "JSON"); + TScopedEnvironmentVariable pattern(TestVariable("PATTERN"), "{level} {message}"); + + ASWLog::TASWLogConfig config; + + // Act + const bool isValid = config.ApplyEnvironment(TestPrefix); + + // Assert + CheckTrue(isValid, "Both values should be valid"); + const auto* patternFormatter = dynamic_cast(config.Line.Formatter.get()); + CheckNotNull(patternFormatter, "PATTERN should win over FORMAT"); + if (patternFormatter != nullptr) + CheckEquals(std::string("{level} {message}"), std::string(patternFormatter->GetPattern()), "The pattern should be the variable's"); + + // Act: FORMAT=Text alone goes back to the built-in layout + { + TScopedEnvironmentVariable noPattern(TestVariable("PATTERN"), std::nullopt); + TScopedEnvironmentVariable textFormat(TestVariable("FORMAT"), "text"); + CheckTrue(config.ApplyEnvironment(TestPrefix), "FORMAT=text should be valid"); + } + + // Assert + CheckNull(config.Line.Formatter, "FORMAT=Text should clear the formatter (the built-in layout)"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplyEnvironment_AppliesTheCategoryLevels() +{ + // Arrange + TCategoryLevelsReset resetLevels; + ASWLog::TASWNullLog wrapped; + ASWLog::TASWCategoryLog netLog("ConfigTestNet", wrapped); + TScopedEnvironmentVariable categories(TestVariable("CATEGORIES"), "ConfigTestNet=Debug"); + + std::vector errors; + ASWLog::TASWLogConfig config; + config.OnError = [&errors](const ASWLog::TASWLogError& error) + { + errors.push_back(error); + }; + + // Act + const bool isValid = config.ApplyEnvironment(TestPrefix); + + // Assert + CheckTrue(isValid, "The spec should be valid"); + CheckEquals(ASWLog::Level::Debug, netLog.GetMinimumLevel(), "CATEGORIES should set the category's level"); + + // Act: an invalid spec + const bool isInvalidValid = config.ApplySetting("CATEGORIES", "ConfigTestNet=Trace,Db=Loud"); + + // Assert + CheckFalse(isInvalidValid, "A spec with an unknown level should be invalid"); + CheckEquals(ASWLog::Level::Debug, netLog.GetMinimumLevel(), "An invalid spec should change no category's level"); + CheckEquals(1, errors.size(), "The invalid spec should be reported once"); + if (errors.size() == 1) + { + CheckEquals(ASWLog::ErrorKind::InvalidSetting, errors[0].Kind, "The report should be InvalidSetting"); + CheckEquals(std::string("CATEGORIES='ConfigTestNet=Trace,Db=Loud': 'Db=Loud': 'Loud' isn't a level (Trace, Debug, Info, Warn, " + "Error, Critical or Off)"), errors[0].Message, "The report should name the setting, the value and the bad item"); + } +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplyEnvironment_IgnoresUnsetAndEmptyVariables() +{ + // Arrange: LEVEL empty, FLUSH only spaces, the rest unset + TScopedEnvironmentVariable level(TestVariable("LEVEL"), ""); + TScopedEnvironmentVariable flush(TestVariable("FLUSH"), " \t "); + + int errorCount = 0; + ASWLog::TASWLogConfig config; + config.OnError = [&errorCount](const ASWLog::TASWLogError&) + { + ++errorCount; + }; + config.InitialMinimumLevel = ASWLog::Level::Warn; + const auto formatter = std::make_shared(); + config.Line.Formatter = formatter; + + // Act + const bool isValid = config.ApplyEnvironment(TestPrefix); + + // Assert + CheckTrue(isValid, "Unset and empty variables should not count as invalid"); + CheckEquals(0, errorCount, "Unset and empty variables should not be reported"); + CheckEquals(ASWLog::Level::Warn, config.InitialMinimumLevel, "An empty LEVEL should leave the level"); + CheckEquals(ASWLog::FlushMode::EveryWrite, config.File.Flush, "A FLUSH of spaces should leave the flush mode"); + CheckTrue(config.File.FolderPath == std::filesystem::path("logs"), "An unset FOLDER should leave the folder"); + CheckFalse(config.Async.Enabled, "An unset ASYNC should leave Async.Enabled"); + CheckEquals(0, config.Backtrace.Capacity, "An unset BACKTRACE should leave the capacity"); + CheckSame(formatter.get(), config.Line.Formatter.get(), "Unset FORMAT and PATTERN should leave the formatter"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplyEnvironment_ReadsTheDefaultPrefix() +{ + // Arrange: a developer's own ASWLOG_CATEGORIES must not change the process's category levels + TScopedEnvironmentVariable categories("ASWLOG_CATEGORIES", std::nullopt); + TScopedEnvironmentVariable precision("ASWLOG_TIME_PRECISION", "ns"); + + ASWLog::TASWLogConfig config; + config.OnError = [](const ASWLog::TASWLogError&) + { + }; // A developer's other ASWLOG_* variables may be invalid + ASWLog::TASWLogConfig otherConfig; + + // Act + config.ApplyEnvironment(); + otherConfig.ApplyEnvironment(TestPrefix); + + // Assert + CheckEquals(ASWLog::TimePrecision::Nanoseconds, config.Line.TimestampPrecision, "The default prefix should be ASWLOG_"); + CheckEquals(ASWLog::TimePrecision::Milliseconds, otherConfig.Line.TimestampPrecision, + "Another prefix should not read the ASWLOG_ variables"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplyEnvironment_ReadsUTF8Values() +{ + // Arrange: a lambda and a CJK character, in UTF-8 + TScopedEnvironmentVariable folder(TestVariable("FOLDER"), "logs\xCE\xBB"); + TScopedEnvironmentVariable file(TestVariable("FILE"), "\xE6\x97\xA5.txt"); + + ASWLog::TASWLogConfig config; + + // Act + const bool isValid = config.ApplyEnvironment(TestPrefix); + + // Assert + CheckTrue(isValid, "The paths should be valid"); + CheckEquals(std::string("logs\xCE\xBB"), ASWLog::PathToUTF8String(config.File.FolderPath), "FOLDER should be read as UTF-8"); + CheckEquals(std::string("\xE6\x97\xA5.txt"), ASWLog::PathToUTF8String(config.File.FilePath), "FILE should be read as UTF-8"); +#if defined(_WIN32) + CheckTrue(config.File.FolderPath.wstring() == L"logs\x03BB", "FOLDER should name the lambda, not ANSI code page characters"); +#endif +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplyEnvironment_ReportsInvalidValues() +{ + // Arrange: every key but FOLDER invalid + TScopedEnvironmentVariable level(TestVariable("LEVEL"), "Verbose"); + TScopedEnvironmentVariable categories(TestVariable("CATEGORIES"), "Net"); + TScopedEnvironmentVariable folder(TestVariable("FOLDER"), "diagnostics"); + TScopedEnvironmentVariable flush(TestVariable("FLUSH"), "Sometimes"); + TScopedEnvironmentVariable syncAtLevel(TestVariable("SYNC_AT_LEVEL"), "Loud"); + TScopedEnvironmentVariable async(TestVariable("ASYNC"), "maybe"); + TScopedEnvironmentVariable backtrace(TestVariable("BACKTRACE"), "-5"); + TScopedEnvironmentVariable timeZone(TestVariable("TIME_ZONE"), "Mars"); + TScopedEnvironmentVariable timePrecision(TestVariable("TIME_PRECISION"), "ps"); + TScopedEnvironmentVariable multiline(TestVariable("MULTILINE"), "Fold"); + TScopedEnvironmentVariable showFunction(TestVariable("SHOW_FUNCTION"), "2"); + TScopedEnvironmentVariable showSource(TestVariable("SHOW_SOURCE"), "yesno"); + TScopedEnvironmentVariable format(TestVariable("FORMAT"), "XML"); + TScopedEnvironmentVariable pattern(TestVariable("PATTERN"), "{nope}"); + + std::vector errors; + ASWLog::TASWLogConfig config; + config.OnError = [&errors](const ASWLog::TASWLogError& error) + { + errors.push_back(error); + }; + config.InitialMinimumLevel = ASWLog::Level::Warn; + config.Backtrace.Capacity = 7; + config.Line.ShowFunctionName = true; + const auto formatter = std::make_shared(); + config.Line.Formatter = formatter; + + // Act + const bool isValid = config.ApplyEnvironment(TestPrefix); + + // Assert: the invalid values changed nothing, the valid one was applied + CheckFalse(isValid, "Invalid values should make ApplyEnvironment() return false"); + CheckEquals(ASWLog::Level::Warn, config.InitialMinimumLevel, "An invalid LEVEL should leave the level"); + CheckEquals(ASWLog::FlushMode::EveryWrite, config.File.Flush, "An invalid FLUSH should leave the flush mode"); + CheckEquals(ASWLog::Level::Off, config.File.SyncToDiskAtLevel, "An invalid SYNC_AT_LEVEL should leave the level"); + CheckFalse(config.Async.Enabled, "An invalid ASYNC should leave Async.Enabled"); + CheckEquals(7, config.Backtrace.Capacity, "An invalid BACKTRACE should leave the capacity"); + CheckEquals(ASWLog::TimeZone::UTC, config.Line.TimestampZone, "An invalid TIME_ZONE should leave the zone"); + CheckEquals(ASWLog::TimePrecision::Milliseconds, config.Line.TimestampPrecision, "An invalid TIME_PRECISION should leave the precision"); + CheckEquals(ASWLog::MultilineMode::Preserve, config.Line.Multiline, "An invalid MULTILINE should leave the mode"); + CheckTrue(config.Line.ShowFunctionName, "An invalid SHOW_FUNCTION should leave the setting"); + CheckFalse(config.Line.ShowSourceLine, "An invalid SHOW_SOURCE should leave the setting"); + CheckSame(formatter.get(), config.Line.Formatter.get(), "An invalid FORMAT and PATTERN should leave the formatter"); + CheckTrue(config.File.FolderPath == std::filesystem::path("diagnostics"), "A valid value should be applied anyway"); + + // Assert: one report per invalid value, in the order of the keys + const std::vector expectedStarts{ + "ASWLOGTEST_CONFIG_LEVEL='Verbose': expected Trace, Debug, Info, Warn, Error, Critical or Off", + "ASWLOGTEST_CONFIG_CATEGORIES='Net': 'Net' has no '='", "ASWLOGTEST_CONFIG_FLUSH='Sometimes': expected", + "ASWLOGTEST_CONFIG_SYNC_AT_LEVEL='Loud': expected", "ASWLOGTEST_CONFIG_ASYNC='maybe': expected", + "ASWLOGTEST_CONFIG_BACKTRACE='-5': expected", "ASWLOGTEST_CONFIG_TIME_ZONE='Mars': expected", + "ASWLOGTEST_CONFIG_TIME_PRECISION='ps': expected", "ASWLOGTEST_CONFIG_MULTILINE='Fold': expected", + "ASWLOGTEST_CONFIG_SHOW_FUNCTION='2': expected", "ASWLOGTEST_CONFIG_SHOW_SOURCE='yesno': expected", + "ASWLOGTEST_CONFIG_FORMAT='XML': expected Text or JSON", "ASWLOGTEST_CONFIG_PATTERN='{nope}': " + }; + + CheckEquals(expectedStarts.size(), errors.size(), "Each invalid value should be reported once"); + for (std::size_t index = 0; index < errors.size() && index < expectedStarts.size(); ++index) + { + CheckEquals(ASWLog::ErrorKind::InvalidSetting, errors[index].Kind, "Each report should be InvalidSetting"); + CheckStartsWith(errors[index].Message, expectedStarts[index], "Each report should name the variable and its value"); + } + + if (errors.size() == expectedStarts.size()) + CheckContains(errors.back().Message, "nope", "The PATTERN report should give the pattern's problem"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplyEnvironment_SeedsTheLevelAtInitialize() +{ + // Arrange + TScopedEnvironmentVariable level(TestVariable("LEVEL"), "Debug"); + + ASWLog::TASWLogConfig config; + config.Startup.WriteApplicationInfo = false; + config.Startup.WriteDriveInfo = false; + config.Startup.WriteMemoryUsage = false; + config.Startup.WriteOSInfo = false; + config.Startup.WriteSystemMemoryInfo = false; + config.Startup.WriteTimeInfo = false; + config.Shutdown.WriteLine = false; + ASWLog::TASWMemoryLog log; + + // Act + config.ApplyEnvironment(TestPrefix); + log.Initialize(config); + log.LogDebug("debug entry"); + log.LogTrace("trace entry"); + const auto lines = log.GetLines(); + + // Assert + CheckEquals(ASWLog::Level::Debug, log.GetMinimumLevel(), "The logger should start at the variable's level"); + CheckEquals(1, lines.size(), "Only the Debug entry should be written"); + if (lines.size() == 1) + CheckContains(lines[0], "debug entry", "The Debug entry should be written"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplyEnvironment_SetsEachKey() +{ + // Arrange: mixed case and spaces around the values + TScopedEnvironmentVariable level(TestVariable("LEVEL"), " debug "); + TScopedEnvironmentVariable folder(TestVariable("FOLDER"), "diag/logs"); + TScopedEnvironmentVariable file(TestVariable("FILE"), "app-diag.txt"); + TScopedEnvironmentVariable flush(TestVariable("FLUSH"), "Periodic"); + TScopedEnvironmentVariable syncAtLevel(TestVariable("SYNC_AT_LEVEL"), "warn"); + TScopedEnvironmentVariable async(TestVariable("ASYNC"), "on"); + TScopedEnvironmentVariable backtrace(TestVariable("BACKTRACE"), "250"); + TScopedEnvironmentVariable timeZone(TestVariable("TIME_ZONE"), "local"); + TScopedEnvironmentVariable timePrecision(TestVariable("TIME_PRECISION"), "us"); + TScopedEnvironmentVariable multiline(TestVariable("MULTILINE"), "Escape"); + TScopedEnvironmentVariable showFunction(TestVariable("SHOW_FUNCTION"), "YES"); + TScopedEnvironmentVariable showSource(TestVariable("SHOW_SOURCE"), "true"); + TScopedEnvironmentVariable format(TestVariable("FORMAT"), "json"); + + int errorCount = 0; + ASWLog::TASWLogConfig config; + config.OnError = [&errorCount](const ASWLog::TASWLogError&) + { + ++errorCount; + }; + + // Act + const bool isValid = config.ApplyEnvironment(TestPrefix); + + // Assert + CheckTrue(isValid, "Every value should be valid"); + CheckEquals(0, errorCount, "Nothing should be reported"); + CheckEquals(ASWLog::Level::Debug, config.InitialMinimumLevel, "LEVEL should set InitialMinimumLevel"); + CheckTrue(config.File.FolderPath == std::filesystem::path("diag/logs"), "FOLDER should set File.FolderPath"); + CheckTrue(config.File.FilePath == std::filesystem::path("app-diag.txt"), "FILE should set File.FilePath"); + CheckEquals(ASWLog::FlushMode::Periodic, config.File.Flush, "FLUSH should set File.Flush"); + CheckEquals(ASWLog::Level::Warn, config.File.SyncToDiskAtLevel, "SYNC_AT_LEVEL should set File.SyncToDiskAtLevel"); + CheckTrue(config.Async.Enabled, "ASYNC should set Async.Enabled"); + CheckEquals(250, config.Backtrace.Capacity, "BACKTRACE should set Backtrace.Capacity"); + CheckEquals(ASWLog::TimeZone::Local, config.Line.TimestampZone, "TIME_ZONE should set Line.TimestampZone"); + CheckEquals(ASWLog::TimePrecision::Microseconds, config.Line.TimestampPrecision, "TIME_PRECISION should set Line.TimestampPrecision"); + CheckEquals(ASWLog::MultilineMode::Escape, config.Line.Multiline, "MULTILINE should set Line.Multiline"); + CheckTrue(config.Line.ShowFunctionName, "SHOW_FUNCTION should set Line.ShowFunctionName"); + CheckTrue(config.Line.ShowSourceLine, "SHOW_SOURCE should set Line.ShowSourceLine"); + CheckNotNull(dynamic_cast(config.Line.Formatter.get()), "FORMAT=json should set a JSON formatter"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplySetting_ReportsAnUnknownKey() +{ + // Arrange + std::vector errors; + ASWLog::TASWLogConfig config; + config.OnError = [&errors](const ASWLog::TASWLogError& error) + { + errors.push_back(error); + }; + + // Act + const bool isTypoValid = config.ApplySetting("LEVL", "Debug"); + const bool isPrefixedValid = config.ApplySetting("ASWLOG_LEVEL", "Debug"); + const bool isEmptyValid = config.ApplySetting("LEVL", ""); + + // Assert + CheckFalse(isTypoValid, "An unknown key should be invalid"); + CheckFalse(isPrefixedValid, "A key with a prefix should be unknown"); + CheckFalse(isEmptyValid, "An unknown key should be invalid with an empty value too"); + CheckEquals(ASWLog::Level::Info, config.InitialMinimumLevel, "An unknown key should change nothing"); + CheckEquals(3, errors.size(), "Each unknown key should be reported"); + if (errors.size() == 3) + { + CheckEquals(ASWLog::ErrorKind::InvalidSetting, errors[0].Kind, "The report should be InvalidSetting"); + CheckEquals(std::string("'LEVL' isn't a setting"), errors[0].Message, "The report should name the key"); + CheckEquals(std::string("'ASWLOG_LEVEL' isn't a setting"), errors[1].Message, "The report should name the key as given"); + } +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplySetting_SetsOneSetting() +{ + // Arrange + std::vector errors; + ASWLog::TASWLogConfig config; + config.OnError = [&errors](const ASWLog::TASWLogError& error) + { + errors.push_back(error); + }; + + // Act & Assert: the key ignores case and spaces; an empty value changes nothing + CheckTrue(config.ApplySetting(" level ", "Trace"), "A key should ignore case and spaces"); + CheckEquals(ASWLog::Level::Trace, config.InitialMinimumLevel, "LEVEL should be set"); + CheckTrue(config.ApplySetting("Level", " "), "An empty value should be valid"); + CheckEquals(ASWLog::Level::Trace, config.InitialMinimumLevel, "An empty value should change nothing"); + + // Act & Assert: each false value, each true value + for (const std::string_view value : { "0", "False", "NO", "off" }) + { + config.Line.ShowSourceLine = true; + CheckTrue(config.ApplySetting("SHOW_SOURCE", value), "A false value should be valid: " + std::string(value)); + CheckFalse(config.Line.ShowSourceLine, "A false value should clear the setting: " + std::string(value)); + } + + for (const std::string_view value : { "1", "TRUE", "Yes", "ON" }) + { + config.Line.ShowSourceLine = false; + CheckTrue(config.ApplySetting("SHOW_SOURCE", value), "A true value should be valid: " + std::string(value)); + CheckTrue(config.Line.ShowSourceLine, "A true value should set the setting: " + std::string(value)); + } + + // Act & Assert: BACKTRACE takes decimal digits only, up to the largest std::size_t + CheckTrue(config.ApplySetting("BACKTRACE", "0"), "0 should be a valid BACKTRACE"); + CheckEquals(0, config.Backtrace.Capacity, "BACKTRACE=0 should turn the backtrace off"); + config.Backtrace.Capacity = 9; + for (const std::string_view value : { "+3", "-1", "12x", "1.5", "0x10", "99999999999999999999999" }) + { + CheckFalse(config.ApplySetting("BACKTRACE", value), "An invalid BACKTRACE should be reported: " + std::string(value)); + CheckEquals(9, config.Backtrace.Capacity, "An invalid BACKTRACE should change nothing: " + std::string(value)); + } + + CheckEquals(6, errors.size(), "Only the invalid BACKTRACE values should be reported"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Config::Test_ApplySetting_WritesToStdErrWithoutHandler() +{ + // Arrange + const auto stdErrFile = std::filesystem::temp_directory_path() / "aswlog_config_stderr.txt"; + ASWLog::TASWLogConfig config; // No OnError + + // Act + bool isRedirected = false; + bool isValid = true; + { + TStdErrRedirect redirect(stdErrFile); + isRedirected = redirect.IsActive(); + if (isRedirected) + isValid = config.ApplySetting("LEVEL", "Verbose"); + } + + if (!isRedirected) + { + std::error_code removeError; + std::filesystem::remove(stdErrFile, removeError); + Skip("stderr has no file descriptor to redirect (e.g. a GUI application)"); + } + + std::string written; + { + std::ifstream stream(stdErrFile, std::ios::binary); + written.assign(std::istreambuf_iterator(stream), std::istreambuf_iterator()); + } + std::error_code removeError; + std::filesystem::remove(stdErrFile, removeError); + std::erase(written, '\r'); // In case stderr is in text mode on Windows + + // Assert + CheckFalse(isValid, "The value should be invalid"); + CheckEquals(std::string("ASWLog: INVALID_SETTING: LEVEL='Verbose': expected Trace, Debug, Info, Warn, Error, Critical or Off\n"), + written, "Without OnError, an invalid value should be written to stderr as one line"); +} //--------------------------------------------------------------------------- void TTest_ASWLog_Config::Test_Defaults_MatchDocumentedValues() { @@ -81,6 +526,8 @@ void TTest_ASWLog_Config::Test_Defaults_MatchDocumentedValues() CheckEquals(ASWLog::Level::Error, config.OnLogEntryMinimumLevel, "OnLogEntryMinimumLevel should default to Error"); CheckNull(config.OnError, "OnError should default to unset (reports go to stderr)"); CheckNull(config.OnBeforeWrite, "OnBeforeWrite should default to unset"); + CheckEquals(1000, config.Memory.MaxLines, "Memory.MaxLines should default to 1000"); + CheckEquals(0, config.Memory.MaxBytes, "Memory.MaxBytes should default to 0 (no limit)"); CheckFalse(config.Async.Enabled, "Async.Enabled should default to false"); CheckEquals(8192, config.Async.QueueCapacity, "Async.QueueCapacity should default to 8192"); CheckEquals(ASWLog::AsyncOverflowPolicy::Block, config.Async.OverflowPolicy, "Async.OverflowPolicy should default to Block"); diff --git a/tests/tests/Test_ASWLog_Config.h b/tests/tests/Test_ASWLog_Config.h index 67bdd0b..c86da4f 100644 --- a/tests/tests/Test_ASWLog_Config.h +++ b/tests/tests/Test_ASWLog_Config.h @@ -36,6 +36,17 @@ class TTest_ASWLog_Config : public TTestGroupBase typedef TTestGroupBase inherited; private: // Test methods + void Test_ApplyEnvironment_AppliesPatternAfterFormat(); + void Test_ApplyEnvironment_AppliesTheCategoryLevels(); + void Test_ApplyEnvironment_IgnoresUnsetAndEmptyVariables(); + void Test_ApplyEnvironment_ReadsTheDefaultPrefix(); + void Test_ApplyEnvironment_ReadsUTF8Values(); + void Test_ApplyEnvironment_ReportsInvalidValues(); + void Test_ApplyEnvironment_SeedsTheLevelAtInitialize(); + void Test_ApplyEnvironment_SetsEachKey(); + void Test_ApplySetting_ReportsAnUnknownKey(); + void Test_ApplySetting_SetsOneSetting(); + void Test_ApplySetting_WritesToStdErrWithoutHandler(); void Test_Defaults_MatchDocumentedValues(); void Test_ResolveFolder_CustomFolder(); void Test_ResolvePath_AbsolutePath(); diff --git a/tests/tests/Test_ASWLog_ConsoleLog.cpp b/tests/tests/Test_ASWLog_ConsoleLog.cpp index 2ab52ae..d158eda 100644 --- a/tests/tests/Test_ASWLog_ConsoleLog.cpp +++ b/tests/tests/Test_ASWLog_ConsoleLog.cpp @@ -26,7 +26,6 @@ limitations under the License. //--------------------------------------------------------------------------- #include #include -#include #include #include #include @@ -35,18 +34,14 @@ limitations under the License. #include #include #include - -#if defined(_WIN32) -#include -#undef min -#undef max -#endif //--------------------------------------------------------------------------- #include "ASWUnitTests_Registry.h" //--------------------------------------------------------------------------- #include "ASWLog_ConsoleLog.h" #include "ASWLog_Utils.h" //--------------------------------------------------------------------------- +#include "UT_Helper_Environment.h" +//--------------------------------------------------------------------------- namespace ASWUnitTests { @@ -155,64 +150,6 @@ class TFlushCounter } }; -// RAII helper: sets an environment variable (or removes it, given std::nullopt), restoring its previous value on -// destruction. -class TScopedEnvironmentVariable -{ -private: - std::string m_Name; - std::optional m_PreviousValue; - - static std::optional Get(const std::string& name) - { -#if defined(_WIN32) - // The Windows API rather than the C runtime, since that's what TASWConsoleLog reads (and MinGW's runtime has - // no _dupenv_s) - const DWORD size = GetEnvironmentVariableA(name.c_str(), nullptr, 0); - if (size == 0) - return std::nullopt; - - std::string value(size, '\0'); - value.resize(GetEnvironmentVariableA(name.c_str(), value.data(), size)); - return value; -#else - const char* value = std::getenv(name.c_str()); - if (value == nullptr) - return std::nullopt; - - return std::string(value); -#endif - } - - static void Set(const std::string& name, const std::optional& value) - { -#if defined(_WIN32) - SetEnvironmentVariableA(name.c_str(), value ? value->c_str() : nullptr); // nullptr removes the variable -#else - if (value) - setenv(name.c_str(), value->c_str(), 1); - else - unsetenv(name.c_str()); -#endif - } - -public: - TScopedEnvironmentVariable(std::string name, std::optional value) - : m_Name(std::move(name)), - m_PreviousValue(Get(m_Name)) - { - Set(m_Name, value); - } - - ~TScopedEnvironmentVariable() - { - Set(m_Name, m_PreviousValue); - } - - TScopedEnvironmentVariable(const TScopedEnvironmentVariable&) = delete; - TScopedEnvironmentVariable& operator=(const TScopedEnvironmentVariable&) = delete; -}; - // RAII helper: redirects a standard stream's buffer to an internal buffer for // the lifetime of the object, restoring the original buffer on destruction. class TStreamCapture @@ -406,11 +343,28 @@ void TTest_ASWLog_ConsoleLog::Test_ColorModeAuto_HonorsNoColor() alwaysContents = outCapture.Str(); } + // Act: NO_COLOR set to an empty value doesn't count (see https://no-color.org) + std::string emptyContents; + bool emptyInitialized = false; + { + TScopedEnvironmentVariable emptyNoColor("NO_COLOR", ""); + TColorDetectConsoleLog emptyLogger; + emptyLogger.StdOutSupportsColor = true; + emptyLogger.StdErrSupportsColor = true; + + TStreamCapture outCapture(std::cout); + emptyInitialized = emptyLogger.Initialize(config); + emptyLogger.LogInfo("empty_message"); + emptyLogger.Close(); + emptyContents = outCapture.Str(); + } + // Assert - CheckTrue(autoInitialized && alwaysInitialized, "Initialize should succeed"); + CheckTrue(autoInitialized && alwaysInitialized && emptyInitialized, "Initialize should succeed"); CheckNotContains(autoContents, "\x1b[", "Auto should not color any stream when NO_COLOR is set"); CheckContains(autoContents, "auto_message", "The uncolored message should still be written"); CheckContains(alwaysContents, "\x1b[", "Always should color even when NO_COLOR is set"); + CheckContains(emptyContents, "\x1b[", "Auto should still color when NO_COLOR is set but empty"); } //--------------------------------------------------------------------------- void TTest_ASWLog_ConsoleLog::Test_ColorModeNever_SuppressesAnsiCodes() diff --git a/tests/tests/Test_ASWLog_DebuggerLog.cpp b/tests/tests/Test_ASWLog_DebuggerLog.cpp new file mode 100644 index 0000000..c91ec50 --- /dev/null +++ b/tests/tests/Test_ASWLog_DebuggerLog.cpp @@ -0,0 +1,339 @@ +/* ************************************************************************** +Test_ASWLog_DebuggerLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "Test_ASWLog_DebuggerLog.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#endif +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Registry.h" +//--------------------------------------------------------------------------- +#include "ASWLog_CrashHandler.h" +#include "ASWLog_DebuggerLog.h" +//--------------------------------------------------------------------------- + +namespace +{ + +// A value whose formatting is counted in DebuggerLogFormatCount, to check whether a *Fmt call formats +struct TDebuggerLogCountedValue +{ +}; + +int DebuggerLogFormatCount = 0; + +} // namespace + +template<> +struct std::formatter +{ + constexpr std::format_parse_context::iterator parse(std::format_parse_context& context) + { + return context.begin(); + } + + std::format_context::iterator format(const TDebuggerLogCountedValue& /*value*/, std::format_context& context) const + { + ++DebuggerLogFormatCount; + return std::format_to(context.out(), "counted"); + } +}; + +namespace ASWUnitTests +{ + +namespace +{ + +// A config with no startup or shutdown lines and only the level in each line, e.g. "[INFO]: message\n" +ASWLog::TASWLogConfig MakeDebuggerConfig() +{ + ASWLog::TASWLogConfig config; + config.InitialMinimumLevel = ASWLog::Level::Info; + config.Line.ShowTimestamp = false; + config.Line.ShowProcessId = false; + config.Line.ShowThreadId = false; + config.Shutdown.WriteLine = false; + config.Startup.WriteTimeInfo = false; + config.Startup.WriteOSInfo = false; + config.Startup.WriteDriveInfo = false; + config.Startup.WriteSystemMemoryInfo = false; + config.Startup.WriteApplicationInfo = false; + config.Startup.WriteMemoryUsage = false; + return config; +} + +#if defined(_WIN32) +// A debugger logger whose crash write a test can call +class TCrashTestDebuggerLog final : public ASWLog::TASWDebuggerLog +{ +public: + void WriteCrashLine(std::string_view line) noexcept + { + WriteCrashLineDirect(line); + } +}; + +// Receives what OutputDebugString sends while no debugger is attached, as DebugView does: a writer waits for the +// DBWIN_BUFFER_READY event, puts its process id and the text (in the ANSI code page) in the DBWIN_BUFFER shared memory, +// and sets DBWIN_DATA_READY. Only one listener can have these at a time. +class TDebugOutputListener +{ +private: + static constexpr DWORD BufferSize = 4096; + + HANDLE m_BufferReady = nullptr; + HANDLE m_DataReady = nullptr; + HANDLE m_Mapping = nullptr; + const char* m_View = nullptr; + bool m_IsListening = false; + +public: + TDebugOutputListener() + { + m_BufferReady = CreateEventW(nullptr, FALSE, FALSE, L"DBWIN_BUFFER_READY"); + bool isTaken = GetLastError() == ERROR_ALREADY_EXISTS; + m_DataReady = CreateEventW(nullptr, FALSE, FALSE, L"DBWIN_DATA_READY"); + isTaken = isTaken || GetLastError() == ERROR_ALREADY_EXISTS; + m_Mapping = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, BufferSize, L"DBWIN_BUFFER"); + isTaken = isTaken || GetLastError() == ERROR_ALREADY_EXISTS; + + if (m_Mapping != nullptr) + m_View = static_cast(MapViewOfFile(m_Mapping, FILE_MAP_READ, 0, 0, BufferSize)); + + m_IsListening = !isTaken && m_BufferReady != nullptr && m_DataReady != nullptr && m_View != nullptr; + } + + ~TDebugOutputListener() + { + if (m_View != nullptr) + UnmapViewOfFile(m_View); + + for (const HANDLE handle : { m_Mapping, m_DataReady, m_BufferReady }) + { + if (handle != nullptr) + CloseHandle(handle); + } + } + + TDebugOutputListener(const TDebugOutputListener&) = delete; + TDebugOutputListener& operator=(const TDebugOutputListener&) = delete; + + // False if another listener (e.g. DebugView) has the shared objects + bool IsListening() const + { + return m_IsListening; + } + + // Runs 'write', which sends one string, and returns the text this process sent, or nothing if none came within + // a second. Strings from other processes (which may write too) are skipped. + template + std::optional Capture(const TWrite& write) + { + SetEvent(m_BufferReady); + write(); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while (std::chrono::steady_clock::now() < deadline) + { + if (WaitForSingleObject(m_DataReady, 100) != WAIT_OBJECT_0) + continue; + + DWORD processId = 0; + std::memcpy(&processId, m_View, sizeof(processId)); + const std::string_view text(m_View + sizeof(processId), BufferSize - sizeof(processId)); + + if (processId == GetCurrentProcessId()) + return std::string(text.substr(0, text.find('\0'))); + + SetEvent(m_BufferReady); // Another process's string: wait for ours + } + + return std::nullopt; + } +}; + +#endif + +} // namespace + +//--------------------------------------------------------------------------- + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_DebuggerLog +/////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TTest_ASWLog_DebuggerLog::TTest_ASWLog_DebuggerLog() + : inherited("ASWLog_DebuggerLog_Tests") +{ + RegisterTest(&TTest_ASWLog_DebuggerLog::Test_GetInstance_ReturnsTheSameLogger, "GetInstance_ReturnsTheSameLogger"); + RegisterTest(&TTest_ASWLog_DebuggerLog::Test_ShouldLog_FollowsThePlatform, "ShouldLog_FollowsThePlatform"); + RegisterTest(&TTest_ASWLog_DebuggerLog::Test_Windows_SendsEachLineToTheDebugOutput, "Windows_SendsEachLineToTheDebugOutput"); + RegisterTest(&TTest_ASWLog_DebuggerLog::Test_Windows_WritesTheCrashLineDirectly, "Windows_WritesTheCrashLineDirectly"); +} +//--------------------------------------------------------------------------- +TTest_ASWLog_DebuggerLog::~TTest_ASWLog_DebuggerLog() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_DebuggerLog::SetUp_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_DebuggerLog::SetUp_Test(ITestCase& /*testCase*/) +{ + DebuggerLogFormatCount = 0; +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_DebuggerLog::TearDown_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_DebuggerLog::TearDown_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- + +// /////// Begin tests after this line /////////////////////// + +//--------------------------------------------------------------------------- +void TTest_ASWLog_DebuggerLog::Test_GetInstance_ReturnsTheSameLogger() +{ + // Act + auto& first = ASWLog::TASWDebuggerLog::GetInstance(); + auto& second = ASWLog::TASWDebuggerLog::GetInstance(); + + // Assert + CheckSame(&first, &second, "GetInstance() should always return the same logger"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_DebuggerLog::Test_ShouldLog_FollowsThePlatform() +{ + // Arrange + ASWLog::TASWDebuggerLog log; + const bool initialized = log.Initialize(MakeDebuggerConfig()); + ASWLog::TASWLogRecord infoRecord; + infoRecord.LogLevel = ASWLog::Level::Info; + + // Act + log.LogInfoFmt("{}", TDebuggerLogCountedValue{}); + + // Assert + CheckTrue(initialized, "Initialize should succeed on every platform"); +#if defined(_WIN32) + CheckTrue(log.ShouldLog(ASWLog::Level::Info), "On Windows, ShouldLog() should follow the minimum level (Info)"); + CheckFalse(log.ShouldLog(ASWLog::Level::Debug), "On Windows, ShouldLog() should follow the minimum level (Debug)"); + CheckTrue(log.ShouldLog(infoRecord), "On Windows, ShouldLog(record) should follow the minimum level"); + CheckEquals(1, DebuggerLogFormatCount, "On Windows, a *Fmt entry should be formatted"); +#else + CheckFalse(log.ShouldLog(ASWLog::Level::Critical), "Without a debugger output, ShouldLog() should be false"); + CheckFalse(log.ShouldLog(infoRecord), "Without a debugger output, ShouldLog(record) should be false"); + CheckEquals(0, DebuggerLogFormatCount, "Without a debugger output, nothing should be formatted"); +#endif +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_DebuggerLog::Test_Windows_SendsEachLineToTheDebugOutput() +{ +#if defined(_WIN32) + // Arrange: listen like DebugView, which needs no debugger attached and no other listener + if (IsDebuggerPresent()) + Skip("A debugger receives the debug output instead of a listener"); + + TDebugOutputListener listener; + if (!listener.IsListening()) + Skip("Another debug output listener (e.g. DebugView) is running"); + + ASWLog::TASWDebuggerLog log; + CheckTrue(log.Initialize(MakeDebuggerConfig()), "Initialize should succeed"); + auto crlfConfig = MakeDebuggerConfig(); + crlfConfig.Line.Ending = ASWLog::LineEnding::CRLF; + + // Act + const auto formatted = listener.Capture([&log] { + log.LogInfo("hello"); + }); + const auto raw = listener.Capture([&log] { + log.LogRaw(ASWLog::Level::Warn, "raw text"); + }); + const bool reconfigured = log.Reconfigure(crlfConfig); + const auto crlf = listener.Capture([&log] { + log.LogError("crlf"); + }); + + // Assert + CheckTrue(reconfigured, "Reconfigure should succeed"); + CheckTrue(formatted == std::string("[INFO]: hello\n"), "The formatted line, with its ending, should be sent: " + formatted.value_or("(none)")); + CheckTrue(raw == std::string("raw text"), "A raw entry should be sent as is: " + raw.value_or("(none)")); + CheckTrue(crlf == std::string("[ERROR]: crlf\r\n"), "The line should end with the configured ending: " + crlf.value_or("(none)")); +#else + Skip("Only Windows has a debugger output"); +#endif +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_DebuggerLog::Test_Windows_WritesTheCrashLineDirectly() +{ +#if defined(_WIN32) + // Arrange + if (IsDebuggerPresent()) + Skip("A debugger receives the debug output instead of a listener"); + + TDebugOutputListener listener; + if (!listener.IsListening()) + Skip("Another debug output listener (e.g. DebugView) is running"); + + TCrashTestDebuggerLog log; + const std::string longLine(ASWLog::Detail::TCrashText::Capacity + 500, 'x'); + + // Act + const auto crashLine = listener.Capture([&log] { + log.WriteCrashLine("[CRITICAL]: Crash: test\n"); + }); + const auto cutLine = listener.Capture([&log, &longLine] { + log.WriteCrashLine(longLine); + }); + + // Assert + CheckTrue(crashLine == std::string("[CRITICAL]: Crash: test\n"), "The crash line should be sent as is: " + crashLine.value_or("(none)")); + AssertTrue(cutLine.has_value(), "A line longer than a crash line can be should still be sent"); + CheckEquals(ASWLog::Detail::TCrashText::Capacity, cutLine->size(), "It should be cut to a crash line's capacity"); +#else + Skip("Only Windows has a debugger output"); +#endif +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +ASW_REGISTER_TEST_GROUP(ASWUnitTests::TTest_ASWLog_DebuggerLog) diff --git a/tests/tests/Test_ASWLog_DebuggerLog.h b/tests/tests/Test_ASWLog_DebuggerLog.h new file mode 100644 index 0000000..cc6d992 --- /dev/null +++ b/tests/tests/Test_ASWLog_DebuggerLog.h @@ -0,0 +1,59 @@ +/* ************************************************************************** +Test_ASWLog_DebuggerLog.h +Author: Anthony S. West - ASW Software + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef Test_ASWLog_DebuggerLogH +#define Test_ASWLog_DebuggerLogH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_TestBase.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_DebuggerLog +/////////////////////////////////////////////////////////////////////////// +class TTest_ASWLog_DebuggerLog : public TTestGroupBase +{ +private: + typedef TTestGroupBase inherited; + +private: // Test methods + void Test_GetInstance_ReturnsTheSameLogger(); + void Test_ShouldLog_FollowsThePlatform(); + void Test_Windows_SendsEachLineToTheDebugOutput(); + void Test_Windows_WritesTheCrashLineDirectly(); + +public: + TTest_ASWLog_DebuggerLog(); + ~TTest_ASWLog_DebuggerLog() override; + + void SetUp_Group() override; + void SetUp_Test(ITestCase& testCase) override; + void TearDown_Group() override; + void TearDown_Test(ITestCase& testCase) override; +}; + +} // ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef Test_ASWLog_DebuggerLogH diff --git a/tests/tests/Test_ASWLog_Limiter.cpp b/tests/tests/Test_ASWLog_Limiter.cpp new file mode 100644 index 0000000..2498066 --- /dev/null +++ b/tests/tests/Test_ASWLog_Limiter.cpp @@ -0,0 +1,421 @@ +/* ************************************************************************** +Test_ASWLog_Limiter.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "Test_ASWLog_Limiter.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Registry.h" +//--------------------------------------------------------------------------- +#include "ASWLog_Limiter.h" +#include "ASWLog_MemoryLog.h" +//--------------------------------------------------------------------------- + +namespace +{ + +// A value whose formatting is counted in LimiterFormatCount, to check whether a macro formats its arguments +struct TLimiterCountedValue +{ +}; + +int LimiterFormatCount = 0; + +} // namespace + +template<> +struct std::formatter +{ + constexpr std::format_parse_context::iterator parse(std::format_parse_context& context) + { + return context.begin(); + } + + std::format_context::iterator format(const TLimiterCountedValue& /*value*/, std::format_context& context) const + { + ++LimiterFormatCount; + return std::format_to(context.out(), "counted"); + } +}; + +namespace ASWUnitTests +{ + +namespace +{ + +using namespace std::chrono_literals; + +// A config with no startup or shutdown lines and only the level and the fields in each line, e.g. "[INFO]: message" +ASWLog::TASWLogConfig MakeLimiterConfig() +{ + ASWLog::TASWLogConfig config; + config.InitialMinimumLevel = ASWLog::Level::Info; + config.Line.ShowTimestamp = false; + config.Line.ShowProcessId = false; + config.Line.ShowThreadId = false; + config.Shutdown.WriteLine = false; + config.Startup.WriteTimeInfo = false; + config.Startup.WriteOSInfo = false; + config.Startup.WriteDriveInfo = false; + config.Startup.WriteSystemMemoryInfo = false; + config.Startup.WriteApplicationInfo = false; + config.Startup.WriteMemoryUsage = false; + return config; +} + +// Each macro's call site, which keeps its limiter for the whole process +void LogEveryFourth(ASWLog::IASWLog& log, ASWLog::Level level, int value) +{ + ASWLOG_EVERY_N(log, 4, level, "Item {}", value); +} + +void LogOnce(ASWLog::IASWLog& log, ASWLog::Level level) +{ + ASWLOG_ONCE(log, level, "Once {}", TLimiterCountedValue{}); +} + +void LogRetry(ASWLog::IASWLog& log, int attempt) +{ + ASWLOG_EVERY_INTERVAL(log, 20ms, ASWLog::Level::Warn, { { "attempt", attempt } }, "Retrying"); +} + +} // namespace + +//--------------------------------------------------------------------------- + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_Limiter +/////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TTest_ASWLog_Limiter::TTest_ASWLog_Limiter() + : inherited("ASWLog_Limiter_Tests") +{ + RegisterTest(&TTest_ASWLog_Limiter::Test_EveryInterval_AllowsOneCallPerInterval, "EveryInterval_AllowsOneCallPerInterval"); + RegisterTest(&TTest_ASWLog_Limiter::Test_EveryInterval_HandlesTheLongestInterval, "EveryInterval_HandlesTheLongestInterval"); + RegisterTest(&TTest_ASWLog_Limiter::Test_EveryInterval_ZeroAllowsEveryCall, "EveryInterval_ZeroAllowsEveryCall"); + RegisterTest(&TTest_ASWLog_Limiter::Test_EveryN_AllowsEveryNthCall, "EveryN_AllowsEveryNthCall"); + RegisterTest(&TTest_ASWLog_Limiter::Test_Macros_EveryIntervalAddsTheSuppressedField, "Macros_EveryIntervalAddsTheSuppressedField"); + RegisterTest(&TTest_ASWLog_Limiter::Test_Macros_EveryNCountsOnlyEntriesThatWouldBeWritten, "Macros_EveryNCountsOnlyEntriesThatWouldBeWritten"); + RegisterTest(&TTest_ASWLog_Limiter::Test_Macros_KeepTheCallersSourceLocation, "Macros_KeepTheCallersSourceLocation"); + RegisterTest(&TTest_ASWLog_Limiter::Test_Macros_OnceLogsTheFirstEntryThatWouldBeWritten, "Macros_OnceLogsTheFirstEntryThatWouldBeWritten"); + RegisterTest(&TTest_ASWLog_Limiter::Test_Once_AllowsOnlyTheFirstCall, "Once_AllowsOnlyTheFirstCall"); + RegisterTest(&TTest_ASWLog_Limiter::Test_Threads_AllowExactlyTheLimitedCalls, "Threads_AllowExactlyTheLimitedCalls"); +} +//--------------------------------------------------------------------------- +TTest_ASWLog_Limiter::~TTest_ASWLog_Limiter() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::SetUp_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::SetUp_Test(ITestCase& /*testCase*/) +{ + LimiterFormatCount = 0; +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::TearDown_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::TearDown_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- + +// /////// Begin tests after this line /////////////////////// + +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_EveryInterval_AllowsOneCallPerInterval() +{ + // Arrange + auto limiter = ASWLog::TASWLogLimiter::EveryInterval(5s); + const std::chrono::steady_clock::time_point start(100s); + std::uint64_t refused[7]{}; + + // Act: 0 s allowed; 1, 2 and 4.999 s refused; 5 s allowed (the interval runs from there); 9 s refused; 10 s allowed + const bool first = limiter.Allow(refused[0], start); + const bool second = limiter.Allow(refused[1], start + 1s); + const bool third = limiter.Allow(refused[2], start + 2s); + const bool justBefore = limiter.Allow(refused[3], start + 4999ms); + const bool atInterval = limiter.Allow(refused[4], start + 5s); + const bool afterIt = limiter.Allow(refused[5], start + 9s); + const bool atNextInterval = limiter.Allow(refused[6], start + 10s); + + // Assert + CheckTrue(first, "The first call should be allowed"); + CheckFalse(second || third || justBefore, "The calls within the interval should be refused"); + CheckTrue(atInterval, "The first call once the interval has passed should be allowed"); + CheckFalse(afterIt, "The interval should run from the call allowed last"); + CheckTrue(atNextInterval, "The first call once the next interval has passed should be allowed"); + CheckEquals(0, refused[0], "The first call follows no refused ones"); + CheckEquals(0, refused[1] + refused[2] + refused[3] + refused[5], "A refused call should get a count of 0"); + CheckEquals(3, refused[4], "The allowed call should get the count of the calls refused since the one before"); + CheckEquals(1, refused[6], "The count should start again after each allowed call"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_EveryInterval_HandlesTheLongestInterval() +{ + // Arrange: the time after the allowed call would overflow the clock's ticks + auto limiter = ASWLog::TASWLogLimiter::EveryInterval(std::chrono::steady_clock::duration::max()); + const std::chrono::steady_clock::time_point start(100s); + std::uint64_t refused = 0; + + // Act + const bool first = limiter.Allow(refused, start); + const bool later = limiter.Allow(refused, start + std::chrono::hours(24 * 365)); + + // Assert + CheckTrue(first, "The first call should be allowed"); + CheckFalse(later, "A call within the (longest) interval should be refused, not allowed by a wrapped time"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_EveryInterval_ZeroAllowsEveryCall() +{ + // Arrange + auto zero = ASWLog::TASWLogLimiter::EveryInterval(0s); + auto negative = ASWLog::TASWLogLimiter::EveryInterval(-1s); + const std::chrono::steady_clock::time_point now(100s); + std::uint64_t refused = 0; + int allowed = 0; + + // Act + for (int i = 0; i < 3; ++i) + allowed += (zero.Allow(refused, now) ? 1 : 0) + (negative.Allow(refused, now) ? 1 : 0); + + // Assert + CheckEquals(6, allowed, "An interval of 0 or less should allow every call"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_EveryN_AllowsEveryNthCall() +{ + // Arrange + auto everyThird = ASWLog::TASWLogLimiter::EveryN(3); + auto everyZeroth = ASWLog::TASWLogLimiter::EveryN(0); + std::string pattern; + std::vector refusedCounts; + int zerothAllowed = 0; + + // Act + for (int i = 0; i < 8; ++i) + { + std::uint64_t refused = 0; + const bool allowed = everyThird.Allow(refused); + pattern += allowed ? 'x' : '.'; + if (allowed) + refusedCounts.push_back(refused); + + zerothAllowed += everyZeroth.Allow() ? 1 : 0; + } + + // Assert + CheckEquals(std::string("x..x..x."), pattern, "The 1st, 4th and 7th calls should be allowed"); + AssertEquals(3, refusedCounts.size(), "Three calls should be allowed"); + CheckEquals(0, refusedCounts[0], "The first follows no refused calls"); + CheckEquals(2, refusedCounts[1], "Each later one follows n - 1 refused calls"); + CheckEquals(8, zerothAllowed, "An n of 0 should allow every call"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_Macros_EveryIntervalAddsTheSuppressedField() +{ + // The call site's limiter lasts for the process + static int runCount = 0; + if (runCount++ > 0) + Skip("The macro's limiter keeps its state for the process, so this test runs once per process"); + + // Arrange + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeLimiterConfig()), "Initialize should succeed"); + const auto deadline = std::chrono::steady_clock::now() + 2s; + int calls = 0; + + // Act: call until the interval has passed and a second entry is written (a bounded wait on that) + while (log.GetLineCount() < 2 && std::chrono::steady_clock::now() < deadline) + LogRetry(log, calls++); + + // Assert + const auto lines = log.GetLines(); + AssertEquals(2, lines.size(), "The first call and the first one after the interval should be written"); + CheckGreaterThan(calls, 2, "Calls within the interval should have been skipped"); + CheckEquals(std::string("[WARN][attempt=0]: Retrying"), lines[0], "The first entry follows no skipped calls"); + CheckEquals(std::format("[WARN][suppressed={} attempt={}]: Retrying", calls - 2, calls - 1), lines[1], + "The next entry should say how many calls were skipped, with the call's own fields"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_Macros_EveryNCountsOnlyEntriesThatWouldBeWritten() +{ + static int runCount = 0; + if (runCount++ > 0) + Skip("The macro's limiter keeps its state for the process, so this test runs once per process"); + + // Arrange + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeLimiterConfig()), "Initialize should succeed"); + + // Act: Debug calls are filtered out (Info is the minimum level), so they don't count + for (int i = 1; i <= 10; ++i) + { + LogEveryFourth(log, ASWLog::Level::Debug, -i); + LogEveryFourth(log, ASWLog::Level::Info, i); + } + + // Assert + const auto lines = log.GetLines(); + AssertEquals(3, lines.size(), "Every 4th written entry of 10 should be written"); + CheckEquals(std::string("[INFO]: Item 1"), lines[0], "The first entry should be written"); + CheckEquals(std::string("[INFO]: Item 5"), lines[1], "Then the 5th"); + CheckEquals(std::string("[INFO]: Item 9"), lines[2], "Then the 9th"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_Macros_KeepTheCallersSourceLocation() +{ + // Arrange + ASWLog::TASWMemoryLog log; + auto config = MakeLimiterConfig(); + std::optional location; + config.OnLogEntryMinimumLevel = ASWLog::Level::Trace; + config.OnLogEntry = [&location](const ASWLog::TASWLogRecord& record, std::string_view /*formattedLine*/) { + location = record.Location; + }; + CheckTrue(log.Initialize(config), "Initialize should succeed"); + + // Act + const auto expectedLine = static_cast(__LINE__ + 1); + ASWLOG_EVERY_N(log, 1, ASWLog::Level::Info, "located"); + + // Assert + AssertTrue(location.has_value(), "The entry should be written"); + CheckEquals(expectedLine, location->line(), "The entry should have the macro's line"); + CheckContains(std::string(location->function_name()), "Test_Macros_KeepTheCallersSourceLocation", "The entry should have the calling function"); + CheckEndsWith(std::string(location->file_name()), "Test_ASWLog_Limiter.cpp", "The entry should have the calling file"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_Macros_OnceLogsTheFirstEntryThatWouldBeWritten() +{ + static int runCount = 0; + if (runCount++ > 0) + Skip("The macro's limiter keeps its state for the process, so this test runs once per process"); + + // Arrange + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeLimiterConfig()), "Initialize should succeed"); + + // Act: filtered calls first (Debug, with Info the minimum level), then the level lets Debug through + LogOnce(log, ASWLog::Level::Debug); + LogOnce(log, ASWLog::Level::Debug); + const auto filteredLineCount = log.GetLineCount(); + log.SetMinimumLevel(ASWLog::Level::Debug); + for (int i = 0; i < 3; ++i) + LogOnce(log, ASWLog::Level::Debug); + + // Assert + CheckEquals(0, filteredLineCount, "Filtered calls should write nothing"); + const auto lines = log.GetLines(); + AssertEquals(1, lines.size(), "Only the first entry that would be written should be written"); + CheckEquals(std::string("[DEBUG]: Once counted"), lines[0], "It should be the formatted entry"); + CheckEquals(1, LimiterFormatCount, "Only the written entry's arguments should be formatted"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_Once_AllowsOnlyTheFirstCall() +{ + // Arrange + auto limiter = ASWLog::TASWLogLimiter::Once(); + std::uint64_t refused = 99; + + // Act + const bool first = limiter.Allow(refused); + const auto firstRefused = refused; + const bool second = limiter.Allow(refused); + const bool third = limiter.Allow(); + + // Assert + CheckTrue(first, "The first call should be allowed"); + CheckEquals(0, firstRefused, "The first call follows no refused ones"); + CheckFalse(second || third, "Later calls should be refused"); + CheckEquals(0, refused, "A refused call should get a count of 0"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Limiter::Test_Threads_AllowExactlyTheLimitedCalls() +{ + // Arrange: threads sharing each kind of limiter (also for ThreadSanitizer), EveryInterval at one fixed time + constexpr int ThreadCount = 8; + constexpr int CallsPerThread = 1000; + auto once = ASWLog::TASWLogLimiter::Once(); + auto everySeventh = ASWLog::TASWLogLimiter::EveryN(7); + auto everyHour = ASWLog::TASWLogLimiter::EveryInterval(1h); + const std::chrono::steady_clock::time_point now(100s); + std::atomic onceAllowed{ 0 }; + std::atomic everySeventhAllowed{ 0 }; + std::atomic everyHourAllowed{ 0 }; + std::atomic everyHourRefused{ 0 }; + std::vector threads; + + // Act + for (int t = 0; t < ThreadCount; ++t) + { + threads.emplace_back([&] { + std::uint64_t refused = 0; + for (int i = 0; i < CallsPerThread; ++i) + { + onceAllowed += once.Allow(refused, now) ? 1 : 0; + everySeventhAllowed += everySeventh.Allow(refused, now) ? 1 : 0; + if (everyHour.Allow(refused, now)) + { + ++everyHourAllowed; + everyHourRefused += refused; + } + } + }); + } + + for (auto& thread : threads) + thread.join(); + + std::uint64_t nextHourRefused = 0; + const bool nextHour = everyHour.Allow(nextHourRefused, now + 1h); + + // Assert: a call refused while another was allowed may be counted with either allowed call, but each just once + CheckEquals(1, onceAllowed.load(), "Once should allow exactly one call"); + CheckEquals((ThreadCount * CallsPerThread + 6) / 7, everySeventhAllowed.load(), "EveryN should allow exactly every 7th call"); + CheckEquals(1, everyHourAllowed.load(), "EveryInterval should allow exactly one call at one time"); + CheckTrue(nextHour, "A call an interval later should be allowed"); + CheckEquals(ThreadCount * CallsPerThread - 1, everyHourRefused.load() + nextHourRefused, "Every refused call should be counted once"); +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +ASW_REGISTER_TEST_GROUP(ASWUnitTests::TTest_ASWLog_Limiter) diff --git a/tests/tests/Test_ASWLog_Limiter.h b/tests/tests/Test_ASWLog_Limiter.h new file mode 100644 index 0000000..be141b4 --- /dev/null +++ b/tests/tests/Test_ASWLog_Limiter.h @@ -0,0 +1,65 @@ +/* ************************************************************************** +Test_ASWLog_Limiter.h +Author: Anthony S. West - ASW Software + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef Test_ASWLog_LimiterH +#define Test_ASWLog_LimiterH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_TestBase.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_Limiter +/////////////////////////////////////////////////////////////////////////// +class TTest_ASWLog_Limiter : public TTestGroupBase +{ +private: + typedef TTestGroupBase inherited; + +private: // Test methods + void Test_EveryInterval_AllowsOneCallPerInterval(); + void Test_EveryInterval_HandlesTheLongestInterval(); + void Test_EveryInterval_ZeroAllowsEveryCall(); + void Test_EveryN_AllowsEveryNthCall(); + void Test_Macros_EveryIntervalAddsTheSuppressedField(); + void Test_Macros_EveryNCountsOnlyEntriesThatWouldBeWritten(); + void Test_Macros_KeepTheCallersSourceLocation(); + void Test_Macros_OnceLogsTheFirstEntryThatWouldBeWritten(); + void Test_Once_AllowsOnlyTheFirstCall(); + void Test_Threads_AllowExactlyTheLimitedCalls(); + +public: + TTest_ASWLog_Limiter(); + ~TTest_ASWLog_Limiter() override; + + void SetUp_Group() override; + void SetUp_Test(ITestCase& testCase) override; + void TearDown_Group() override; + void TearDown_Test(ITestCase& testCase) override; +}; + +} // ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef Test_ASWLog_LimiterH diff --git a/tests/tests/Test_ASWLog_MemoryLog.cpp b/tests/tests/Test_ASWLog_MemoryLog.cpp new file mode 100644 index 0000000..8a3f029 --- /dev/null +++ b/tests/tests/Test_ASWLog_MemoryLog.cpp @@ -0,0 +1,372 @@ +/* ************************************************************************** +Test_ASWLog_MemoryLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "Test_ASWLog_MemoryLog.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Registry.h" +//--------------------------------------------------------------------------- +#include "ASWLog_MemoryLog.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +namespace +{ + +// How long a test waits for something another thread does, before it fails +constexpr std::chrono::milliseconds WaitTimeout = std::chrono::seconds(5); + +//--------------------------------------------------------------------------- + +// A config with no startup or shutdown lines and only the level in each line, e.g. "[INFO]: message", keeping at most +// 'maxLines' lines and 'maxBytes' bytes +ASWLog::TASWLogConfig MakeMemoryConfig(std::size_t maxLines = 1000, std::size_t maxBytes = 0) +{ + ASWLog::TASWLogConfig config; + config.InitialMinimumLevel = ASWLog::Level::Info; + config.Line.ShowTimestamp = false; + config.Line.ShowProcessId = false; + config.Line.ShowThreadId = false; + config.Shutdown.WriteLine = false; + config.Startup.WriteTimeInfo = false; + config.Startup.WriteOSInfo = false; + config.Startup.WriteDriveInfo = false; + config.Startup.WriteSystemMemoryInfo = false; + config.Startup.WriteApplicationInfo = false; + config.Startup.WriteMemoryUsage = false; + config.Memory.MaxLines = maxLines; + config.Memory.MaxBytes = maxBytes; + return config; +} + +} // namespace + +//--------------------------------------------------------------------------- + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_MemoryLog +/////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TTest_ASWLog_MemoryLog::TTest_ASWLog_MemoryLog() + : inherited("ASWLog_MemoryLog_Tests") +{ + RegisterTest(&TTest_ASWLog_MemoryLog::Test_Async_KeepsTheQueuedLines, "Async_KeepsTheQueuedLines"); + RegisterTest(&TTest_ASWLog_MemoryLog::Test_Clear_ForgetsTheLinesButNotTheSequence, "Clear_ForgetsTheLinesButNotTheSequence"); + RegisterTest(&TTest_ASWLog_MemoryLog::Test_Close_KeepsTheLines, "Close_KeepsTheLines"); + RegisterTest(&TTest_ASWLog_MemoryLog::Test_GetLinesSince_ReturnsNewLinesAndCountsMissedOnes, "GetLinesSince_ReturnsNewLinesAndCountsMissedOnes"); + RegisterTest(&TTest_ASWLog_MemoryLog::Test_KeepsLinesWithoutTheirEnding, "KeepsLinesWithoutTheirEnding"); + RegisterTest(&TTest_ASWLog_MemoryLog::Test_MaxBytes_CutsALineThatAloneIsTooLong, "MaxBytes_CutsALineThatAloneIsTooLong"); + RegisterTest(&TTest_ASWLog_MemoryLog::Test_MaxBytes_DropsTheOldestLines, "MaxBytes_DropsTheOldestLines"); + RegisterTest(&TTest_ASWLog_MemoryLog::Test_MaxLines_KeepsTheNewest, "MaxLines_KeepsTheNewest"); + RegisterTest(&TTest_ASWLog_MemoryLog::Test_Reconfigure_AppliesTheNewLimits, "Reconfigure_AppliesTheNewLimits"); + RegisterTest(&TTest_ASWLog_MemoryLog::Test_Threads_ReaderGetsEveryLineOnce, "Threads_ReaderGetsEveryLineOnce"); +} +//--------------------------------------------------------------------------- +TTest_ASWLog_MemoryLog::~TTest_ASWLog_MemoryLog() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::SetUp_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::SetUp_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::TearDown_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::TearDown_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- + +// /////// Begin tests after this line /////////////////////// + +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_Async_KeepsTheQueuedLines() +{ + // Arrange + auto config = MakeMemoryConfig(); + config.Async.Enabled = true; + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(config), "Initialize should succeed"); + + // Act + log.LogInfo("one"); + log.LogWarn("two"); + log.Flush(); // Waits for the queued entries + + // Assert + CheckTrue(log.GetLines() == std::vector{ "[INFO]: one", "[WARN]: two" }, "The queued lines should be kept in order"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_Clear_ForgetsTheLinesButNotTheSequence() +{ + // Arrange + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeMemoryConfig()), "Initialize should succeed"); + log.LogInfo("one"); + log.LogInfo("two"); + + // Act + log.Clear(); + const auto countAfterClear = log.GetLineCount(); + const auto afterClear = log.GetLinesSince(1); + log.LogInfo("three"); + const auto afterNewLine = log.GetLinesSince(afterClear.NextSequence); + + // Assert + CheckEquals(0, countAfterClear, "Clear() should forget the lines"); + CheckEmpty(afterClear.Lines, "No line should be left after Clear()"); + CheckEquals(2, afterClear.MissedCount, "The cleared lines should count as missed"); + CheckEquals(3, afterClear.NextSequence, "The sequence should go on after Clear()"); + CheckTrue(afterNewLine.Lines == std::vector{ "[INFO]: three" }, "A new line should follow the cleared ones"); + CheckEquals(0, afterNewLine.MissedCount, "Nothing should be missed after the cleared lines"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_Close_KeepsTheLines() +{ + // Arrange + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeMemoryConfig()), "Initialize should succeed"); + log.LogInfo("kept"); + + // Act + const bool closed = log.Close(); + log.LogInfo("dropped"); + + // Assert + CheckTrue(closed, "Close() should succeed"); + CheckFalse(log.IsOpen(), "The logger should be closed"); + CheckFalse(log.Flush(), "Flush() should fail while closed"); + CheckTrue(log.GetLines() == std::vector{ "[INFO]: kept" }, "The lines should be kept after Close(), and no more added"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_GetLinesSince_ReturnsNewLinesAndCountsMissedOnes() +{ + // Arrange: lines 1 to 3 kept, then 4 and 5, which push 1 and 2 out + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeMemoryConfig(3)), "Initialize should succeed"); + for (int index = 1; index <= 3; ++index) + log.LogInfo(std::format("line {}", index)); + + // Act + const auto all = log.GetLinesSince(1); + const auto fromZero = log.GetLinesSince(0); + const auto last = log.GetLinesSince(3); + const auto none = log.GetLinesSince(4); + log.LogInfo("line 4"); + log.LogInfo("line 5"); + const auto afterDrops = log.GetLinesSince(1); + const auto ahead = log.GetLinesSince(10); + + // Assert + const std::vector firstThree{ "[INFO]: line 1", "[INFO]: line 2", "[INFO]: line 3" }; + CheckTrue(all.Lines == firstThree, "Every kept line should be returned from 1"); + CheckEquals(4, all.NextSequence, "The next sequence number should follow the last line"); + CheckEquals(0, all.MissedCount, "Nothing should be missed yet"); + CheckTrue(fromZero.Lines == firstThree, "0 should read like 1, the first line's number"); + CheckEquals(0, fromZero.MissedCount, "0 shouldn't count a line that never existed"); + CheckTrue(last.Lines == std::vector{ "[INFO]: line 3" }, "Only the lines from the given number should be returned"); + CheckEmpty(none.Lines, "No line should be returned when there is none newer"); + CheckEquals(4, none.NextSequence, "The next sequence number should stay when there is no new line"); + CheckTrue(afterDrops.Lines == std::vector{ "[INFO]: line 3", "[INFO]: line 4", "[INFO]: line 5" }, + "The lines still kept should be returned"); + CheckEquals(2, afterDrops.MissedCount, "The dropped lines should count as missed"); + CheckEquals(6, afterDrops.NextSequence, "The next sequence number should follow line 5"); + CheckEmpty(ahead.Lines, "A number past the last line should return nothing"); + CheckEquals(0, ahead.MissedCount, "A number past the last line should miss nothing"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_KeepsLinesWithoutTheirEnding() +{ + // Arrange: LF, and CRLF with indented continuation lines + ASWLog::TASWMemoryLog log; + ASWLog::TASWMemoryLog crlfLog; + auto crlfConfig = MakeMemoryConfig(); + crlfConfig.Line.Ending = ASWLog::LineEnding::CRLF; + crlfConfig.Line.Multiline = ASWLog::MultilineMode::Indent; + CheckTrue(log.Initialize(MakeMemoryConfig()), "Initialize should succeed"); + CheckTrue(crlfLog.Initialize(crlfConfig), "Initialize should succeed (CRLF)"); + + // Act + log.LogInfo("plain"); + log.LogInfo("ends with a break\n"); + log.LogRaw(ASWLog::Level::Info, "raw\n"); + log.LogRaw(ASWLog::Level::Info, "partial"); + crlfLog.LogInfo("first\nsecond"); + crlfLog.LogRaw(ASWLog::Level::Info, "raw\r\n"); + + // Assert + const std::vector expected{ "[INFO]: plain", "[INFO]: ends with a break\n", "raw\n", "partial" }; + CheckTrue(log.GetLines() == expected, "Formatted lines should lose only their line ending; raw ones should be kept as given"); + CheckTrue(crlfLog.GetLines() == std::vector{ "[INFO]: first\r\n | second", "raw\r\n" }, + "A CRLF ending should be left off, and the line breaks inside the line kept"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_MaxBytes_CutsALineThatAloneIsTooLong() +{ + // Arrange: 20 bytes, so the line "[INFO]: abcde" + a 2-byte UTF-8 character + more is cut inside that character; + // and 4 bytes, too few for the marker + ASWLog::TASWMemoryLog log; + ASWLog::TASWMemoryLog tinyLog; + CheckTrue(log.Initialize(MakeMemoryConfig(1000, 20)), "Initialize should succeed"); + CheckTrue(tinyLog.Initialize(MakeMemoryConfig(1000, 4)), "Initialize should succeed (tiny)"); + log.LogInfo("first"); + + // Act + log.LogInfo("abcde\xCE\xBBxxxxxxxxxxxxxxxxxxxx"); + tinyLog.LogInfo("hello"); + + // Assert + CheckTrue(log.GetLines() == std::vector{ "[INFO]: abcde [cut]" }, + "The line should be cut between UTF-8 characters, marked, and the older line dropped"); + CheckTrue(tinyLog.GetLines() == std::vector{ "[INF" }, "Without room for the marker, the line should only be cut"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_MaxBytes_DropsTheOldestLines() +{ + // Arrange: 30 bytes, two 15-byte lines + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeMemoryConfig(1000, 30)), "Initialize should succeed"); + + // Act + log.LogInfo("1111111"); + log.LogInfo("2222222"); + const auto atTheLimit = log.GetLines(); + log.LogInfo("3333333"); + + // Assert + CheckTrue(atTheLimit == std::vector{ "[INFO]: 1111111", "[INFO]: 2222222" }, "Lines that fit exactly should all be kept"); + CheckTrue(log.GetLines() == std::vector{ "[INFO]: 2222222", "[INFO]: 3333333" }, "The oldest line should go to make room"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_MaxLines_KeepsTheNewest() +{ + // Arrange + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeMemoryConfig(3)), "Initialize should succeed"); + + // Act + for (int index = 1; index <= 5; ++index) + log.LogInfo(std::format("line {}", index)); + + // Assert + CheckEquals(3, log.GetLineCount(), "At most MaxLines lines should be kept"); + CheckTrue(log.GetLines() == std::vector{ "[INFO]: line 3", "[INFO]: line 4", "[INFO]: line 5" }, + "The newest lines should be kept, oldest first"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_Reconfigure_AppliesTheNewLimits() +{ + // Arrange + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeMemoryConfig(10)), "Initialize should succeed"); + for (int index = 1; index <= 5; ++index) + log.LogInfo(std::format("line {}", index)); + + // Act + const bool fewerLines = log.Reconfigure(MakeMemoryConfig(3)); + const auto afterFewerLines = log.GetLines(); + const bool fewerBytes = log.Reconfigure(MakeMemoryConfig(3, 30)); + const auto afterFewerBytes = log.GetLines(); + const bool unlimited = log.Reconfigure(MakeMemoryConfig(0, 0)); + for (int index = 6; index <= 9; ++index) + log.LogInfo(std::format("line {}", index)); + + // Assert + CheckTrue(fewerLines && fewerBytes && unlimited, "Reconfigure should succeed"); + CheckTrue(afterFewerLines == std::vector{ "[INFO]: line 3", "[INFO]: line 4", "[INFO]: line 5" }, + "A smaller MaxLines should keep the newest lines"); + CheckTrue(afterFewerBytes == std::vector{ "[INFO]: line 4", "[INFO]: line 5" }, "A smaller MaxBytes should keep the newest lines"); + CheckEquals(6, log.GetLineCount(), "With no limits, every new line should be kept"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_MemoryLog::Test_Threads_ReaderGetsEveryLineOnce() +{ + // Arrange: four threads log while another reads the new lines as they come (for ThreadSanitizer) + constexpr int ThreadCount = 4; + constexpr int EntryCount = 250; + constexpr std::size_t Total = ThreadCount * EntryCount; + ASWLog::TASWMemoryLog log; + CheckTrue(log.Initialize(MakeMemoryConfig(0)), "Initialize should succeed"); + std::set seen; + std::uint64_t missedCount = 0; + std::size_t readCount = 0; + + // Act + std::thread reader([&] { + std::uint64_t next = 1; + const auto deadline = std::chrono::steady_clock::now() + WaitTimeout; + while (readCount < Total && std::chrono::steady_clock::now() < deadline) + { + const auto newLines = log.GetLinesSince(next); + missedCount += newLines.MissedCount; + readCount += newLines.Lines.size(); + seen.insert(newLines.Lines.begin(), newLines.Lines.end()); + next = newLines.NextSequence; + std::this_thread::yield(); + } + }); + + std::vector writers; + for (int threadIndex = 0; threadIndex < ThreadCount; ++threadIndex) + { + writers.emplace_back([&log, threadIndex] { + for (int index = 0; index < EntryCount; ++index) + log.LogInfo(std::format("{} {}", threadIndex, index)); + }); + } + + for (auto& writer : writers) + writer.join(); + + reader.join(); + + // Assert + CheckEquals(Total, readCount, "The reader should get every line"); + CheckEquals(Total, seen.size(), "The reader should get each line once"); + CheckEquals(0, missedCount, "Nothing should be missed without limits"); +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +ASW_REGISTER_TEST_GROUP(ASWUnitTests::TTest_ASWLog_MemoryLog) diff --git a/tests/tests/Test_ASWLog_MemoryLog.h b/tests/tests/Test_ASWLog_MemoryLog.h new file mode 100644 index 0000000..dea6a02 --- /dev/null +++ b/tests/tests/Test_ASWLog_MemoryLog.h @@ -0,0 +1,65 @@ +/* ************************************************************************** +Test_ASWLog_MemoryLog.h +Author: Anthony S. West - ASW Software + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef Test_ASWLog_MemoryLogH +#define Test_ASWLog_MemoryLogH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_TestBase.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_MemoryLog +/////////////////////////////////////////////////////////////////////////// +class TTest_ASWLog_MemoryLog : public TTestGroupBase +{ +private: + typedef TTestGroupBase inherited; + +private: // Test methods + void Test_Async_KeepsTheQueuedLines(); + void Test_Clear_ForgetsTheLinesButNotTheSequence(); + void Test_Close_KeepsTheLines(); + void Test_GetLinesSince_ReturnsNewLinesAndCountsMissedOnes(); + void Test_KeepsLinesWithoutTheirEnding(); + void Test_MaxBytes_CutsALineThatAloneIsTooLong(); + void Test_MaxBytes_DropsTheOldestLines(); + void Test_MaxLines_KeepsTheNewest(); + void Test_Reconfigure_AppliesTheNewLimits(); + void Test_Threads_ReaderGetsEveryLineOnce(); + +public: + TTest_ASWLog_MemoryLog(); + ~TTest_ASWLog_MemoryLog() override; + + void SetUp_Group() override; + void SetUp_Test(ITestCase& testCase) override; + void TearDown_Group() override; + void TearDown_Test(ITestCase& testCase) override; +}; + +} // ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef Test_ASWLog_MemoryLogH diff --git a/tests/tests/Test_ASWLog_NullLog.cpp b/tests/tests/Test_ASWLog_NullLog.cpp new file mode 100644 index 0000000..580e4c4 --- /dev/null +++ b/tests/tests/Test_ASWLog_NullLog.cpp @@ -0,0 +1,265 @@ +/* ************************************************************************** +Test_ASWLog_NullLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "Test_ASWLog_NullLog.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Registry.h" +//--------------------------------------------------------------------------- +#include "ASWLog_CategoryLog.h" +#include "ASWLog_MultiLog.h" +#include "ASWLog_NullLog.h" +#include "ASWLog_Version.h" +//--------------------------------------------------------------------------- + +namespace +{ + +// A value whose formatting is counted in NullLogFormatCount, to check that no *Fmt call formats +struct TNullLogCountedValue +{ +}; + +int NullLogFormatCount = 0; + +} // namespace + +template<> +struct std::formatter +{ + constexpr std::format_parse_context::iterator parse(std::format_parse_context& context) + { + return context.begin(); + } + + std::format_context::iterator format(const TNullLogCountedValue& /*value*/, std::format_context& context) const + { + ++NullLogFormatCount; + return std::format_to(context.out(), "counted"); + } +}; + +namespace ASWUnitTests +{ + +//--------------------------------------------------------------------------- + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_NullLog +/////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TTest_ASWLog_NullLog::TTest_ASWLog_NullLog() + : inherited("ASWLog_NullLog_Tests") +{ + RegisterTest(&TTest_ASWLog_NullLog::Test_Fmt_NeverFormats, "Fmt_NeverFormats"); + RegisterTest(&TTest_ASWLog_NullLog::Test_GetConfig_IsSafeWhileReconfiguring, "GetConfig_IsSafeWhileReconfiguring"); + RegisterTest(&TTest_ASWLog_NullLog::Test_GetInstance_ReturnsTheSameLogger, "GetInstance_ReturnsTheSameLogger"); + RegisterTest(&TTest_ASWLog_NullLog::Test_Lifecycle_SucceedsAndKeepsTheConfig, "Lifecycle_SucceedsAndKeepsTheConfig"); + RegisterTest(&TTest_ASWLog_NullLog::Test_ShouldLog_IsAlwaysFalse, "ShouldLog_IsAlwaysFalse"); + RegisterTest(&TTest_ASWLog_NullLog::Test_Wrapped_WritesNothing, "Wrapped_WritesNothing"); +} +//--------------------------------------------------------------------------- +TTest_ASWLog_NullLog::~TTest_ASWLog_NullLog() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::SetUp_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::SetUp_Test(ITestCase& /*testCase*/) +{ + NullLogFormatCount = 0; +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::TearDown_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::TearDown_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- + +// /////// Begin tests after this line /////////////////////// + +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::Test_Fmt_NeverFormats() +{ + // Arrange: everything a real logger would need to write an entry + ASWLog::TASWNullLog log; + log.SetMinimumLevel(ASWLog::Level::Trace); + log.SetEnabled(true); + + // Act + log.LogTraceFmt("{}", TNullLogCountedValue{}); + log.LogCriticalFmt("{}", TNullLogCountedValue{}); + log.LogRawFmt(ASWLog::Level::Error, "{}", TNullLogCountedValue{}); + log.LogForceFmt(ASWLog::Level::Critical, "{}", TNullLogCountedValue{}); + log.LogForceRawFmt(ASWLog::Level::Critical, "{}", TNullLogCountedValue{}); + log.LogInfoFmt({ { "orderId", 17 } }, "{}", TNullLogCountedValue{}); + + // Assert + CheckEquals(0, NullLogFormatCount, "No *Fmt call should format its message"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::Test_GetConfig_IsSafeWhileReconfiguring() +{ + // Arrange: two threads reconfigure while two read the config (for ThreadSanitizer) + ASWLog::TASWNullLog log; + ASWLog::TASWLogConfig first; + first.Startup.Banner = "first"; + ASWLog::TASWLogConfig second; + second.Startup.Banner = "second"; + log.Initialize(first); + std::atomic unexpectedCount{ 0 }; + std::atomic failedCount{ 0 }; + + // Act + std::vector threads; + for (int threadIndex = 0; threadIndex < 4; ++threadIndex) + { + threads.emplace_back([&, threadIndex] { + for (int index = 0; index < 500; ++index) + { + if (threadIndex < 2) + { + if (!log.Reconfigure(index % 2 == 0 ? second : first)) + failedCount.fetch_add(1); + } + else + { + const auto banner = log.GetConfig()->Startup.Banner; + if (banner != "first" && banner != "second") + unexpectedCount.fetch_add(1); + } + } + }); + } + + for (auto& thread : threads) + thread.join(); + + // Assert + CheckEquals(0, failedCount.load(), "Every Reconfigure() should succeed"); + CheckEquals(0, unexpectedCount.load(), "Every read should see one of the configs whole"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::Test_GetInstance_ReturnsTheSameLogger() +{ + // Act + auto& first = ASWLog::TASWNullLog::GetInstance(); + auto& second = ASWLog::TASWNullLog::GetInstance(); + + // Assert + CheckSame(&first, &second, "GetInstance() should always return the same logger"); + CheckFalse(first.ShouldLog(ASWLog::Level::Critical), "The shared instance should write nothing either"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::Test_Lifecycle_SucceedsAndKeepsTheConfig() +{ + // Arrange + ASWLog::TASWNullLog log; + ASWLog::TASWLogConfig config; + config.Startup.Banner = "initialized"; + config.InitialMinimumLevel = ASWLog::Level::Debug; + auto changedConfig = config; + changedConfig.Startup.Banner = "reconfigured"; + changedConfig.InitialMinimumLevel = ASWLog::Level::Error; + + // Act + const auto defaultConfig = log.GetConfig(); + const bool initialized = log.Initialize(config); + const auto initializedConfig = log.GetConfig(); + const auto levelAfterInitialize = log.GetMinimumLevel(); + const bool reconfigured = log.Reconfigure(changedConfig); + const auto levelAfterReconfigure = log.GetMinimumLevel(); + log.SetMinimumLevel(ASWLog::Level::Warn); + log.DumpBacktrace(); + + // Assert + AssertNotNull(defaultConfig, "GetConfig() should return a config before Initialize()"); + CheckEmpty(defaultConfig->Startup.Banner, "The config before Initialize() should be the default"); + CheckTrue(initialized && reconfigured, "Initialize() and Reconfigure() should succeed"); + CheckEquals(std::string("initialized"), initializedConfig->Startup.Banner, "An earlier snapshot should stay unchanged"); + CheckEquals(std::string("reconfigured"), log.GetConfig()->Startup.Banner, "GetConfig() should return the last config passed in"); + CheckEquals(ASWLog::Level::Debug, levelAfterInitialize, "Initialize() should seed the minimum level"); + CheckEquals(ASWLog::Level::Debug, levelAfterReconfigure, "Reconfigure() should keep the minimum level"); + CheckEquals(ASWLog::Level::Warn, log.GetMinimumLevel(), "The minimum level should read back as set"); + CheckTrue(log.Open() && log.Flush() && log.IsOpen() && log.Close(), "Open(), Flush(), IsOpen() and Close() should succeed"); + CheckContains(log.GetFullVersionStr(), "TASWNullLog", "The full version should name the logger"); + CheckEquals(std::string(ASWLog::Version), std::string(log.GetVersionStr()), "The version should be the library's"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::Test_ShouldLog_IsAlwaysFalse() +{ + // Arrange + ASWLog::TASWNullLog log; + log.SetMinimumLevel(ASWLog::Level::Trace); + log.SetEnabled(true); + ASWLog::TASWLogRecord forced; + forced.LogLevel = ASWLog::Level::Critical; + forced.Forced = true; + + // Act & Assert + for (const auto level : { ASWLog::Level::Trace, ASWLog::Level::Info, ASWLog::Level::Critical }) + CheckFalse(log.ShouldLog(level), std::format("ShouldLog({}) should be false", ASWLog::Level_ToString(level))); + + CheckFalse(log.ShouldLog(forced), "ShouldLog() should be false for a forced record"); + CheckFalse(log.IsEnabled(), "IsEnabled() should stay false after SetEnabled(true)"); + log.Write(forced); + log.LogCritical("not written"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_NullLog::Test_Wrapped_WritesNothing() +{ + // Arrange: a category and a multi-log in front of a null log + ASWLog::TASWNullLog log; + ASWLog::TASWCategoryLog netLog("Net", log); + netLog.SetMinimumLevel(ASWLog::Level::Trace); + ASWLog::TASWMultiLog multiLog; + multiLog.AddLogger(log); + multiLog.SetMinimumLevel(ASWLog::Level::Trace); + + // Act + netLog.LogInfoFmt("{}", TNullLogCountedValue{}); + multiLog.LogInfoFmt("{}", TNullLogCountedValue{}); + + // Assert + CheckFalse(netLog.ShouldLog(ASWLog::Level::Critical), "A category over a null log should write nothing"); + CheckFalse(multiLog.ShouldLog(ASWLog::Level::Critical), "A multi-log holding only a null log should write nothing"); + CheckEquals(0, NullLogFormatCount, "Neither should format a message for the null log"); +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +ASW_REGISTER_TEST_GROUP(ASWUnitTests::TTest_ASWLog_NullLog) diff --git a/tests/tests/Test_ASWLog_NullLog.h b/tests/tests/Test_ASWLog_NullLog.h new file mode 100644 index 0000000..3f60a25 --- /dev/null +++ b/tests/tests/Test_ASWLog_NullLog.h @@ -0,0 +1,61 @@ +/* ************************************************************************** +Test_ASWLog_NullLog.h +Author: Anthony S. West - ASW Software + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef Test_ASWLog_NullLogH +#define Test_ASWLog_NullLogH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_TestBase.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_NullLog +/////////////////////////////////////////////////////////////////////////// +class TTest_ASWLog_NullLog : public TTestGroupBase +{ +private: + typedef TTestGroupBase inherited; + +private: // Test methods + void Test_Fmt_NeverFormats(); + void Test_GetConfig_IsSafeWhileReconfiguring(); + void Test_GetInstance_ReturnsTheSameLogger(); + void Test_Lifecycle_SucceedsAndKeepsTheConfig(); + void Test_ShouldLog_IsAlwaysFalse(); + void Test_Wrapped_WritesNothing(); + +public: + TTest_ASWLog_NullLog(); + ~TTest_ASWLog_NullLog() override; + + void SetUp_Group() override; + void SetUp_Test(ITestCase& testCase) override; + void TearDown_Group() override; + void TearDown_Test(ITestCase& testCase) override; +}; + +} // ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef Test_ASWLog_NullLogH diff --git a/tests/tests/Test_ASWLog_SyslogLog.cpp b/tests/tests/Test_ASWLog_SyslogLog.cpp new file mode 100644 index 0000000..60822ff --- /dev/null +++ b/tests/tests/Test_ASWLog_SyslogLog.cpp @@ -0,0 +1,447 @@ +/* ************************************************************************** +Test_ASWLog_SyslogLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "Test_ASWLog_SyslogLog.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Registry.h" +//--------------------------------------------------------------------------- +#include "ASWLog_CrashHandler.h" +#include "ASWLog_SyslogLog.h" +#include "ASWLog_Utils.h" +//--------------------------------------------------------------------------- + +namespace +{ + +// A value whose formatting is counted in SyslogLogFormatCount, to check whether a *Fmt call formats +struct TSyslogLogCountedValue +{ +}; + +int SyslogLogFormatCount = 0; + +} // namespace + +template<> +struct std::formatter +{ + constexpr std::format_parse_context::iterator parse(std::format_parse_context& context) + { + return context.begin(); + } + + std::format_context::iterator format(const TSyslogLogCountedValue& /*value*/, std::format_context& context) const + { + ++SyslogLogFormatCount; + return std::format_to(context.out(), "counted"); + } +}; + +namespace ASWUnitTests +{ + +namespace +{ + +// The syslog priorities of the LOG_USER facility (8) with each severity, as in +constexpr int UserCritical = 8 | 2; +constexpr int UserError = 8 | 3; +constexpr int UserWarning = 8 | 4; +constexpr int UserInfo = 8 | 6; +constexpr int UserDebug = 8 | 7; + +//--------------------------------------------------------------------------- + +// A config with no startup or shutdown lines and only the level in each line, e.g. "[INFO]: message" +ASWLog::TASWLogConfig MakeSyslogConfig() +{ + ASWLog::TASWLogConfig config; + config.InitialMinimumLevel = ASWLog::Level::Info; + config.Line.ShowTimestamp = false; + config.Line.ShowProcessId = false; + config.Line.ShowThreadId = false; + config.Shutdown.WriteLine = false; + config.Startup.WriteTimeInfo = false; + config.Startup.WriteOSInfo = false; + config.Startup.WriteDriveInfo = false; + config.Startup.WriteSystemMemoryInfo = false; + config.Startup.WriteApplicationInfo = false; + config.Startup.WriteMemoryUsage = false; + return config; +} + +// Counts the lines it formats +class TCountingFormatter final : public ASWLog::IASWLogFormatter +{ +public: + mutable int FormatCount = 0; + + std::string Format(const ASWLog::TASWLogRecord& record, const ASWLog::TASWLogConfig& /*config*/) const override + { + ++FormatCount; + return std::string(record.Message); + } +}; + +// A syslog logger that keeps what it would pass to openlog() and syslog(), on every platform (on Windows, the +// logger's own ShouldLog() and PrepareWriteUnlocked() would drop every entry) +class TCapturingSyslogLog final : public ASWLog::TASWSyslogLog +{ +public: + struct TOpen + { + std::string Ident; + ASWLog::SyslogFacility Facility = ASWLog::SyslogFacility::User; + }; + + struct TMessage + { + int Priority = 0; + std::string Text; + }; + + std::vector Opens; + std::vector Messages; + +protected: + void OpenSyslogUnlocked(const std::string& ident, ASWLog::SyslogFacility facility) override + { + Opens.push_back({ ident, facility }); + } + + bool PrepareWriteUnlocked(std::chrono::system_clock::time_point /*now*/) override + { + return true; + } + + void SendToSyslogUnlocked(int priority, std::string_view message) override + { + Messages.push_back({ priority, std::string(message) }); + } + +public: + // The overrides above are used until the shutdown line + ~TCapturingSyslogLog() override + { + Finalize(); + } + + bool ShouldLog(ASWLog::Level level) const noexcept override + { + return ASWLog::TASWTextLogBase::ShouldLog(level); + } + + bool ShouldLog(const ASWLog::TASWLogRecord& record) const noexcept override + { + return ASWLog::TASWTextLogBase::ShouldLog(record); + } + + void WriteCrashLine(std::string_view line) noexcept + { + WriteCrashLineDirect(line); + } +}; + +} // namespace + +//--------------------------------------------------------------------------- + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_SyslogLog +/////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TTest_ASWLog_SyslogLog::TTest_ASWLog_SyslogLog() + : inherited("ASWLog_SyslogLog_Tests") +{ + RegisterTest(&TTest_ASWLog_SyslogLog::Test_GetInstance_ReturnsTheSameLogger, "GetInstance_ReturnsTheSameLogger"); + RegisterTest(&TTest_ASWLog_SyslogLog::Test_HandleCrash_SendsTheFormattedCrashLineOnly, "HandleCrash_SendsTheFormattedCrashLineOnly"); + RegisterTest(&TTest_ASWLog_SyslogLog::Test_Initialize_OpensSyslogWithTheIdentAndFacility, "Initialize_OpensSyslogWithTheIdentAndFacility"); + RegisterTest(&TTest_ASWLog_SyslogLog::Test_Linux_SendsToTheRealSyslog, "Linux_SendsToTheRealSyslog"); + RegisterTest(&TTest_ASWLog_SyslogLog::Test_Send_FollowsTheMultilineMode, "Send_FollowsTheMultilineMode"); + RegisterTest(&TTest_ASWLog_SyslogLog::Test_Send_MapsEachLevelToAPriority, "Send_MapsEachLevelToAPriority"); + RegisterTest(&TTest_ASWLog_SyslogLog::Test_Send_StripsOnlyTheLineEnding, "Send_StripsOnlyTheLineEnding"); + RegisterTest(&TTest_ASWLog_SyslogLog::Test_ShouldLog_FollowsThePlatform, "ShouldLog_FollowsThePlatform"); +} +//--------------------------------------------------------------------------- +TTest_ASWLog_SyslogLog::~TTest_ASWLog_SyslogLog() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::SetUp_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::SetUp_Test(ITestCase& /*testCase*/) +{ + SyslogLogFormatCount = 0; +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::TearDown_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::TearDown_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- + +// /////// Begin tests after this line /////////////////////// + +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::Test_GetInstance_ReturnsTheSameLogger() +{ + // Act + auto& first = ASWLog::TASWSyslogLog::GetInstance(); + auto& second = ASWLog::TASWSyslogLog::GetInstance(); + + // Assert + CheckSame(&first, &second, "GetInstance() should always return the same logger"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::Test_HandleCrash_SendsTheFormattedCrashLineOnly() +{ + // Arrange + TCapturingSyslogLog log; + CheckTrue(log.Initialize(MakeSyslogConfig()), "Initialize should succeed"); + + // Act: a crash in normal context, then the direct write a POSIX signal handler (or a busy lock) would use + ASWLog::HandleCrash("test crash"); + log.WriteCrashLine("[CRITICAL]: Crash: direct\n"); + + // Assert + AssertEquals(1, log.Messages.size(), "Only the formatted crash line should be sent"); + CheckEquals(UserCritical, log.Messages[0].Priority, "The crash line should be sent as LOG_CRIT"); + CheckEquals(std::string("[CRITICAL]: Crash: test crash"), log.Messages[0].Text, "The crash line should be sent without its line ending"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::Test_Initialize_OpensSyslogWithTheIdentAndFacility() +{ + // Arrange + TCapturingSyslogLog log; + const auto config = MakeSyslogConfig(); + auto appConfig = config; + appConfig.Syslog.Ident = "MyApp"; + appConfig.Syslog.Facility = ASWLog::SyslogFacility::Local3; + const std::string executableName = ASWLog::PathToUTF8String(ASWLog::GetExecutablePath().filename()); + + // Act + const bool initialized = log.Initialize(config); + const bool reconfigured = log.Reconfigure(appConfig); + const bool closed = log.Close(); + const bool opened = log.Open(); + + // Assert + CheckTrue(initialized && reconfigured && closed && opened, "Initialize, Reconfigure, Close and Open should succeed"); + CheckEmpty(config.Syslog.Ident, "The default ident should be empty (the executable's file name)"); + CheckEquals(ASWLog::SyslogFacility::User, config.Syslog.Facility, "The default facility should be User"); + AssertEquals(2, log.Opens.size(), "Initialize and Reconfigure should open syslog; Close and Open should not"); + CheckNotEmpty(executableName, "The executable's file name should be known"); + CheckEquals(executableName, log.Opens[0].Ident, "Without an ident, the executable's file name should be used"); + CheckEquals(ASWLog::SyslogFacility::User, log.Opens[0].Facility, "Initialize should pass the configured facility"); + CheckEquals(std::string("MyApp"), log.Opens[1].Ident, "Reconfigure should pass the new ident"); + CheckEquals(ASWLog::SyslogFacility::Local3, log.Opens[1].Facility, "Reconfigure should pass the new facility"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::Test_Linux_SendsToTheRealSyslog() +{ +#if defined(_WIN32) + Skip("Only Linux has a syslog"); +#else + // Arrange: the real openlog() and syslog() (each Reconfigure replaces the ident's copy). This writes three Debug + // lines to the system log; nothing here can read them back, so it only checks that the calls work. + ASWLog::TASWSyslogLog log; + auto config = MakeSyslogConfig(); + config.InitialMinimumLevel = ASWLog::Level::Debug; + config.Syslog.Ident = "ASWLogTests"; + auto otherConfig = config; + otherConfig.Syslog.Ident = "ASWLogTests_Other"; + otherConfig.Syslog.Facility = ASWLog::SyslogFacility::Local7; + + // Act + const bool initialized = log.Initialize(config); + log.LogDebug("ASWLog unit test: syslog smoke test (1 of 3)"); + const bool reconfigured = log.Reconfigure(otherConfig); + log.LogDebug("ASWLog unit test: syslog smoke test (2 of 3)"); + const bool reconfiguredBack = log.Reconfigure(config); + log.LogDebug("ASWLog unit test: syslog smoke test (3 of 3)"); + const bool flushed = log.Flush(); + + // Assert + CheckTrue(initialized, "Initialize should succeed"); + CheckTrue(reconfigured && reconfiguredBack, "Reconfigure should succeed"); + CheckTrue(flushed, "Flush should succeed while open"); + CheckTrue(log.ShouldLog(ASWLog::Level::Debug), "ShouldLog() should follow the minimum level on Linux"); +#endif +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::Test_Send_FollowsTheMultilineMode() +{ + // Arrange + TCapturingSyslogLog log; + auto indentConfig = MakeSyslogConfig(); + indentConfig.Line.Multiline = ASWLog::MultilineMode::Indent; + auto escapeConfig = MakeSyslogConfig(); + escapeConfig.Line.Multiline = ASWLog::MultilineMode::Escape; + CheckTrue(log.Initialize(MakeSyslogConfig()), "Initialize should succeed"); + + // Act + log.LogInfo("first\nsecond"); + CheckTrue(log.Reconfigure(indentConfig), "Reconfigure should succeed"); + log.LogInfo("first\nsecond"); + CheckTrue(log.Reconfigure(escapeConfig), "Reconfigure should succeed"); + log.LogInfo("first\r\nsecond"); + + // Assert + AssertEquals(3, log.Messages.size(), "Each entry should be one message"); + CheckEquals(std::string("[INFO]: first\nsecond"), log.Messages[0].Text, "Preserve should keep the line break"); + CheckEquals(std::string("[INFO]: first\n | second"), log.Messages[1].Text, "Indent should mark the next line"); + CheckEquals(std::string("[INFO]: first\\r\\nsecond"), log.Messages[2].Text, "Escape should write \\r and \\n"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::Test_Send_MapsEachLevelToAPriority() +{ + // Arrange + TCapturingSyslogLog log; + auto config = MakeSyslogConfig(); + config.InitialMinimumLevel = ASWLog::Level::Trace; + auto local3Config = config; + local3Config.Syslog.Facility = ASWLog::SyslogFacility::Local3; + auto daemonConfig = config; + daemonConfig.Syslog.Facility = ASWLog::SyslogFacility::Daemon; + CheckTrue(log.Initialize(config), "Initialize should succeed"); + + // Act + log.LogTrace("trace"); + log.LogDebug("debug"); + log.LogInfo("info"); + log.LogWarn("warn"); + log.LogError("error"); + log.LogCritical("critical"); + log.LogRaw(ASWLog::Level::Warn, "raw"); + CheckTrue(log.Reconfigure(local3Config), "Reconfigure should succeed"); + log.LogError("local3"); + CheckTrue(log.Reconfigure(daemonConfig), "Reconfigure should succeed"); + log.LogInfo("daemon"); + + // Assert + AssertEquals(9, log.Messages.size(), "Each entry should be one message"); + CheckEquals(UserDebug, log.Messages[0].Priority, "Trace should be LOG_DEBUG"); + CheckEquals(UserDebug, log.Messages[1].Priority, "Debug should be LOG_DEBUG"); + CheckEquals(UserInfo, log.Messages[2].Priority, "Info should be LOG_INFO"); + CheckEquals(UserWarning, log.Messages[3].Priority, "Warn should be LOG_WARNING"); + CheckEquals(UserError, log.Messages[4].Priority, "Error should be LOG_ERR"); + CheckEquals(UserCritical, log.Messages[5].Priority, "Critical should be LOG_CRIT"); + CheckEquals(UserWarning, log.Messages[6].Priority, "A raw entry should get its level's priority"); + CheckEquals((19 << 3) | 3, log.Messages[7].Priority, "The facility should be sent with each message (LOG_LOCAL3 | LOG_ERR)"); + CheckEquals((3 << 3) | 6, log.Messages[8].Priority, "The facility should be sent with each message (LOG_DAEMON | LOG_INFO)"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::Test_Send_StripsOnlyTheLineEnding() +{ + // Arrange + TCapturingSyslogLog log; + auto crlfConfig = MakeSyslogConfig(); + crlfConfig.Line.Ending = ASWLog::LineEnding::CRLF; + CheckTrue(log.Initialize(MakeSyslogConfig()), "Initialize should succeed"); + + // Act + log.LogInfo("hello"); + log.LogInfo("ends with a break\n"); + log.LogRaw(ASWLog::Level::Info, "raw text\n"); + CheckTrue(log.Reconfigure(crlfConfig), "Reconfigure should succeed"); + log.LogInfo("crlf"); + log.LogInfo("ends with LF\n"); + + // Assert + AssertEquals(5, log.Messages.size(), "Each entry should be one message"); + CheckEquals(std::string("[INFO]: hello"), log.Messages[0].Text, "The LF ending should be stripped"); + CheckEquals(std::string("[INFO]: ends with a break\n"), log.Messages[1].Text, "Only the line ending should be stripped, not the message's own break"); + CheckEquals(std::string("raw text\n"), log.Messages[2].Text, "A raw entry should be sent as is"); + CheckEquals(std::string("[INFO]: crlf"), log.Messages[3].Text, "The CRLF ending should be stripped"); + CheckEquals(std::string("[INFO]: ends with LF\n"), log.Messages[4].Text, "With CRLF endings, the message's own LF should stay"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_SyslogLog::Test_ShouldLog_FollowsThePlatform() +{ +#if defined(_WIN32) + // Arrange: a startup and a shutdown line too, each of which would be formatted + auto config = MakeSyslogConfig(); + const auto formatter = std::make_shared(); + config.Line.Formatter = formatter; + config.Startup.Banner = "banner"; + config.Shutdown.WriteLine = true; + ASWLog::TASWLogRecord criticalRecord; + criticalRecord.LogLevel = ASWLog::Level::Critical; + bool initialized = false; + bool shouldLog = true; + bool shouldLogRecord = true; + + // Act + { + ASWLog::TASWSyslogLog log; + initialized = log.Initialize(config); + shouldLog = log.ShouldLog(ASWLog::Level::Critical); + shouldLogRecord = log.ShouldLog(criticalRecord); + log.LogCriticalFmt("{}", TSyslogLogCountedValue{}); + log.LogCritical("not formatted"); + } + + // Assert + CheckTrue(initialized, "Initialize should succeed on every platform"); + CheckFalse(shouldLog, "Without a syslog, ShouldLog() should be false"); + CheckFalse(shouldLogRecord, "Without a syslog, ShouldLog(record) should be false"); + CheckEquals(0, SyslogLogFormatCount, "Without a syslog, no *Fmt arguments should be formatted"); + CheckEquals(0, formatter->FormatCount, "Without a syslog, no line (startup, entry or shutdown) should be formatted"); +#else + // Arrange: no startup or shutdown lines, and only checks, so nothing reaches the system log + ASWLog::TASWSyslogLog log; + ASWLog::TASWLogRecord infoRecord; + infoRecord.LogLevel = ASWLog::Level::Info; + + // Act + const bool initialized = log.Initialize(MakeSyslogConfig()); + log.LogDebugFmt("{}", TSyslogLogCountedValue{}); + + // Assert + CheckTrue(initialized, "Initialize should succeed on every platform"); + CheckEquals(0, SyslogLogFormatCount, "An entry below the minimum level should not be formatted"); + CheckTrue(log.ShouldLog(ASWLog::Level::Info), "On Linux, ShouldLog() should follow the minimum level (Info)"); + CheckFalse(log.ShouldLog(ASWLog::Level::Debug), "On Linux, ShouldLog() should follow the minimum level (Debug)"); + CheckTrue(log.ShouldLog(infoRecord), "On Linux, ShouldLog(record) should follow the minimum level"); +#endif +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +ASW_REGISTER_TEST_GROUP(ASWUnitTests::TTest_ASWLog_SyslogLog) diff --git a/tests/tests/Test_ASWLog_SyslogLog.h b/tests/tests/Test_ASWLog_SyslogLog.h new file mode 100644 index 0000000..17bfb65 --- /dev/null +++ b/tests/tests/Test_ASWLog_SyslogLog.h @@ -0,0 +1,63 @@ +/* ************************************************************************** +Test_ASWLog_SyslogLog.h +Author: Anthony S. West - ASW Software + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef Test_ASWLog_SyslogLogH +#define Test_ASWLog_SyslogLogH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_TestBase.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_SyslogLog +/////////////////////////////////////////////////////////////////////////// +class TTest_ASWLog_SyslogLog : public TTestGroupBase +{ +private: + typedef TTestGroupBase inherited; + +private: // Test methods + void Test_GetInstance_ReturnsTheSameLogger(); + void Test_HandleCrash_SendsTheFormattedCrashLineOnly(); + void Test_Initialize_OpensSyslogWithTheIdentAndFacility(); + void Test_Linux_SendsToTheRealSyslog(); + void Test_Send_FollowsTheMultilineMode(); + void Test_Send_MapsEachLevelToAPriority(); + void Test_Send_StripsOnlyTheLineEnding(); + void Test_ShouldLog_FollowsThePlatform(); + +public: + TTest_ASWLog_SyslogLog(); + ~TTest_ASWLog_SyslogLog() override; + + void SetUp_Group() override; + void SetUp_Test(ITestCase& testCase) override; + void TearDown_Group() override; + void TearDown_Test(ITestCase& testCase) override; +}; + +} // ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef Test_ASWLog_SyslogLogH diff --git a/tests/tests/Test_ASWLog_Types.cpp b/tests/tests/Test_ASWLog_Types.cpp index 066f175..d3950fd 100644 --- a/tests/tests/Test_ASWLog_Types.cpp +++ b/tests/tests/Test_ASWLog_Types.cpp @@ -83,6 +83,8 @@ TTest_ASWLog_Types::TTest_ASWLog_Types() RegisterTest(&TTest_ASWLog_Types::Test_PendingEntry_RemoveFieldRemovesItForThisEntry, "PendingEntry_RemoveFieldRemovesItForThisEntry"); RegisterTest(&TTest_ASWLog_Types::Test_PendingEntry_SetFieldReplacesOrAddsACopy, "PendingEntry_SetFieldReplacesOrAddsACopy"); RegisterTest(&TTest_ASWLog_Types::Test_PendingEntry_SetMessageOwnsTheText, "PendingEntry_SetMessageOwnsTheText"); + RegisterTest(&TTest_ASWLog_Types::Test_SyslogFacility_FromString, "SyslogFacility_FromString"); + RegisterTest(&TTest_ASWLog_Types::Test_SyslogFacility_ToString, "SyslogFacility_ToString"); RegisterTest(&TTest_ASWLog_Types::Test_TimePrecision_FromString, "TimePrecision_FromString"); RegisterTest(&TTest_ASWLog_Types::Test_TimePrecision_ToString, "TimePrecision_ToString"); RegisterTest(&TTest_ASWLog_Types::Test_TimeZone_FromString, "TimeZone_FromString"); @@ -184,7 +186,8 @@ void TTest_ASWLog_Types::Test_ErrorKind_ToString() CheckEquals(std::string("DELETE_FAILED"), std::string(ASWLog::ErrorKind_ToString(ASWLog::ErrorKind::DeleteFailed)), "DeleteFailed should stringify as DELETE_FAILED"); CheckEquals(std::string("EXCEPTION"), std::string(ASWLog::ErrorKind_ToString(ASWLog::ErrorKind::Exception)), "Exception should stringify as EXCEPTION"); CheckEquals(std::string("ENTRIES_DROPPED"), std::string(ASWLog::ErrorKind_ToString(ASWLog::ErrorKind::EntriesDropped)), "EntriesDropped should stringify as ENTRIES_DROPPED"); - CheckEquals(static_cast(ASWLog::ErrorKind::EntriesDropped) + 1, ASWLog::ErrorKindCount, "ErrorKindCount should count every kind (EntriesDropped is the last)"); + CheckEquals(std::string("INVALID_SETTING"), std::string(ASWLog::ErrorKind_ToString(ASWLog::ErrorKind::InvalidSetting)), "InvalidSetting should stringify as INVALID_SETTING"); + CheckEquals(static_cast(ASWLog::ErrorKind::InvalidSetting) + 1, ASWLog::ErrorKindCount, "ErrorKindCount should count every kind (InvalidSetting is the last)"); } //--------------------------------------------------------------------------- void TTest_ASWLog_Types::Test_FlushMode_FromString() @@ -503,6 +506,37 @@ void TTest_ASWLog_Types::Test_PendingEntry_SetMessageOwnsTheText() CheckEquals(std::string("Net"), std::string(entry.GetRecord().Category), "The category should be kept"); } //--------------------------------------------------------------------------- +void TTest_ASWLog_Types::Test_SyslogFacility_FromString() +{ + // Arrange + const auto user = ASWLog::SyslogFacility_FromString("USER"); + const auto daemon = ASWLog::SyslogFacility_FromString("daemon"); + const auto local0 = ASWLog::SyslogFacility_FromString("Local0"); + const auto local7 = ASWLog::SyslogFacility_FromString("LOCAL7"); + const auto local8 = ASWLog::SyslogFacility_FromString("LOCAL8"); + const auto kernel = ASWLog::SyslogFacility_FromString("KERN"); + + // Act & Assert + CheckTrue(user == ASWLog::SyslogFacility::User, "USER should map to User"); + CheckTrue(daemon == ASWLog::SyslogFacility::Daemon, "daemon should map to Daemon, ignoring case"); + CheckTrue(local0 == ASWLog::SyslogFacility::Local0, "Local0 should map to Local0, ignoring case"); + CheckTrue(local7 == ASWLog::SyslogFacility::Local7, "LOCAL7 should map to Local7"); + CheckFalse(local8.has_value(), "There is no LOCAL8"); + CheckFalse(kernel.has_value(), "A facility the logger doesn't offer should not parse"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Types::Test_SyslogFacility_ToString() +{ + // Act & Assert + CheckEquals(std::string("USER"), std::string(ASWLog::SyslogFacility_ToString(ASWLog::SyslogFacility::User)), "User should stringify as USER"); + CheckEquals(std::string("DAEMON"), std::string(ASWLog::SyslogFacility_ToString(ASWLog::SyslogFacility::Daemon)), "Daemon should stringify as DAEMON"); + CheckEquals(std::string("LOCAL0"), std::string(ASWLog::SyslogFacility_ToString(ASWLog::SyslogFacility::Local0)), "Local0 should stringify as LOCAL0"); + CheckEquals(std::string("LOCAL4"), std::string(ASWLog::SyslogFacility_ToString(ASWLog::SyslogFacility::Local4)), "Local4 should stringify as LOCAL4"); + CheckEquals(std::string("LOCAL7"), std::string(ASWLog::SyslogFacility_ToString(ASWLog::SyslogFacility::Local7)), "Local7 should stringify as LOCAL7"); + CheckEquals(8, static_cast(ASWLog::SyslogFacility::User) << 3, "User should be RFC 5424 facility 1 (LOG_USER = 8)"); + CheckEquals(184, static_cast(ASWLog::SyslogFacility::Local7) << 3, "Local7 should be RFC 5424 facility 23 (LOG_LOCAL7 = 184)"); +} +//--------------------------------------------------------------------------- void TTest_ASWLog_Types::Test_TimePrecision_FromString() { // Arrange diff --git a/tests/tests/Test_ASWLog_Types.h b/tests/tests/Test_ASWLog_Types.h index efbe2fd..ffdad6f 100644 --- a/tests/tests/Test_ASWLog_Types.h +++ b/tests/tests/Test_ASWLog_Types.h @@ -55,6 +55,8 @@ class TTest_ASWLog_Types : public TTestGroupBase void Test_PendingEntry_SetFieldReplacesOrAddsACopy(); void Test_PendingEntry_SetMessageOwnsTheText(); void Test_LogError_ToString(); + void Test_SyslogFacility_FromString(); + void Test_SyslogFacility_ToString(); void Test_TimePrecision_FromString(); void Test_TimePrecision_ToString(); void Test_TimeZone_FromString(); diff --git a/tests/tests/Test_ASWLog_Unicode.cpp b/tests/tests/Test_ASWLog_Unicode.cpp new file mode 100644 index 0000000..b8c3b39 --- /dev/null +++ b/tests/tests/Test_ASWLog_Unicode.cpp @@ -0,0 +1,752 @@ +/* ************************************************************************** +Test_ASWLog_Unicode.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "Test_ASWLog_Unicode.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include +#include +#include +#include +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Registry.h" +//--------------------------------------------------------------------------- +#include "ASWLog_Interface.h" +#include "ASWLog_MemoryLog.h" +#include "ASWLog_Unicode.h" +#include "ASWLog_Version.h" +//--------------------------------------------------------------------------- + +namespace +{ + +// A value whose wide formatting is counted in WideFormatCount, to check when the wide *Fmt methods format +struct TWideCountedValue +{ +}; + +int WideFormatCount = 0; + +} // namespace + +template<> +struct std::formatter +{ + constexpr std::wformat_parse_context::iterator parse(std::wformat_parse_context& context) + { + return context.begin(); + } + + std::wformat_context::iterator format(const TWideCountedValue& /*value*/, std::wformat_context& context) const + { + ++WideFormatCount; + return std::format_to(context.out(), L"counted"); + } +}; + +namespace ASWUnitTests +{ + +namespace +{ + +// An entry as TCountingLog received it +struct TReceivedEntry +{ + ASWLog::Level LogLevel = ASWLog::Level::Info; + std::string Message; + bool Raw = false; + bool Forced = false; + std::size_t FieldCount = 0; + std::uint_least32_t Line = 0; +}; + +// A minimal logger that counts the Write() calls (the entries a logging method passed on, used or not) and keeps a +// copy of each entry it would use, filtering like TASWLogBase: enabled, not Level::Off, and the minimum level unless +// forced +class TCountingLog final : public ASWLog::IASWLog +{ +private: + bool IsUsed(const ASWLog::TASWLogRecord& record) const noexcept + { + return Enabled && record.LogLevel != ASWLog::Level::Off && (record.Forced || record.LogLevel >= MinimumLevel); + } + +public: + ASWLog::Level MinimumLevel = ASWLog::Level::Info; + bool Enabled = true; + int WriteCount = 0; + std::vector Entries; + + std::string_view GetVersionStr() const noexcept override + { + return ASWLog::Version; + } + + std::string GetFullVersionStr() const override + { + return "TCountingLog"; + } + + std::shared_ptr GetConfig() const noexcept override + { + return nullptr; + } + + bool Initialize(const ASWLog::TASWLogConfig& /*config*/) noexcept override + { + return true; + } + + bool Reconfigure(const ASWLog::TASWLogConfig& /*config*/) noexcept override + { + return true; + } + + bool Open() noexcept override + { + return true; + } + + bool Close() noexcept override + { + return true; + } + + bool Flush() noexcept override + { + return true; + } + + bool IsOpen() const noexcept override + { + return true; + } + + void DumpBacktrace() noexcept override + { + } + + bool IsEnabled() const noexcept override + { + return Enabled; + } + + void SetEnabled(bool enabled) noexcept override + { + Enabled = enabled; + } + + ASWLog::Level GetMinimumLevel() const noexcept override + { + return MinimumLevel; + } + + void SetMinimumLevel(ASWLog::Level level) noexcept override + { + MinimumLevel = level; + } + + bool ShouldLog(ASWLog::Level level) const noexcept override + { + ASWLog::TASWLogRecord record; + record.LogLevel = level; + + return IsUsed(record); + } + + bool ShouldLog(const ASWLog::TASWLogRecord& record) const noexcept override + { + return IsUsed(record); + } + + void Write(const ASWLog::TASWLogRecord& record) noexcept override + { + ++WriteCount; + if (!IsUsed(record)) + return; + + TReceivedEntry entry; + entry.LogLevel = record.LogLevel; + entry.Message = std::string(record.Message); + entry.Raw = record.Raw; + entry.Forced = record.Forced; + entry.FieldCount = record.GetOwnFields().size(); + entry.Line = record.Location.line(); + Entries.push_back(entry); + } +}; + +// What a TReceivedEntry should hold +struct TExpectedEntry +{ + ASWLog::Level LogLevel; + std::string Message; + bool Raw; + bool Forced; + std::size_t FieldCount; +}; + +// A code point and its UTF-8 bytes +struct TCodePoint +{ + char32_t Value; + std::string UTF8; +}; + +// How the entries 'log' received differ from 'expected', in order: empty if they don't, else the first difference +std::string DescribeEntryMismatch(const TCountingLog& log, const std::vector& expected) +{ + if (log.Entries.size() != expected.size()) + return std::format("{} entries instead of {}", log.Entries.size(), expected.size()); + + for (std::size_t index = 0; index < expected.size(); ++index) + { + const auto& entry = log.Entries[index]; + const auto& item = expected[index]; + if (entry.LogLevel != item.LogLevel || entry.Message != item.Message || entry.Raw != item.Raw || + entry.Forced != item.Forced || entry.FieldCount != item.FieldCount) + { + return std::format("entry {}: {} '{}' raw={} forced={} fields={} instead of {} '{}' raw={} forced={} fields={}", index, + ASWLog::Level_ToString(entry.LogLevel), entry.Message, entry.Raw, entry.Forced, entry.FieldCount, + ASWLog::Level_ToString(item.LogLevel), item.Message, item.Raw, item.Forced, item.FieldCount); + } + } + + return {}; +} + +// A config with no startup or shutdown lines and only the level in each line, e.g. "[INFO]: message" +ASWLog::TASWLogConfig MakeQuietConfig() +{ + ASWLog::TASWLogConfig config; + config.Line.ShowTimestamp = false; + config.Line.ShowProcessId = false; + config.Line.ShowThreadId = false; + config.Shutdown.WriteLine = false; + config.Startup.WriteApplicationInfo = false; + config.Startup.WriteDriveInfo = false; + config.Startup.WriteMemoryUsage = false; + config.Startup.WriteOSInfo = false; + config.Startup.WriteSystemMemoryInfo = false; + config.Startup.WriteTimeInfo = false; + return config; +} + +// The bytes of 'text' in hex, e.g. "E2 82", for a check's message +std::string ToHex(std::string_view text) +{ + std::string hex; + for (const char character : text) + hex += std::format("{}{:02X}", hex.empty() ? "" : " ", static_cast(character)); + + return hex; +} + +// 'value' (a Unicode scalar value) as UTF-16, written out here rather than with the code the tests check +std::u16string ToUTF16(char32_t value) +{ + if (value < 0x10000) + return std::u16string(1, static_cast(value)); + + const char32_t offset = value - 0x10000; + + return std::u16string{ static_cast(0xD800 + (offset >> 10)), static_cast(0xDC00 + (offset & 0x3FF)) }; +} + +// 'value' as a wide string: UTF-16 where wchar_t has 16 bits, else the value itself +std::wstring ToWideString(char32_t value) +{ + if constexpr (sizeof(wchar_t) == sizeof(char16_t)) + { + const auto utf16 = ToUTF16(value); + + return std::wstring(utf16.begin(), utf16.end()); + } + else + { + return std::wstring(1, static_cast(value)); + } +} + +} // namespace + +//--------------------------------------------------------------------------- + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_Unicode +/////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TTest_ASWLog_Unicode::TTest_ASWLog_Unicode() + : inherited("ASWLog_Unicode_Tests") +{ + RegisterTest(&TTest_ASWLog_Unicode::Test_Formatters_ConvertTheWrappedText, "Formatters_ConvertTheWrappedText"); + RegisterTest(&TTest_ASWLog_Unicode::Test_LogFmt_NarrowFormatTakesWideArguments, "LogFmt_NarrowFormatTakesWideArguments"); + RegisterTest(&TTest_ASWLog_Unicode::Test_LogFmt_WideFormatSkipsFilteredEntries, "LogFmt_WideFormatSkipsFilteredEntries"); + RegisterTest(&TTest_ASWLog_Unicode::Test_LogFmt_WideFormatWritesUTF8, "LogFmt_WideFormatWritesUTF8"); + RegisterTest(&TTest_ASWLog_Unicode::Test_LogWide_ConvertsOnlyEntriesThatAreUsed, "LogWide_ConvertsOnlyEntriesThatAreUsed"); + RegisterTest(&TTest_ASWLog_Unicode::Test_LogWide_EachMethodWritesUTF8, "LogWide_EachMethodWritesUTF8"); + RegisterTest(&TTest_ASWLog_Unicode::Test_LogWide_WritesThroughARealLogger, "LogWide_WritesThroughARealLogger"); + RegisterTest(&TTest_ASWLog_Unicode::Test_RoundTrip_KeepsEveryCharacter, "RoundTrip_KeepsEveryCharacter"); + RegisterTest(&TTest_ASWLog_Unicode::Test_UTF8ToUTF16_ReplacesInvalidSequences, "UTF8ToUTF16_ReplacesInvalidSequences"); + RegisterTest(&TTest_ASWLog_Unicode::Test_UTF8ToWide_FollowsTheWidthOfWchar, "UTF8ToWide_FollowsTheWidthOfWchar"); + RegisterTest(&TTest_ASWLog_Unicode::Test_WideText_TakesEachKindOfText, "WideText_TakesEachKindOfText"); + RegisterTest(&TTest_ASWLog_Unicode::Test_WideToUTF8_ReplacesWhatIsNotACharacter, "WideToUTF8_ReplacesWhatIsNotACharacter"); +} +//--------------------------------------------------------------------------- +TTest_ASWLog_Unicode::~TTest_ASWLog_Unicode() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::SetUp_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::SetUp_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::TearDown_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::TearDown_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- + +// /////// Begin tests after this line /////////////////////// + +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_Formatters_ConvertTheWrappedText() +{ + // Arrange + const std::wstring wideEmoji = ToWideString(0x1F600); + + // Act + const auto narrowWide = std::format("[{}]", ASWLog::Wide(L"a\x03BB")); + const auto narrowUTF16 = std::format("[{:>5}]", ASWLog::Wide(u"ab")); + const auto narrowUTF8 = std::format("[{:<4}]", ASWLog::UTF8("ab")); + const auto wideUTF8 = std::format(L"[{}]", ASWLog::UTF8("a\xCE\xBB")); + const auto wideUTF8Padded = std::format(L"[{:<4}]", ASWLog::UTF8("ab")); + const auto wideUTF16 = std::format(L"[{}]", ASWLog::Wide(u"\xD83D\xDE00")); + const auto wideWide = std::format(L"[{}]", ASWLog::Wide(L"x")); + + // Assert + CheckEquals(std::string("[a\xCE\xBB]"), narrowWide, "Wide() should format wchar_t text as UTF-8"); + CheckEquals(std::string("[ ab]"), narrowUTF16, "Wide() should format char16_t text, with a width"); + CheckEquals(std::string("[ab ]"), narrowUTF8, "UTF8() should format as it is in a narrow format"); + CheckTrue(wideUTF8 == L"[a\x03BB]", "UTF8() should format UTF-8 as wide text in a wide format"); + CheckTrue(wideUTF8Padded == L"[ab ]", "UTF8() should take a width in a wide format"); + CheckTrue(wideUTF16 == L"[" + wideEmoji + L"]", "Wide() should format char16_t text in a wide format"); + CheckTrue(wideWide == L"[x]", "Wide() should format wchar_t text as it is in a wide format"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_LogFmt_NarrowFormatTakesWideArguments() +{ + // Arrange + TCountingLog log; + + // Act + log.LogInfoFmt("{} and {}", ASWLog::Wide(L"\x03BB"), ASWLog::Wide(u"\xD83D\xDE00")); + log.LogWarnFmt("[{:>3}]", ASWLog::Wide(std::wstring(L"a"))); + + // Assert + CheckEmpty(DescribeEntryMismatch(log, { + { ASWLog::Level::Info, "\xCE\xBB and \xF0\x9F\x98\x80", false, false, 0 }, + { ASWLog::Level::Warn, "[ a]", false, false, 0 }, + }), "Each call should pass on one entry with its level, flags, fields and UTF-8 message"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_LogFmt_WideFormatSkipsFilteredEntries() +{ + // Arrange + TCountingLog log; + log.MinimumLevel = ASWLog::Level::Warn; + WideFormatCount = 0; + + // Act + log.LogInfoFmt(L"{}", TWideCountedValue{}); + log.LogFmt(ASWLog::Level::Debug, L"{}", TWideCountedValue{}); + log.LogRawFmt(ASWLog::Level::Info, L"{}", TWideCountedValue{}); + log.LogDebugFmt({ { "key", 1 } }, L"{}", TWideCountedValue{}); + log.LogForceFmt(ASWLog::Level::Off, L"{}", TWideCountedValue{}); + const int filteredCount = WideFormatCount; + + log.LogWarnFmt(L"{}", TWideCountedValue{}); + log.LogForceFmt(ASWLog::Level::Debug, L"{}", TWideCountedValue{}); + log.LogForceRawFmt(ASWLog::Level::Trace, { { "key", 1 } }, L"{}", TWideCountedValue{}); + const int writtenCount = WideFormatCount; + + log.Enabled = false; + log.LogForceFmt(ASWLog::Level::Error, L"{}", TWideCountedValue{}); + const int disabledCount = WideFormatCount; + + // Assert + CheckEquals(0, filteredCount, "An entry that wouldn't be written should not be formatted"); + CheckEquals(3, writtenCount, "An entry that is written should be formatted once"); + CheckEquals(3, disabledCount, "A forced entry should not be formatted while the logger is disabled"); + CheckEquals(3, log.Entries.size(), "Only the used entries should be kept"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_LogFmt_WideFormatWritesUTF8() +{ + // Arrange + const std::wstring runtimeFormat = L"{} {}"; + TCountingLog log; + log.MinimumLevel = ASWLog::Level::Trace; + + // Act: each method, arguments of each kind + const auto line = std::source_location::current().line(); + log.LogFmt(ASWLog::Level::Warn, L"fmt {} {}", 1, L"\x03BB"); + log.LogRawFmt(ASWLog::Level::Info, L"raw {}", ASWLog::UTF8("\xE6\x97\xA5")); + log.LogForceFmt(ASWLog::Level::Debug, L"force {}", ASWLog::Wide(u"\x03BB")); + log.LogForceRawFmt(ASWLog::Level::Trace, L"forceraw {:.1f}", 2.25); + log.LogTraceFmt(L"trace {}", 1); + log.LogDebugFmt(L"debug {}", 2); + log.LogInfoFmt(L"info {}", 3); + log.LogWarnFmt(L"warn {}", 4); + log.LogErrorFmt(L"error {}", 5); + log.LogCriticalFmt(L"critical {}", 6); + + log.LogFmt(ASWLog::Level::Warn, { { "k", 1 } }, L"fmt {}", 1); + log.LogRawFmt(ASWLog::Level::Info, { { "k", 1 } }, L"raw {}", 2); + log.LogForceFmt(ASWLog::Level::Debug, { { "k", 1 } }, L"force {}", 3); + log.LogForceRawFmt(ASWLog::Level::Trace, { { "k", 1 } }, L"forceraw {}", 4); + log.LogTraceFmt({ { "k", 1 } }, L"trace {}", 5); + log.LogDebugFmt({ { "k", 1 } }, L"debug {}", 6); + log.LogInfoFmt({ { "k", 1 } }, L"info {}", 7); + log.LogWarnFmt({ { "k", 1 }, { "j", 2 } }, L"warn {}", 8); + log.LogErrorFmt({ { "k", 1 } }, L"error {}", 9); + log.LogCriticalFmt({ { "k", 1 } }, L"critical {}", 10); + + log.LogInfoFmt(ASWLog::RuntimeFormat(runtimeFormat), 1, L"\x03BB"); + + // Assert + CheckEmpty(DescribeEntryMismatch(log, { + { ASWLog::Level::Warn, "fmt 1 \xCE\xBB", false, false, 0 }, + { ASWLog::Level::Info, "raw \xE6\x97\xA5", true, false, 0 }, + { ASWLog::Level::Debug, "force \xCE\xBB", false, true, 0 }, + { ASWLog::Level::Trace, "forceraw 2.2", true, true, 0 }, + { ASWLog::Level::Trace, "trace 1", false, false, 0 }, + { ASWLog::Level::Debug, "debug 2", false, false, 0 }, + { ASWLog::Level::Info, "info 3", false, false, 0 }, + { ASWLog::Level::Warn, "warn 4", false, false, 0 }, + { ASWLog::Level::Error, "error 5", false, false, 0 }, + { ASWLog::Level::Critical, "critical 6", false, false, 0 }, + { ASWLog::Level::Warn, "fmt 1", false, false, 1 }, + { ASWLog::Level::Info, "raw 2", true, false, 1 }, + { ASWLog::Level::Debug, "force 3", false, true, 1 }, + { ASWLog::Level::Trace, "forceraw 4", true, true, 1 }, + { ASWLog::Level::Trace, "trace 5", false, false, 1 }, + { ASWLog::Level::Debug, "debug 6", false, false, 1 }, + { ASWLog::Level::Info, "info 7", false, false, 1 }, + { ASWLog::Level::Warn, "warn 8", false, false, 2 }, + { ASWLog::Level::Error, "error 9", false, false, 1 }, + { ASWLog::Level::Critical, "critical 10", false, false, 1 }, + { ASWLog::Level::Info, "1 \xCE\xBB", false, false, 0 }, + }), "Each call should pass on one entry with its level, flags, fields and UTF-8 message"); + + if (!log.Entries.empty()) + CheckEquals(line + 1, log.Entries[0].Line, "The entry should have the caller's source line"); + + // Act: a runtime format string with too few arguments + log.LogInfoFmt(ASWLog::RuntimeFormat(runtimeFormat), 1); + + // Assert + CheckEquals(22, log.Entries.size(), "The mismatched entry should still be written"); + if (log.Entries.size() == 22) + { + CheckStartsWith(log.Entries[21].Message, std::string("[ASWLog format error: "), "A mismatched runtime format should be described"); + CheckEndsWith(log.Entries[21].Message, std::string("] {} {}"), "The description should end with the format string"); + } +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_LogWide_ConvertsOnlyEntriesThatAreUsed() +{ + // Arrange + TCountingLog log; + log.MinimumLevel = ASWLog::Level::Warn; + + // Act: entries that wouldn't be used, then entries that would + log.LogInfo(L"info"); + log.LogDebug(u"debug", { { "key", 1 } }); + log.Log(ASWLog::Level::Info, L"info"); + log.LogRaw(ASWLog::Level::Trace, u"trace"); + log.LogForce(ASWLog::Level::Off, L"off"); + const int filteredWriteCount = log.WriteCount; + + log.LogWarn(L"warn"); + log.LogForce(ASWLog::Level::Debug, u"forced"); + const int usedWriteCount = log.WriteCount; + + log.Enabled = false; + log.LogForceRaw(ASWLog::Level::Error, L"disabled"); + const int disabledWriteCount = log.WriteCount; + + // Assert + CheckEquals(0, filteredWriteCount, "An entry that wouldn't be used should not be passed on (nor converted)"); + CheckEquals(2, usedWriteCount, "An entry that would be used should be passed on"); + CheckEquals(2, disabledWriteCount, "A forced entry should not be passed on while the logger is disabled"); + CheckEquals(2, log.Entries.size(), "The used entries should be kept"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_LogWide_EachMethodWritesUTF8() +{ + // Arrange + TCountingLog log; + log.MinimumLevel = ASWLog::Level::Trace; + + // Act: each method, with wchar_t and char16_t text of each kind + const auto line = std::source_location::current().line(); + log.Log(ASWLog::Level::Warn, L"log \x03BB"); + log.LogRaw(ASWLog::Level::Info, u"raw \x03BB"); + log.LogForce(ASWLog::Level::Debug, std::wstring(L"force")); + log.LogForceRaw(ASWLog::Level::Trace, std::u16string(u"forceraw")); + log.LogTrace(L"trace"); + log.LogDebug(u"debug"); + log.LogInfo(std::wstring_view(L"info")); + log.LogWarn(std::u16string_view(u"warn")); + log.LogError(std::wstring(L"error ") + ToWideString(0x1F600)); // A surrogate pair only where wchar_t has 16 bits + log.LogCritical(u"critical \xD83D\xDE00"); + + const auto fieldsLine = std::source_location::current().line(); + log.Log(ASWLog::Level::Warn, L"log", { { "k", 1 } }); + log.LogRaw(ASWLog::Level::Info, u"raw", { { "k", 1 } }); + log.LogForce(ASWLog::Level::Debug, L"force", { { "k", 1 } }); + log.LogForceRaw(ASWLog::Level::Trace, u"forceraw", { { "k", 1 } }); + log.LogTrace(L"trace", { { "k", 1 } }); + log.LogDebug(u"debug", { { "k", 1 } }); + log.LogInfo(L"info", { { "k", 1 }, { "j", 2 } }); + log.LogWarn(u"warn", { { "k", 1 } }); + log.LogError(L"error", { { "k", 1 } }); + log.LogCritical(u"critical", { { "k", 1 } }); + + // Assert + CheckEmpty(DescribeEntryMismatch(log, { + { ASWLog::Level::Warn, "log \xCE\xBB", false, false, 0 }, + { ASWLog::Level::Info, "raw \xCE\xBB", true, false, 0 }, + { ASWLog::Level::Debug, "force", false, true, 0 }, + { ASWLog::Level::Trace, "forceraw", true, true, 0 }, + { ASWLog::Level::Trace, "trace", false, false, 0 }, + { ASWLog::Level::Debug, "debug", false, false, 0 }, + { ASWLog::Level::Info, "info", false, false, 0 }, + { ASWLog::Level::Warn, "warn", false, false, 0 }, + { ASWLog::Level::Error, "error \xF0\x9F\x98\x80", false, false, 0 }, + { ASWLog::Level::Critical, "critical \xF0\x9F\x98\x80", false, false, 0 }, + { ASWLog::Level::Warn, "log", false, false, 1 }, + { ASWLog::Level::Info, "raw", true, false, 1 }, + { ASWLog::Level::Debug, "force", false, true, 1 }, + { ASWLog::Level::Trace, "forceraw", true, true, 1 }, + { ASWLog::Level::Trace, "trace", false, false, 1 }, + { ASWLog::Level::Debug, "debug", false, false, 1 }, + { ASWLog::Level::Info, "info", false, false, 2 }, + { ASWLog::Level::Warn, "warn", false, false, 1 }, + { ASWLog::Level::Error, "error", false, false, 1 }, + { ASWLog::Level::Critical, "critical", false, false, 1 }, + }), "Each call should pass on one entry with its level, flags, fields and UTF-8 message"); + + if (log.Entries.size() == 20) + { + CheckEquals(line + 1, log.Entries[0].Line, "An entry should have the caller's source line"); + CheckEquals(fieldsLine + 1, log.Entries[10].Line, "An entry with fields should have the caller's source line"); + } +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_LogWide_WritesThroughARealLogger() +{ + // Arrange + ASWLog::TASWMemoryLog log; + log.Initialize(MakeQuietConfig()); + + // Act + log.LogInfo(L"Opened \x03BB.txt"); + log.LogWarnFmt(L"{} of {}", 3, ASWLog::UTF8("\xE6\x97\xA5")); + log.LogErrorFmt("{} failed", ASWLog::Wide(u"\x03BB")); + log.LogDebug(L"filtered"); + const auto lines = log.GetLines(); + + // Assert + const std::vector expected{ "[INFO]: Opened \xCE\xBB.txt", "[WARN]: 3 of \xE6\x97\xA5", "[ERROR]: \xCE\xBB failed" }; + CheckEquals(expected.size(), lines.size(), "Each entry at or above Info should be written"); + for (std::size_t index = 0; index < expected.size() && index < lines.size(); ++index) + CheckEquals(expected[index], lines[index], "The line should hold the message in UTF-8"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_RoundTrip_KeepsEveryCharacter() +{ + // Arrange: the first and last characters of each UTF-8 length, around the surrogates, and the largest + const std::vector codePoints{ + { 0x0000, std::string(1, '\0') }, { 0x007F, "\x7F" }, { 0x0080, "\xC2\x80" }, { 0x07FF, "\xDF\xBF" }, + { 0x0800, "\xE0\xA0\x80" }, { 0xD7FF, "\xED\x9F\xBF" }, { 0xE000, "\xEE\x80\x80" }, { 0xFFFD, "\xEF\xBF\xBD" }, + { 0xFFFF, "\xEF\xBF\xBF" }, { 0x10000, "\xF0\x90\x80\x80" }, { 0x10FFFF, "\xF4\x8F\xBF\xBF" } + }; + + for (const auto& item : codePoints) + { + // Act + const std::u16string utf16 = ToUTF16(item.Value); + const std::wstring wide = ToWideString(item.Value); + + const auto fromUTF16 = ASWLog::WideToUTF8(utf16); + const auto fromWide = ASWLog::WideToUTF8(wide); + const auto toUTF16 = ASWLog::UTF8ToUTF16(item.UTF8); + const auto toWide = ASWLog::UTF8ToWide(item.UTF8); + + // Assert + const std::string what = ": " + ToHex(item.UTF8); + CheckEquals(item.UTF8, fromUTF16, "UTF-16 should become UTF-8" + what); + CheckEquals(item.UTF8, fromWide, "A wide string should become UTF-8" + what); + CheckTrue(toUTF16 == utf16, "UTF-8 should become UTF-16" + what); + CheckTrue(toWide == wide, "UTF-8 should become a wide string" + what); + } +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_UTF8ToUTF16_ReplacesInvalidSequences() +{ + // Arrange: valid text, then invalid sequences, each replaced by one U+FFFD per longest start of a valid sequence + struct TCase + { + std::string Text; + std::u16string Expected; + }; + + const std::vector cases{ + { "", u"" }, + { "a\xCE\xBB" "\xE6\x97\xA5" "\xF0\x9F\x98\x80", u"a\x03BB\x65E5\xD83D\xDE00" }, + { "x\xFFy", u"x\xFFFDy" }, // Not a lead byte + { "\x80x", u"\xFFFDx" }, // A lone continuation byte + { "\xC0\xAF", u"\xFFFD\xFFFD" }, // Overlong 2-byte form + { "\xE0\x80\x80", u"\xFFFD\xFFFD\xFFFD" }, // Overlong 3-byte form + { "\xED\xA0\x80", u"\xFFFD\xFFFD\xFFFD" }, // A surrogate + { "\xF4\x90\x80\x80", u"\xFFFD\xFFFD\xFFFD\xFFFD" }, // Above U+10FFFF + { "\xE6\x97" "x", u"\xFFFDx" }, // Cut short by another character + { "a\xF0\x9F\x98", u"a\xFFFD" }, // Cut short by the end + }; + + for (const auto& item : cases) + { + // Act + const auto result = ASWLog::UTF8ToUTF16(item.Text); + + // Assert + CheckTrue(result == item.Expected, "UTF-8 should become UTF-16, invalid sequences U+FFFD: " + ToHex(item.Text)); + } +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_UTF8ToWide_FollowsTheWidthOfWchar() +{ + // Arrange + const std::wstring expected = std::wstring(L"a") + ToWideString(0x1F600) + ToWideString(0xFFFD); + + // Act + const auto result = ASWLog::UTF8ToWide("a\xF0\x9F\x98\x80\xFF"); + + // Assert + CheckTrue(result == expected, "UTF-8 should become UTF-16 where wchar_t has 16 bits, else UTF-32"); + CheckEquals(sizeof(wchar_t) == sizeof(char16_t) ? 4 : 3, result.size(), "A character above U+FFFF should be two " + "wchar_t where it has 16 bits, else one"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_WideText_TakesEachKindOfText() +{ + // Arrange + const auto toUTF8 = [](ASWLog::TASWWideText text) + { + return text.ToUTF8(); + }; + const wchar_t* nullWide = nullptr; + const char16_t* nullUTF16 = nullptr; + const std::wstring wideString = L"wide \x03BB"; + const std::u16string utf16String = u"utf16 \x03BB"; + + // Act & Assert: made implicitly from each kind of text + CheckEquals(std::string("a\xCE\xBB"), toUTF8(L"a\x03BB"), "A wchar_t literal should be taken"); + CheckEquals(std::string("a\xCE\xBB"), toUTF8(u"a\x03BB"), "A char16_t literal should be taken"); + CheckEquals(std::string("wide \xCE\xBB"), toUTF8(wideString), "A std::wstring should be taken"); + CheckEquals(std::string("utf16 \xCE\xBB"), toUTF8(utf16String), "A std::u16string should be taken"); + CheckEquals(std::string("wide"), toUTF8(std::wstring_view(wideString).substr(0, 4)), "A std::wstring_view should be taken"); + CheckEquals(std::string("utf16"), toUTF8(std::u16string_view(utf16String).substr(0, 5)), "A std::u16string_view should be taken"); + CheckEquals(std::string(), toUTF8(nullWide), "A null wchar_t pointer should be empty text"); + CheckEquals(std::string(), toUTF8(nullUTF16), "A null char16_t pointer should be empty text"); + + // Act & Assert: as a wide string + CheckTrue(ASWLog::Wide(u"\xD83D\xDE00").ToWide() == ToWideString(0x1F600), "char16_t text should become a wide string"); + CheckTrue(ASWLog::Wide(L"x\x03BB").ToWide() == L"x\x03BB", "wchar_t text should stay as it is"); + const std::wstring unpaired = sizeof(wchar_t) == sizeof(char16_t) ? std::wstring(1, static_cast(0xD83D)) : + ToWideString(0xFFFD); + CheckTrue(ASWLog::Wide(u"\xD83D").ToWide() == unpaired, + "An unpaired surrogate should stay where wchar_t has 16 bits, and become U+FFFD where it has 32"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_Unicode::Test_WideToUTF8_ReplacesWhatIsNotACharacter() +{ + // Arrange: UTF-16 with unpaired surrogates + struct TCase + { + std::u16string Text; + std::string Expected; + }; + + const std::vector cases{ + { u"", "" }, + { u"a\x03BB\xD83D\xDE00", "a\xCE\xBB\xF0\x9F\x98\x80" }, + { u"x\xD83D", "x\xEF\xBF\xBD" }, // A high surrogate at the end + { u"\xDE00y", "\xEF\xBF\xBDy" }, // A lone low surrogate + { u"\xD83Dz", "\xEF\xBF\xBDz" }, // A high surrogate, then no low one + { u"\xD83D\xD83D\xDE00", "\xEF\xBF\xBD\xF0\x9F\x98\x80" }, // Two high surrogates, then a low one + }; + + // Act & Assert: wide text is UTF-16 too where wchar_t has 16 bits + for (const auto& item : cases) + { + CheckEquals(item.Expected, ASWLog::WideToUTF8(item.Text), "UTF-16 should become UTF-8: " + ToHex(item.Expected)); + if constexpr (sizeof(wchar_t) == sizeof(char16_t)) + CheckEquals(item.Expected, ASWLog::WideToUTF8(std::wstring(item.Text.begin(), item.Text.end())), + "16-bit wide text should become UTF-8 as UTF-16 does: " + ToHex(item.Expected)); + } + + // Arrange: values that aren't characters, which a 32-bit wchar_t holds one each + if constexpr (sizeof(wchar_t) != sizeof(char16_t)) + { + const std::uint32_t surrogate = 0xD800; + const std::uint32_t tooLarge = 0x110000; + const std::wstring invalid{ static_cast(surrogate), static_cast(tooLarge), static_cast(0x1F600) }; + + // Act & Assert + CheckEquals(std::string("\xEF\xBF\xBD\xEF\xBF\xBD\xF0\x9F\x98\x80"), ASWLog::WideToUTF8(invalid), + "A surrogate or a value above U+10FFFF should become U+FFFD"); + } +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +ASW_REGISTER_TEST_GROUP(ASWUnitTests::TTest_ASWLog_Unicode) diff --git a/tests/tests/Test_ASWLog_Unicode.h b/tests/tests/Test_ASWLog_Unicode.h new file mode 100644 index 0000000..d9f4703 --- /dev/null +++ b/tests/tests/Test_ASWLog_Unicode.h @@ -0,0 +1,67 @@ +/* ************************************************************************** +Test_ASWLog_Unicode.h +Author: Anthony S. West - ASW Software + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef Test_ASWLog_UnicodeH +#define Test_ASWLog_UnicodeH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_TestBase.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_Unicode +/////////////////////////////////////////////////////////////////////////// +class TTest_ASWLog_Unicode : public TTestGroupBase +{ +private: + typedef TTestGroupBase inherited; + +private: // Test methods + void Test_Formatters_ConvertTheWrappedText(); + void Test_LogFmt_NarrowFormatTakesWideArguments(); + void Test_LogFmt_WideFormatSkipsFilteredEntries(); + void Test_LogFmt_WideFormatWritesUTF8(); + void Test_LogWide_ConvertsOnlyEntriesThatAreUsed(); + void Test_LogWide_EachMethodWritesUTF8(); + void Test_LogWide_WritesThroughARealLogger(); + void Test_RoundTrip_KeepsEveryCharacter(); + void Test_UTF8ToUTF16_ReplacesInvalidSequences(); + void Test_UTF8ToWide_FollowsTheWidthOfWchar(); + void Test_WideText_TakesEachKindOfText(); + void Test_WideToUTF8_ReplacesWhatIsNotACharacter(); + +public: + TTest_ASWLog_Unicode(); + ~TTest_ASWLog_Unicode() override; + + void SetUp_Group() override; + void SetUp_Test(ITestCase& testCase) override; + void TearDown_Group() override; + void TearDown_Test(ITestCase& testCase) override; +}; + +} // ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef Test_ASWLog_UnicodeH diff --git a/tests/tests/Test_ASWLog_Utils.cpp b/tests/tests/Test_ASWLog_Utils.cpp index ca03a37..b541ce5 100644 --- a/tests/tests/Test_ASWLog_Utils.cpp +++ b/tests/tests/Test_ASWLog_Utils.cpp @@ -48,6 +48,7 @@ limitations under the License. #include "ASWUnitTests_Registry.h" //--------------------------------------------------------------------------- #include "UT_Helper_DateTime.h" +#include "UT_Helper_Environment.h" //--------------------------------------------------------------------------- namespace ASWUnitTests @@ -107,6 +108,7 @@ TTest_ASWLog_Utils::TTest_ASWLog_Utils() RegisterTest(&TTest_ASWLog_Utils::Test_ApplyMultilineMode_FindsLineBreaksAnywhereInALongText, "ApplyMultilineMode_FindsLineBreaksAnywhereInALongText"); RegisterTest(&TTest_ASWLog_Utils::Test_ApplyMultilineMode_IndentMarksEachLineAfterTheFirst, "ApplyMultilineMode_IndentMarksEachLineAfterTheFirst"); RegisterTest(&TTest_ASWLog_Utils::Test_ApplyMultilineMode_LeavesOtherLinesAsTheyAre, "ApplyMultilineMode_LeavesOtherLinesAsTheyAre"); + RegisterTest(&TTest_ASWLog_Utils::Test_EqualsIgnoringCase_IgnoresOnlyASCIICase, "EqualsIgnoringCase_IgnoresOnlyASCIICase"); RegisterTest(&TTest_ASWLog_Utils::Test_GenerateLogFileName_ContainsExpectedFields, "GenerateLogFileName_ContainsExpectedFields"); RegisterTest(&TTest_ASWLog_Utils::Test_GenerateLogFileName_PrefixAndPostfixAreOptional, "GenerateLogFileName_PrefixAndPostfixAreOptional"); RegisterTest(&TTest_ASWLog_Utils::Test_GetCurrentOSProcessId_MatchesOS, "GetCurrentOSProcessId_MatchesOS"); @@ -121,6 +123,7 @@ TTest_ASWLog_Utils::TTest_ASWLog_Utils() RegisterTest(&TTest_ASWLog_Utils::Test_JSON_AppendString_KeepsValidUTF8, "JSON_AppendString_KeepsValidUTF8"); RegisterTest(&TTest_ASWLog_Utils::Test_JSON_AppendString_ReplacesInvalidUTF8, "JSON_AppendString_ReplacesInvalidUTF8"); RegisterTest(&TTest_ASWLog_Utils::Test_MatchesWildcard_Patterns, "MatchesWildcard_Patterns"); + RegisterTest(&TTest_ASWLog_Utils::Test_ReadEnvironmentVariable_ReadsUTF8Values, "ReadEnvironmentVariable_ReadsUTF8Values"); RegisterTest(&TTest_ASWLog_Utils::Test_RenameWithoutReplacing_KeepsExistingTarget, "RenameWithoutReplacing_KeepsExistingTarget"); RegisterTest(&TTest_ASWLog_Utils::Test_Time_GetUTCOffsetMinutes_FollowsDaylightSavingTime, "Time_GetUTCOffsetMinutes_FollowsDaylightSavingTime"); RegisterTest(&TTest_ASWLog_Utils::Test_Time_ToDateString, "Time_ToDateString"); @@ -130,6 +133,7 @@ TTest_ASWLog_Utils::TTest_ASWLog_Utils() RegisterTest(&TTest_ASWLog_Utils::Test_Time_WriteISO8601_CalendarEdges, "Time_WriteISO8601_CalendarEdges"); RegisterTest(&TTest_ASWLog_Utils::Test_Time_WriteISO8601_LocalTimeWithEachPrecision, "Time_WriteISO8601_LocalTimeWithEachPrecision"); RegisterTest(&TTest_ASWLog_Utils::Test_Time_WriteISO8601_UTCWithEachPrecision, "Time_WriteISO8601_UTCWithEachPrecision"); + RegisterTest(&TTest_ASWLog_Utils::Test_TrimSpaces_RemovesOuterSpacesAndTabs, "TrimSpaces_RemovesOuterSpacesAndTabs"); } //--------------------------------------------------------------------------- TTest_ASWLog_Utils::~TTest_ASWLog_Utils() @@ -264,6 +268,20 @@ void TTest_ASWLog_Utils::Test_ApplyMultilineMode_LeavesOtherLinesAsTheyAre() CheckEquals(std::string(), ApplyMode("", ASWLog::MultilineMode::Indent), "An empty line should stay empty"); } //--------------------------------------------------------------------------- +void TTest_ASWLog_Utils::Test_EqualsIgnoringCase_IgnoresOnlyASCIICase() +{ + // Arrange / Act / Assert + CheckTrue(ASWLog::Detail::EqualsIgnoringCase("", ""), "Two empty texts should be equal"); + CheckTrue(ASWLog::Detail::EqualsIgnoringCase("Net.Http_2", "NET.HTTP_2"), "Letters should match in either case"); + CheckTrue(ASWLog::Detail::EqualsIgnoringCase("az", "AZ"), "The first and last letters should match too"); + CheckFalse(ASWLog::Detail::EqualsIgnoringCase("Net", "Nets"), "Texts of different lengths should differ"); + CheckFalse(ASWLog::Detail::EqualsIgnoringCase("Nets", "Net"), "Texts of different lengths should differ either way"); + CheckFalse(ASWLog::Detail::EqualsIgnoringCase("abc", "abd"), "Different letters should differ"); + CheckFalse(ASWLog::Detail::EqualsIgnoringCase("@", "`"), "Characters next to the letters should not be folded"); + CheckFalse(ASWLog::Detail::EqualsIgnoringCase("[", "{"), "Characters next to the letters should not be folded"); + CheckFalse(ASWLog::Detail::EqualsIgnoringCase("\xC3\x84", "\xC3\xA4"), "Non-ASCII letters should keep their case"); +} +//--------------------------------------------------------------------------- void TTest_ASWLog_Utils::Test_GenerateLogFileName_ContainsExpectedFields() { // Arrange @@ -600,6 +618,33 @@ void TTest_ASWLog_Utils::Test_MatchesWildcard_Patterns() CheckTrue(ASWLog::MatchesWildcard("no_extension", "*"), "A lone '*' should match any name, with or without a dot"); } //--------------------------------------------------------------------------- +void TTest_ASWLog_Utils::Test_ReadEnvironmentVariable_ReadsUTF8Values() +{ + // Arrange: a name with a lambda in it, a value with a lambda and a CJK character, and a value longer than any + // first guess at its size + const std::string longValue(5000, 'x'); + TScopedEnvironmentVariable unset("ASWLOGTEST_UTILS_UNSET", std::nullopt); + TScopedEnvironmentVariable empty("ASWLOGTEST_UTILS_EMPTY", ""); + TScopedEnvironmentVariable plain("ASWLOGTEST_UTILS_PLAIN", "plain value"); + TScopedEnvironmentVariable utf8("ASWLOGTEST_UTILS_\xCE\xBB", "a\xCE\xBB\xE6\x97\xA5"); + TScopedEnvironmentVariable longVariable("ASWLOGTEST_UTILS_LONG", longValue); + + // Act + const auto unsetValue = ASWLog::Detail::ReadEnvironmentVariable("ASWLOGTEST_UTILS_UNSET"); + const auto emptyValue = ASWLog::Detail::ReadEnvironmentVariable("ASWLOGTEST_UTILS_EMPTY"); + const auto plainValue = ASWLog::Detail::ReadEnvironmentVariable("ASWLOGTEST_UTILS_PLAIN"); + const auto utf8Value = ASWLog::Detail::ReadEnvironmentVariable("ASWLOGTEST_UTILS_\xCE\xBB"); + const auto longReadValue = ASWLog::Detail::ReadEnvironmentVariable("ASWLOGTEST_UTILS_LONG"); + + // Assert + CheckFalse(unsetValue.has_value(), "An unset variable should give nullopt"); + CheckTrue(emptyValue.has_value(), "A variable set to an empty value should be set"); + CheckEquals(std::string(), emptyValue.value_or("unset"), "A variable set to an empty value should give an empty text"); + CheckEquals(std::string("plain value"), plainValue.value_or("unset"), "A variable should give its value"); + CheckEquals(std::string("a\xCE\xBB\xE6\x97\xA5"), utf8Value.value_or("unset"), "A non-ASCII name and value should be UTF-8"); + CheckEquals(longValue, longReadValue.value_or("unset"), "A long value should be read whole"); +} +//--------------------------------------------------------------------------- void TTest_ASWLog_Utils::Test_RenameWithoutReplacing_KeepsExistingTarget() { // Arrange @@ -818,6 +863,16 @@ void TTest_ASWLog_Utils::Test_Time_WriteISO8601_UTCWithEachPrecision() } } //--------------------------------------------------------------------------- +void TTest_ASWLog_Utils::Test_TrimSpaces_RemovesOuterSpacesAndTabs() +{ + // Arrange / Act / Assert + CheckEquals(std::string(), std::string(ASWLog::Detail::TrimSpaces("")), "An empty text should stay empty"); + CheckEquals(std::string(), std::string(ASWLog::Detail::TrimSpaces(" \t \t")), "Only spaces and tabs should become empty"); + CheckEquals(std::string("a b"), std::string(ASWLog::Detail::TrimSpaces("\t a b \t")), "Outer spaces and tabs should go, inner ones stay"); + CheckEquals(std::string("x"), std::string(ASWLog::Detail::TrimSpaces("x")), "A single character should stay"); + CheckEquals(std::string("\nx\n"), std::string(ASWLog::Detail::TrimSpaces(" \nx\n ")), "Line breaks should stay"); +} +//--------------------------------------------------------------------------- } // namespace ASWUnitTests diff --git a/tests/tests/Test_ASWLog_Utils.h b/tests/tests/Test_ASWLog_Utils.h index 10ef84e..e177b70 100644 --- a/tests/tests/Test_ASWLog_Utils.h +++ b/tests/tests/Test_ASWLog_Utils.h @@ -40,6 +40,7 @@ class TTest_ASWLog_Utils : public TTestGroupBase void Test_ApplyMultilineMode_FindsLineBreaksAnywhereInALongText(); void Test_ApplyMultilineMode_IndentMarksEachLineAfterTheFirst(); void Test_ApplyMultilineMode_LeavesOtherLinesAsTheyAre(); + void Test_EqualsIgnoringCase_IgnoresOnlyASCIICase(); void Test_GenerateLogFileName_ContainsExpectedFields(); void Test_GenerateLogFileName_PrefixAndPostfixAreOptional(); void Test_GetCurrentOSProcessId_MatchesOS(); @@ -54,6 +55,7 @@ class TTest_ASWLog_Utils : public TTestGroupBase void Test_JSON_AppendString_KeepsValidUTF8(); void Test_JSON_AppendString_ReplacesInvalidUTF8(); void Test_MatchesWildcard_Patterns(); + void Test_ReadEnvironmentVariable_ReadsUTF8Values(); void Test_RenameWithoutReplacing_KeepsExistingTarget(); void Test_Time_GetUTCOffsetMinutes_FollowsDaylightSavingTime(); void Test_Time_ToDateString(); @@ -63,6 +65,7 @@ class TTest_ASWLog_Utils : public TTestGroupBase void Test_Time_WriteISO8601_CalendarEdges(); void Test_Time_WriteISO8601_LocalTimeWithEachPrecision(); void Test_Time_WriteISO8601_UTCWithEachPrecision(); + void Test_TrimSpaces_RemovesOuterSpacesAndTabs(); public: TTest_ASWLog_Utils(); diff --git a/tests/tests/Test_ASWLog_WindowsEventLog.cpp b/tests/tests/Test_ASWLog_WindowsEventLog.cpp new file mode 100644 index 0000000..400b6e6 --- /dev/null +++ b/tests/tests/Test_ASWLog_WindowsEventLog.cpp @@ -0,0 +1,639 @@ +/* ************************************************************************** +Test_ASWLog_WindowsEventLog.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "Test_ASWLog_WindowsEventLog.h" +//--------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#endif +//--------------------------------------------------------------------------- +#include "ASWUnitTests_Registry.h" +//--------------------------------------------------------------------------- +#include "ASWLog_CrashHandler.h" +#include "ASWLog_Utils.h" +#include "ASWLog_WindowsEventLog.h" +//--------------------------------------------------------------------------- + +namespace +{ + +// A value whose formatting is counted in EventLogFormatCount, to check whether a *Fmt call formats +struct TEventLogCountedValue +{ +}; + +int EventLogFormatCount = 0; + +} // namespace + +template<> +struct std::formatter +{ + constexpr std::format_parse_context::iterator parse(std::format_parse_context& context) + { + return context.begin(); + } + + std::format_context::iterator format(const TEventLogCountedValue& /*value*/, std::format_context& context) const + { + ++EventLogFormatCount; + return std::format_to(context.out(), "counted"); + } +}; + +namespace ASWUnitTests +{ + +namespace +{ + +using EventType = ASWLog::TASWWindowsEventLog::EventType; + +// A config with no startup or shutdown lines and only the level in each line, e.g. "[INFO]: message" +ASWLog::TASWLogConfig MakeEventLogConfig() +{ + ASWLog::TASWLogConfig config; + config.InitialMinimumLevel = ASWLog::Level::Info; + config.Line.ShowTimestamp = false; + config.Line.ShowProcessId = false; + config.Line.ShowThreadId = false; + config.Shutdown.WriteLine = false; + config.Startup.WriteTimeInfo = false; + config.Startup.WriteOSInfo = false; + config.Startup.WriteDriveInfo = false; + config.Startup.WriteSystemMemoryInfo = false; + config.Startup.WriteApplicationInfo = false; + config.Startup.WriteMemoryUsage = false; + return config; +} + +// Counts the lines it formats +class TCountingFormatter final : public ASWLog::IASWLogFormatter +{ +public: + mutable int FormatCount = 0; + + std::string Format(const ASWLog::TASWLogRecord& record, const ASWLog::TASWLogConfig& /*config*/) const override + { + ++FormatCount; + return std::string(record.Message); + } +}; + +// An Event Log logger that keeps the sources it would open and the events it would report, on every platform (where +// there is no Event Log, the logger's own ShouldLog() and PrepareWriteUnlocked() would drop every entry) +class TCapturingEventLog final : public ASWLog::TASWWindowsEventLog +{ +public: + struct TEvent + { + EventType Type = EventType::Information; + std::uint16_t EventId = 0; + std::string Text; + }; + + std::vector Sources; + std::vector Events; + +protected: + bool OpenEventSourceUnlocked(const std::string& source) override + { + Sources.push_back(source); + return true; + } + + bool PrepareWriteUnlocked(std::chrono::system_clock::time_point /*now*/) override + { + return true; + } + + void ReportEventUnlocked(EventType type, std::uint16_t eventId, std::string_view message) override + { + Events.push_back({ type, eventId, std::string(message) }); + } + +public: + // The overrides above are used until the shutdown line + ~TCapturingEventLog() override + { + Finalize(); + } + + bool ShouldLog(ASWLog::Level level) const noexcept override + { + return ASWLog::TASWTextLogBase::ShouldLog(level); + } + + bool ShouldLog(const ASWLog::TASWLogRecord& record) const noexcept override + { + return ASWLog::TASWTextLogBase::ShouldLog(record); + } + + void WriteCrashLine(std::string_view line) noexcept + { + WriteCrashLineDirect(line); + } +}; + +#if defined(_WIN32) +// An Event Log logger that reports each entry as an event too long for the Event Log, which refuses it +class TOversizedEventLog final : public ASWLog::TASWWindowsEventLog +{ +protected: + void WriteLineUnlocked(ASWLog::Level /*level*/, std::string_view /*line*/, bool /*endsLine*/) override + { + ReportEventUnlocked(EventType::Warning, 1003, std::string(40000, 'x')); + } + +public: + // The override above is used until the shutdown line + ~TOversizedEventLog() override + { + Finalize(); + } +}; + +// An event read back from the Application log +struct TReadEvent +{ + WORD Type = 0; + DWORD EventId = 0; + std::wstring Text; +}; + +// The newest event of 'source' in the Application log whose text contains 'token', or nothing if none came within two +// seconds (the newest 1000 events are searched) +std::optional FindEvent(const std::wstring& source, const std::wstring& token) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + + do + { + const HANDLE log = OpenEventLogW(nullptr, L"Application"); + if (log == nullptr) + return std::nullopt; + + std::vector buffer(64 * 1024); + int recordCount = 0; + DWORD readSize = 0; + DWORD neededSize = 0; + + while (recordCount < 1000) + { + if (!ReadEventLogW(log, EVENTLOG_SEQUENTIAL_READ | EVENTLOG_BACKWARDS_READ, 0, buffer.data(), static_cast(buffer.size()), &readSize, &neededSize)) + { + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) + break; + + buffer.resize(neededSize); + continue; + } + + for (DWORD offset = 0; offset < readSize; ++recordCount) + { + const auto* record = reinterpret_cast(buffer.data() + offset); + const std::wstring recordSource(reinterpret_cast(record + 1)); + const std::wstring text = record->NumStrings > 0 ? std::wstring(reinterpret_cast(buffer.data() + offset + record->StringOffset)) : std::wstring(); + + if (recordSource == source && text.find(token) != std::wstring::npos) + { + CloseEventLog(log); + return TReadEvent{ record->EventType, record->EventID, text }; + } + + offset += record->Length; + } + } + + CloseEventLog(log); + } while (std::chrono::steady_clock::now() < deadline); + + return std::nullopt; +} + +// True if the process runs with administrator rights (elevated) +bool IsElevated() +{ + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return false; + + TOKEN_ELEVATION elevation{}; + DWORD size = 0; + const bool isElevated = GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &size) && elevation.TokenIsElevated != 0; + CloseHandle(token); + + return isElevated; +} + +// The EventMessageFile value of 'source''s registry key (empty if it has none), or nothing if it has no key +std::optional ReadMessageFile(const std::wstring& source) +{ + const std::wstring key = L"SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\" + source; + HKEY sourceKey = nullptr; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, key.c_str(), 0, KEY_QUERY_VALUE, &sourceKey) != ERROR_SUCCESS) + return std::nullopt; + + wchar_t value[MAX_PATH]{}; + DWORD size = sizeof(value); + const LSTATUS status = RegGetValueW(sourceKey, nullptr, L"EventMessageFile", RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND, nullptr, value, &size); + RegCloseKey(sourceKey); + + return status == ERROR_SUCCESS ? std::wstring(value) : std::wstring(); +} + +// 'text' (ASCII) as a wide string +std::wstring Widen(std::string_view text) +{ + return std::wstring(text.begin(), text.end()); +} +#endif + +} // namespace + +//--------------------------------------------------------------------------- + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_WindowsEventLog +/////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TTest_ASWLog_WindowsEventLog::TTest_ASWLog_WindowsEventLog() + : inherited("ASWLog_WindowsEventLog_Tests") +{ + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_GetInstance_ReturnsTheSameLogger, "GetInstance_ReturnsTheSameLogger"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_HandleCrash_ReportsTheFormattedCrashLineOnly, "HandleCrash_ReportsTheFormattedCrashLineOnly"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_Open_OpensTheConfiguredSource, "Open_OpensTheConfiguredSource"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_RegisterSource_NeedsAdministratorRights, "RegisterSource_NeedsAdministratorRights"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_Report_CutsTooLongMessages, "Report_CutsTooLongMessages"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_Report_MapsEachLevelToATypeAndEventId, "Report_MapsEachLevelToATypeAndEventId"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_Report_StripsOnlyTheLineEnding, "Report_StripsOnlyTheLineEnding"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_ShouldLog_FollowsThePlatform, "ShouldLog_FollowsThePlatform"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_Windows_ReportsAFailedOpen, "Windows_ReportsAFailedOpen"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_Windows_ReportsARealEvent, "Windows_ReportsARealEvent"); + RegisterTest(&TTest_ASWLog_WindowsEventLog::Test_Windows_ReportsARefusedEvent, "Windows_ReportsARefusedEvent"); +} +//--------------------------------------------------------------------------- +TTest_ASWLog_WindowsEventLog::~TTest_ASWLog_WindowsEventLog() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::SetUp_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::SetUp_Test(ITestCase& /*testCase*/) +{ + EventLogFormatCount = 0; +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::TearDown_Group() +{ +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::TearDown_Test(ITestCase& /*testCase*/) +{ +} +//--------------------------------------------------------------------------- + +// /////// Begin tests after this line /////////////////////// + +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_GetInstance_ReturnsTheSameLogger() +{ + // Act + auto& first = ASWLog::TASWWindowsEventLog::GetInstance(); + auto& second = ASWLog::TASWWindowsEventLog::GetInstance(); + + // Assert + CheckSame(&first, &second, "GetInstance() should always return the same logger"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_HandleCrash_ReportsTheFormattedCrashLineOnly() +{ + // Arrange + TCapturingEventLog log; + CheckTrue(log.Initialize(MakeEventLogConfig()), "Initialize should succeed"); + + // Act: a crash in normal context, then the direct write a crash with a busy lock would use + ASWLog::HandleCrash("test crash"); + log.WriteCrashLine("[CRITICAL]: Crash: direct\n"); + + // Assert + AssertEquals(1, log.Events.size(), "Only the formatted crash line should be reported"); + CheckEquals(EventType::Error, log.Events[0].Type, "The crash line should be an Error event"); + CheckEquals(1005, log.Events[0].EventId, "The crash line should have Critical's event ID"); + CheckEquals(std::string("[CRITICAL]: Crash: test crash"), log.Events[0].Text, "The crash line should be reported without its line ending"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_Open_OpensTheConfiguredSource() +{ + // Arrange + TCapturingEventLog log; + const auto config = MakeEventLogConfig(); + auto appConfig = config; + appConfig.EventLog.Source = "MyApp"; + const std::string executableStem = ASWLog::PathToUTF8String(ASWLog::GetExecutablePath().stem()); + + // Act + const bool initialized = log.Initialize(config); + const bool reconfigured = log.Reconfigure(appConfig); + const bool closed = log.Close(); + const bool opened = log.Open(); + + // Assert + CheckTrue(initialized && reconfigured && closed && opened, "Initialize, Reconfigure, Close and Open should succeed"); + CheckEmpty(config.EventLog.Source, "The default source should be empty (the executable's file name)"); + AssertEquals(3, log.Sources.size(), "Initialize, Reconfigure and Open should each open the source"); + CheckNotEmpty(executableStem, "The executable's file name should be known"); + CheckEquals(executableStem, log.Sources[0], "Without a source, the executable's file name without its extension should be used"); + CheckEquals(std::string("MyApp"), log.Sources[1], "Reconfigure should open the new source"); + CheckEquals(std::string("MyApp"), log.Sources[2], "Open after Close should open the current config's source again"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_RegisterSource_NeedsAdministratorRights() +{ +#if defined(_WIN32) + // Arrange: a source of this test's own; elevated (e.g. on a CI runner) it is registered and removed again + const std::wstring source = L"ASWLogTests_Registration"; + const bool isElevated = IsElevated(); + if (isElevated) + ASWLog::TASWWindowsEventLog::UnregisterSource("ASWLogTests_Registration"); + + // Act + const bool registered = ASWLog::TASWWindowsEventLog::RegisterSource("ASWLogTests_Registration"); + const auto messageFile = ReadMessageFile(source); + const bool unregistered = ASWLog::TASWWindowsEventLog::UnregisterSource("ASWLogTests_Registration"); + const auto messageFileAfter = ReadMessageFile(source); + const bool registeredNested = ASWLog::TASWWindowsEventLog::RegisterSource("ASWLogTests_Registration\\Nested"); + const bool unregisteredNested = ASWLog::TASWWindowsEventLog::UnregisterSource("ASWLogTests_Registration\\Nested"); + + // Assert + CheckEquals(isElevated, registered, "RegisterSource() should succeed exactly when the process is elevated"); + if (isElevated) + { + CheckTrue(messageFile == std::wstring(L"%SystemRoot%\\Microsoft.NET\\Framework\\v4.0.30319\\EventLogMessages.dll"), "The source's message file should be EventLogMessages.dll"); + CheckTrue(unregistered, "UnregisterSource() should succeed when elevated"); + } + else + { + CheckTrue(unregistered, "UnregisterSource() of a source that isn't registered should succeed"); + } + CheckFalse(messageFile.has_value() && !isElevated, "Without administrator rights, no registry key should be made"); + CheckFalse(messageFileAfter.has_value(), "After UnregisterSource(), the source should have no registry key"); + CheckFalse(registeredNested, "A source holding a '\\' should not be registered"); + CheckFalse(unregisteredNested, "A source holding a '\\' names no source to unregister"); +#else + // Act & Assert + CheckFalse(ASWLog::TASWWindowsEventLog::RegisterSource("ASWLogTests_Registration"), "Without an Event Log, RegisterSource() should fail"); + CheckFalse(ASWLog::TASWWindowsEventLog::UnregisterSource("ASWLogTests_Registration"), "Without an Event Log, UnregisterSource() should fail"); +#endif +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_Report_CutsTooLongMessages() +{ + // Arrange: "[INFO]: " is 8 bytes. The longer line has 2-byte characters from byte MaxSize - 9 on, so the cut, which + // keeps MaxSize - 6 bytes for the " [cut]" marker, falls inside one and backs off to the character before it. + constexpr std::size_t MaxSize = ASWLog::TASWWindowsEventLog::MaxMessageSize; + TCapturingEventLog log; + CheckTrue(log.Initialize(MakeEventLogConfig()), "Initialize should succeed"); + const std::string fitting(MaxSize - 8, 'x'); + std::string tooLong(MaxSize - 8 - 9, 'y'); + for (int i = 0; i < 20; ++i) + tooLong += "\xC3\xA9"; + + // Act + log.LogInfo(fitting); + log.LogInfo(tooLong); + + // Assert + CheckEquals(31839, MaxSize, "The limit should be the Event Log's 31,839 characters"); + AssertEquals(2, log.Events.size(), "Each entry should be one event"); + CheckEquals("[INFO]: " + fitting, log.Events[0].Text, "A message that fits should be reported whole"); + CheckLessThanOrEqual(log.Events[1].Text.size(), MaxSize, "A longer one should be cut to fit"); + CheckEquals(MaxSize - 1, log.Events[1].Text.size(), "The cut should back off to the start of a 2-byte character"); + CheckStartsWith(log.Events[1].Text, "[INFO]: yyy", "The start of the message should be kept"); + CheckEndsWith(log.Events[1].Text, "\xC3\xA9 [cut]", "The cut should end with a whole character and the marker"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_Report_MapsEachLevelToATypeAndEventId() +{ + // Arrange + TCapturingEventLog log; + auto config = MakeEventLogConfig(); + config.InitialMinimumLevel = ASWLog::Level::Trace; + auto tableConfig = config; + tableConfig.EventLog.EventIds = { 10, 20, 30, 40, 50, 65535 }; + CheckTrue(log.Initialize(config), "Initialize should succeed"); + + // Act + log.LogTrace("trace"); + log.LogDebug("debug"); + log.LogInfo("info"); + log.LogWarn("warn"); + log.LogError("error"); + log.LogCritical("critical"); + log.LogRaw(ASWLog::Level::Warn, "raw"); + CheckTrue(log.Reconfigure(tableConfig), "Reconfigure should succeed"); + log.LogInfo("info"); + log.LogCritical("critical"); + + // Assert + AssertEquals(9, log.Events.size(), "Each entry should be one event"); + const EventType types[] = { EventType::Information, EventType::Information, EventType::Information, EventType::Warning, EventType::Error, EventType::Error, EventType::Warning, EventType::Information, EventType::Error }; + const int eventIds[] = { 1000, 1001, 1002, 1003, 1004, 1005, 1003, 30, 65535 }; + for (std::size_t i = 0; i < log.Events.size(); ++i) + { + CheckEquals(types[i], log.Events[i].Type, std::format("Event {} ({}) should have its level's type", i, log.Events[i].Text)); + CheckEquals(eventIds[i], log.Events[i].EventId, std::format("Event {} ({}) should have its level's event ID", i, log.Events[i].Text)); + } +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_Report_StripsOnlyTheLineEnding() +{ + // Arrange + TCapturingEventLog log; + auto crlfConfig = MakeEventLogConfig(); + crlfConfig.Line.Ending = ASWLog::LineEnding::CRLF; + CheckTrue(log.Initialize(MakeEventLogConfig()), "Initialize should succeed"); + + // Act + log.LogInfo("hello"); + log.LogInfo("ends with a break\n"); + log.LogRaw(ASWLog::Level::Info, "raw text\n"); + CheckTrue(log.Reconfigure(crlfConfig), "Reconfigure should succeed"); + log.LogInfo("crlf"); + log.LogInfo("ends with LF\n"); + + // Assert + AssertEquals(5, log.Events.size(), "Each entry should be one event"); + CheckEquals(std::string("[INFO]: hello"), log.Events[0].Text, "The LF ending should be stripped"); + CheckEquals(std::string("[INFO]: ends with a break\n"), log.Events[1].Text, "Only the line ending should be stripped, not the message's own break"); + CheckEquals(std::string("raw text\n"), log.Events[2].Text, "A raw entry should be reported as is"); + CheckEquals(std::string("[INFO]: crlf"), log.Events[3].Text, "The CRLF ending should be stripped"); + CheckEquals(std::string("[INFO]: ends with LF\n"), log.Events[4].Text, "With CRLF endings, the message's own LF should stay"); +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_ShouldLog_FollowsThePlatform() +{ +#if defined(_WIN32) + // Arrange: no startup or shutdown lines, and only checks, so nothing reaches the Event Log + ASWLog::TASWWindowsEventLog log; + ASWLog::TASWLogRecord infoRecord; + infoRecord.LogLevel = ASWLog::Level::Info; + + // Act + const bool initialized = log.Initialize(MakeEventLogConfig()); + log.LogDebugFmt("{}", TEventLogCountedValue{}); + + // Assert + CheckTrue(initialized, "Initialize should succeed on every platform"); + CheckEquals(0, EventLogFormatCount, "An entry below the minimum level should not be formatted"); + CheckTrue(log.ShouldLog(ASWLog::Level::Info), "On Windows, ShouldLog() should follow the minimum level (Info)"); + CheckFalse(log.ShouldLog(ASWLog::Level::Debug), "On Windows, ShouldLog() should follow the minimum level (Debug)"); + CheckTrue(log.ShouldLog(infoRecord), "On Windows, ShouldLog(record) should follow the minimum level"); +#else + // Arrange: a startup and a shutdown line too, each of which would be formatted + auto config = MakeEventLogConfig(); + const auto formatter = std::make_shared(); + config.Line.Formatter = formatter; + config.Startup.Banner = "banner"; + config.Shutdown.WriteLine = true; + ASWLog::TASWLogRecord criticalRecord; + criticalRecord.LogLevel = ASWLog::Level::Critical; + bool initialized = false; + bool shouldLog = true; + bool shouldLogRecord = true; + + // Act + { + ASWLog::TASWWindowsEventLog log; + initialized = log.Initialize(config); + shouldLog = log.ShouldLog(ASWLog::Level::Critical); + shouldLogRecord = log.ShouldLog(criticalRecord); + log.LogCriticalFmt("{}", TEventLogCountedValue{}); + log.LogCritical("not formatted"); + } + + // Assert + CheckTrue(initialized, "Initialize should succeed on every platform"); + CheckFalse(shouldLog, "Without an Event Log, ShouldLog() should be false"); + CheckFalse(shouldLogRecord, "Without an Event Log, ShouldLog(record) should be false"); + CheckEquals(0, EventLogFormatCount, "Without an Event Log, no *Fmt arguments should be formatted"); + CheckEquals(0, formatter->FormatCount, "Without an Event Log, no line (startup, entry or shutdown) should be formatted"); +#endif +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_Windows_ReportsAFailedOpen() +{ +#if defined(_WIN32) + // Arrange: only the system (LSA) may report to the Security log, so other processes can't open it as a source + ASWLog::TASWWindowsEventLog log; + auto config = MakeEventLogConfig(); + config.EventLog.Source = "Security"; + std::vector errors; + config.OnError = [&errors](const ASWLog::TASWLogError& error) { + errors.push_back(error); + }; + + // Act + const bool initialized = log.Initialize(config); + if (initialized) + Skip("This process may report to the Security log"); + + // Assert + AssertEquals(1, errors.size(), "The failed open should be reported"); + CheckEquals(ASWLog::ErrorKind::OpenFailed, errors[0].Kind, "It should be an OpenFailed error"); + CheckContains(errors[0].Message, "'Security'", "It should name the source"); + CheckEquals(ERROR_ACCESS_DENIED, errors[0].Code.value(), "It should carry the system's error"); + CheckFalse(log.IsOpen(), "The logger should not be open"); +#else + Skip("Only Windows has an Event Log"); +#endif +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_Windows_ReportsARealEvent() +{ +#if defined(_WIN32) + // Arrange: one Warning event of a test source in the Application log, found again by a token of this run + ASWLog::TASWWindowsEventLog log; + auto config = MakeEventLogConfig(); + config.EventLog.Source = "ASWLogTests"; + config.EventLog.EventIds[static_cast(ASWLog::Level::Warn)] = 4321; + const std::string token = std::format("token {}", std::chrono::steady_clock::now().time_since_epoch().count()); + CheckTrue(log.Initialize(config), "Initialize should succeed"); + + // Act + log.LogWarn("ASWLog unit test event, " + token); + const auto event = FindEvent(L"ASWLogTests", Widen(token)); + + // Assert + AssertTrue(event.has_value(), "The event should be in the Application log"); + CheckEquals(EVENTLOG_WARNING_TYPE, event->Type, "It should be a Warning event"); + CheckEquals(4321, event->EventId & 0xFFFF, "It should have Warn's event ID from the config"); + CheckTrue(event->Text == Widen("[WARN]: ASWLog unit test event, " + token), "Its text should be the line without its ending"); +#else + Skip("Only Windows has an Event Log"); +#endif +} +//--------------------------------------------------------------------------- +void TTest_ASWLog_WindowsEventLog::Test_Windows_ReportsARefusedEvent() +{ +#if defined(_WIN32) + // Arrange: a real source, and an event the Event Log refuses (ERROR_INVALID_PARAMETER), so nothing is written + TOversizedEventLog log; + auto config = MakeEventLogConfig(); + config.EventLog.Source = "ASWLogTests"; + std::vector errors; + config.OnError = [&errors](const ASWLog::TASWLogError& error) { + errors.push_back(error); + }; + CheckTrue(log.Initialize(config), "Initialize should succeed"); + + // Act + log.LogWarn("refused"); + + // Assert + AssertEquals(1, errors.size(), "The refused event should be reported"); + CheckEquals(ASWLog::ErrorKind::WriteFailed, errors[0].Kind, "It should be a WriteFailed error"); + CheckEquals(ERROR_INVALID_PARAMETER, errors[0].Code.value(), "It should carry the system's error"); +#else + Skip("Only Windows has an Event Log"); +#endif +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests + +//--------------------------------------------------------------------------- +ASW_REGISTER_TEST_GROUP(ASWUnitTests::TTest_ASWLog_WindowsEventLog) diff --git a/tests/tests/Test_ASWLog_WindowsEventLog.h b/tests/tests/Test_ASWLog_WindowsEventLog.h new file mode 100644 index 0000000..38b5f95 --- /dev/null +++ b/tests/tests/Test_ASWLog_WindowsEventLog.h @@ -0,0 +1,66 @@ +/* ************************************************************************** +Test_ASWLog_WindowsEventLog.h +Author: Anthony S. West - ASW Software + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#pragma once + +#ifndef Test_ASWLog_WindowsEventLogH +#define Test_ASWLog_WindowsEventLogH +//--------------------------------------------------------------------------- +#include "ASWUnitTests_TestBase.h" +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TTest_ASWLog_WindowsEventLog +/////////////////////////////////////////////////////////////////////////// +class TTest_ASWLog_WindowsEventLog : public TTestGroupBase +{ +private: + typedef TTestGroupBase inherited; + +private: // Test methods + void Test_GetInstance_ReturnsTheSameLogger(); + void Test_HandleCrash_ReportsTheFormattedCrashLineOnly(); + void Test_Open_OpensTheConfiguredSource(); + void Test_RegisterSource_NeedsAdministratorRights(); + void Test_Report_CutsTooLongMessages(); + void Test_Report_MapsEachLevelToATypeAndEventId(); + void Test_Report_StripsOnlyTheLineEnding(); + void Test_ShouldLog_FollowsThePlatform(); + void Test_Windows_ReportsAFailedOpen(); + void Test_Windows_ReportsARealEvent(); + void Test_Windows_ReportsARefusedEvent(); + +public: + TTest_ASWLog_WindowsEventLog(); + ~TTest_ASWLog_WindowsEventLog() override; + + void SetUp_Group() override; + void SetUp_Test(ITestCase& testCase) override; + void TearDown_Group() override; + void TearDown_Test(ITestCase& testCase) override; +}; + +} // ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef Test_ASWLog_WindowsEventLogH diff --git a/tests/tests/UT_Helper_Environment.cpp b/tests/tests/UT_Helper_Environment.cpp new file mode 100644 index 0000000..b86bffa --- /dev/null +++ b/tests/tests/UT_Helper_Environment.cpp @@ -0,0 +1,127 @@ +/* ************************************************************************** +UT_Helper_Environment.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "UT_Helper_Environment.h" +//--------------------------------------------------------------------------- +// System includes here +#include +#include + +#if defined(_WIN32) +#include +#endif +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +#if defined(_WIN32) +namespace +{ + +std::string ToUTF8(const std::wstring& text); +std::wstring ToWide(const std::string& text); + +//--------------------------------------------------------------------------- + +// 'text' (UTF-16) as UTF-8 +std::string ToUTF8(const std::wstring& text) +{ + const int size = WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0, nullptr, nullptr); + std::string utf8(static_cast(size), '\0'); + WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast(text.size()), utf8.data(), size, nullptr, nullptr); + + return utf8; +} + +// 'text' (UTF-8) as UTF-16, converted here rather than with the library's helper the tests check +std::wstring ToWide(const std::string& text) +{ + const int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0); + std::wstring wide(static_cast(size), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), wide.data(), size); + + return wide; +} + +} // namespace +#endif + +//--------------------------------------------------------------------------- + +/////////////////////////////////////////////////////////////////////////// +// TScopedEnvironmentVariable +/////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TScopedEnvironmentVariable::TScopedEnvironmentVariable(std::string name, std::optional value) + : m_Name(std::move(name)), + m_PreviousValue(Get(m_Name)) +{ + Set(m_Name, value); +} +//--------------------------------------------------------------------------- +TScopedEnvironmentVariable::~TScopedEnvironmentVariable() +{ + Set(m_Name, m_PreviousValue); +} +//--------------------------------------------------------------------------- +std::optional TScopedEnvironmentVariable::Get(const std::string& name) +{ +#if defined(_WIN32) + // The Windows API rather than the C runtime, since that's what the logger reads (and MinGW's runtime has no + // _dupenv_s) + const auto wideName = ToWide(name); + const DWORD size = GetEnvironmentVariableW(wideName.c_str(), nullptr, 0); + if (size == 0) + return std::nullopt; + + std::wstring value(size, L'\0'); + value.resize(GetEnvironmentVariableW(wideName.c_str(), value.data(), size)); + + return ToUTF8(value); +#else + const char* value = std::getenv(name.c_str()); + if (value == nullptr) + return std::nullopt; + + return std::string(value); +#endif +} +//--------------------------------------------------------------------------- +void TScopedEnvironmentVariable::Set(const std::string& name, const std::optional& value) +{ +#if defined(_WIN32) + // nullptr removes the variable + SetEnvironmentVariableW(ToWide(name).c_str(), value ? ToWide(*value).c_str() : nullptr); +#else + if (value) + setenv(name.c_str(), value->c_str(), 1); + else + unsetenv(name.c_str()); +#endif +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests diff --git a/tests/tests/UT_Helper_Environment.h b/tests/tests/UT_Helper_Environment.h new file mode 100644 index 0000000..8a315ca --- /dev/null +++ b/tests/tests/UT_Helper_Environment.h @@ -0,0 +1,60 @@ +/* ************************************************************************** +UT_Helper_Environment.h +Author: Anthony S. West - ASW Software + +Environment variable helpers shared by the unit tests. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#ifndef UT_Helper_EnvironmentH +#define UT_Helper_EnvironmentH +//--------------------------------------------------------------------------- +#include +#include +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TScopedEnvironmentVariable +// +// While alive, sets an environment variable (or removes it, given std::nullopt), then restores its previous value. +// Names and values are UTF-8 (set with the wide Windows API on Windows, which is what the logger reads there). +/////////////////////////////////////////////////////////////////////////// +class TScopedEnvironmentVariable +{ +private: + std::string m_Name; + std::optional m_PreviousValue; + +private: + static std::optional Get(const std::string& name); + static void Set(const std::string& name, const std::optional& value); + +public: + TScopedEnvironmentVariable(std::string name, std::optional value); + ~TScopedEnvironmentVariable(); + + TScopedEnvironmentVariable(const TScopedEnvironmentVariable&) = delete; + TScopedEnvironmentVariable& operator=(const TScopedEnvironmentVariable&) = delete; +}; + +} // ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef UT_Helper_EnvironmentH diff --git a/tests/tests/UT_Helper_StdErr.cpp b/tests/tests/UT_Helper_StdErr.cpp new file mode 100644 index 0000000..a83b0b0 --- /dev/null +++ b/tests/tests/UT_Helper_StdErr.cpp @@ -0,0 +1,96 @@ +/* ************************************************************************** +UT_Helper_StdErr.cpp +Author: Anthony S. West - ASW Software + +See header for info. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +//--------------------------------------------------------------------------- +// Module header +#include "UT_Helper_StdErr.h" +//--------------------------------------------------------------------------- +// System includes here +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TStdErrRedirect +/////////////////////////////////////////////////////////////////////////// + +//--------------------------------------------------------------------------- +TStdErrRedirect::TStdErrRedirect(const std::filesystem::path& file) +{ + std::fflush(stderr); +#if defined(_WIN32) + std::FILE* target = _wfsopen(file.c_str(), L"wb", _SH_DENYNO); + if (target == nullptr) + return; + + m_SavedDescriptor = _dup(_fileno(stderr)); + if (m_SavedDescriptor >= 0 && _dup2(_fileno(target), _fileno(stderr)) != 0) + { + _close(m_SavedDescriptor); + m_SavedDescriptor = -1; + } +#else + std::FILE* target = std::fopen(file.c_str(), "wb"); + if (target == nullptr) + return; + + m_SavedDescriptor = dup(fileno(stderr)); + if (m_SavedDescriptor >= 0 && dup2(fileno(target), fileno(stderr)) < 0) + { + close(m_SavedDescriptor); + m_SavedDescriptor = -1; + } +#endif + std::fclose(target); +} +//--------------------------------------------------------------------------- +TStdErrRedirect::~TStdErrRedirect() +{ + if (m_SavedDescriptor < 0) + return; + + std::fflush(stderr); +#if defined(_WIN32) + _dup2(m_SavedDescriptor, _fileno(stderr)); + _close(m_SavedDescriptor); +#else + dup2(m_SavedDescriptor, fileno(stderr)); + close(m_SavedDescriptor); +#endif +} +//--------------------------------------------------------------------------- +bool TStdErrRedirect::IsActive() const noexcept +{ + return m_SavedDescriptor >= 0; +} +//--------------------------------------------------------------------------- + +} // namespace ASWUnitTests diff --git a/tests/tests/UT_Helper_StdErr.h b/tests/tests/UT_Helper_StdErr.h new file mode 100644 index 0000000..0fe5b94 --- /dev/null +++ b/tests/tests/UT_Helper_StdErr.h @@ -0,0 +1,56 @@ +/* ************************************************************************** +UT_Helper_StdErr.h +Author: Anthony S. West - ASW Software + +stderr helpers shared by the unit tests. + +Copyright 2026 ASW Software + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +************************************************************************** */ + +#ifndef UT_Helper_StdErrH +#define UT_Helper_StdErrH +//--------------------------------------------------------------------------- +#include +//--------------------------------------------------------------------------- + +namespace ASWUnitTests +{ + +/////////////////////////////////////////////////////////////////////////// +// TStdErrRedirect +// +// While alive, sends what is written to stderr to 'file' instead, by pointing stderr's file descriptor at it. Inactive +// if stderr has no file descriptor (e.g. in a GUI application without a console). +/////////////////////////////////////////////////////////////////////////// +class TStdErrRedirect +{ +private: + int m_SavedDescriptor = -1; + +public: + explicit TStdErrRedirect(const std::filesystem::path& file); + ~TStdErrRedirect(); + + TStdErrRedirect(const TStdErrRedirect&) = delete; + TStdErrRedirect& operator=(const TStdErrRedirect&) = delete; + + bool IsActive() const noexcept; +}; + +} // ASWUnitTests + +//--------------------------------------------------------------------------- +#endif // #ifndef UT_Helper_StdErrH diff --git a/tests/vcl/gui/rad370/ASWLogTests_VCL_GUI.cbproj b/tests/vcl/gui/rad370/ASWLogTests_VCL_GUI.cbproj index 90800a6..0320775 100644 --- a/tests/vcl/gui/rad370/ASWLogTests_VCL_GUI.cbproj +++ b/tests/vcl/gui/rad370/ASWLogTests_VCL_GUI.cbproj @@ -208,6 +208,10 @@ ..\..\..\..\ASWLog\ASWLog_CrashHandler.h 42 + + ..\..\..\..\ASWLog\ASWLog_DebuggerLog.h + 56 + ..\..\..\..\ASWLog\ASWLog_Fields.h 50 @@ -224,10 +228,26 @@ ..\..\..\..\ASWLog\ASWLog_Interface.h 6 + + ..\..\..\..\ASWLog\ASWLog_Limiter.h + 62 + + + ..\..\..\..\ASWLog\ASWLog_MemoryLog.h + 54 + ..\..\..\..\ASWLog\ASWLog_MultiLog.h 7 + + ..\..\..\..\ASWLog\ASWLog_NullLog.h + 52 + + + ..\..\..\..\ASWLog\ASWLog_SyslogLog.h + 58 + ..\..\..\..\ASWLog\ASWLog_TextLogBase.h 34 @@ -236,6 +256,10 @@ ..\..\..\..\ASWLog\ASWLog_Types.h 8 + + ..\..\..\..\ASWLog\ASWLog_Unicode.h + 66 + ..\..\..\..\ASWLog\ASWLog_Utils.h 9 @@ -244,6 +268,10 @@ ..\..\..\..\ASWLog\ASWLog_Version.h 38 + + ..\..\..\..\ASWLog\ASWLog_WindowsEventLog.h + 60 + ..\..\..\tests\Test_ASWLog_Base.h 27 @@ -264,6 +292,10 @@ ..\..\..\tests\Test_ASWLog_CrashHandler.h 43 + + ..\..\..\tests\Test_ASWLog_DebuggerLog.h + 57 + ..\..\..\tests\Test_ASWLog_Fields.h 51 @@ -276,10 +308,26 @@ ..\..\..\tests\Test_ASWLog_Formatter.h 37 + + ..\..\..\tests\Test_ASWLog_Limiter.h + 63 + + + ..\..\..\tests\Test_ASWLog_MemoryLog.h + 55 + ..\..\..\tests\Test_ASWLog_MultiLog.h 31 + + ..\..\..\tests\Test_ASWLog_NullLog.h + 53 + + + ..\..\..\tests\Test_ASWLog_SyslogLog.h + 59 + ..\..\..\tests\Test_ASWLog_TextLogBase.h 35 @@ -288,6 +336,10 @@ ..\..\..\tests\Test_ASWLog_Types.h 32 + + ..\..\..\tests\Test_ASWLog_Unicode.h + 67 + ..\..\..\tests\Test_ASWLog_Utils.h 33 @@ -296,14 +348,26 @@ ..\..\..\tests\Test_ASWLog_Version.h 39 + + ..\..\..\tests\Test_ASWLog_WindowsEventLog.h + 61 + ..\..\..\tests\UT_Helper_DateTime.h 44 + + ..\..\..\tests\UT_Helper_Environment.h + 64 + ..\..\..\tests\UT_Helper_JSON.h 49 + + ..\..\..\tests\UT_Helper_StdErr.h + 65 + 0