public interface IAppLogger { /// /// Log a debug/detail message intended for developers or verbose diagnostics. /// /// The message to log. void Debug(object? message); /// /// Log an informational message that represents normal operation or key milestones. /// /// The message to log. void Info(object? message); /// /// Log a warning about an unexpected but recoverable condition. /// /// The warning message to log. void Warn(object? message); /// /// Log an error. Implementations may include exception details when is provided. /// /// The error message to log. /// Optional exception associated with the error. void Error(object? message, Exception? ex = null); } public sealed class NullLogger : IAppLogger { /// /// Shared instance to avoid unnecessary allocations. /// public static readonly NullLogger Instance = new NullLogger(); // Private ctor encourages use of Instance when appropriate. internal NullLogger() { } public void Debug(object? message) { /* intentionally no-op */ } public void Info(object? message) { /* intentionally no-op */ } public void Warn(object? message) { /* intentionally no-op */ } public void Error(object? message, Exception? ex = null) { /* intentionally no-op */ } } } using log4net; using log4net.Appender; using log4net.Layout; using System; using System.Collections.Generic; using System.IO; using System.Reflection; namespace MyProject.Lib.Logging { /// /// Static entry point for library diagnostics. /// /// /// /// Purpose /// - Provide a small, test-friendly logging façade for the library so internal components can /// emit diagnostics without depending on a concrete logging framework. /// - Expose a single adjustable global logger via and convenient forwarding /// helpers (, , , /// ) so call sites remain concise and testable. /// /// /// /// Default behavior and test guidance /// - By default is a which drops messages. Tests /// and hosts should replace with a deterministic implementation /// (for example a TestLogger) rather than enabling file-based logging when possible. /// - Use to restore no-op behavior in teardown. This prevents tests from /// producing flaky file/IO side-effects. /// /// /// /// File-based logging via log4net /// - Use to programmatically enable a rolling file appender: /// - The method resolves and normalizes the path, ensures the directory exists and performs a /// lightweight write probe to fail early on permission/path issues. /// - If the probe succeeds the method registers a UTF-8 rolling appender and sets /// to a bound to the configured repository. /// - Hosts may instead call to adopt the host's existing log4net configuration. /// - Note: programmatic configuration performed by this class mutates the global log4net repository. /// Only call these helpers during process startup or in controlled integration tests. /// /// /// /// Non-throwing & best-effort contract /// - Logging is best-effort. Implementations of must not throw from log calls. /// - performs defensive checks and will throw on invalid arguments or unwritable /// paths so hosts can surface configuration errors early. Consumers should catch these exceptions /// during host initialization; unit tests should avoid using this method. /// /// /// /// Concurrency and lifetime /// - The reference is assigned atomically but there is no internal locking for /// concurrent set/get. Set the global logger early on a single thread during startup to avoid /// races. For per-component scoping prefer constructor injection of an . /// - The logging façade and its adapters must be safe for concurrent use; the library may call into /// the logger from parallel tasks or test runners. /// /// /// /// Diagnostic tokens and verbosity /// - The library uses compact, stable tokens in messages to enable reliable assertions in tests. /// - The file appender uses a human-friendly layout including timestamp, level, logger and message; /// callers can adjust verbosity via the parameter to . /// /// /// /// Recommendations for hosts and CI /// - Prefer wiring a host-level logger and calling or setting /// to an adapter that forwards to the host. This avoids the library performing global repository changes. /// - Use file-based logging sparingly in CI; prefer capturing test logs via injected test loggers to keep /// pipelines hermetic and fast. /// /// /// /// Example /// /// // Programmatic file logging (call during startup) /// Log.SetFile("C:\\logs\\convert.log", "DEBUG"); /// /// // Or use a test logger in unit tests /// Log.Current = new TestLogger(); /// /// /// public static class Log { private static IAppLogger _current = new NullLogger(); /// /// Gets or sets the active logger. If set to null, a no-op logger is used. /// /// /// /// Consumers (tests, hosts) may set this property to supply a custom logger (for example a /// test logger). Assigning null will restore a instance to preserve /// no-op behavior. /// /// /// Assignments are atomic for the reference; however concurrent read/write races are not /// synchronized. Set the global logger early during initialization to avoid race conditions. /// /// public static IAppLogger Current { get => _current; set => _current = value ?? new NullLogger(); } /// /// Configure file-based logging using a programmatically-created log4net rolling appender. /// /// Target log file path. The method will create containing directories as needed. /// Optional log level name (All/Debug/Info/Warn/Error/Fatal/Off). Defaults to "All". /// /// /// This helper performs the following defensive steps before enabling file logging: /// - Resolves and normalizes the supplied path. /// - Ensures the target directory exists. /// - Probes write access by opening the file for append and immediately closing it. /// - Creates a rolling file appender with a UTF-8 pattern layout and configures the log4net /// repository for the current entry assembly. /// /// /// The method throws an for invalid arguments and an /// when the path is not writable. Callers should catch and handle /// these exceptions during host startup; tests should avoid calling this method and instead /// set directly to a deterministic test logger. /// /// public static void SetFile(string logFilePath, string logLevel = "All") { if (string.IsNullOrWhiteSpace(logFilePath)) throw new ArgumentException("logFilePath cannot be null or empty.", nameof(logFilePath)); var fullPath = ResolvePath(logFilePath); EnsureDirectoryExists(fullPath); ProbeWriteAccess(fullPath); var appender = CreateRollingAppender(fullPath); var level = ParseLogLevel(logLevel); ConfigureRepository(appender, level); Current = new Log4NetAdapter(LogManager.GetLogger(typeof(Log))); } /// /// Convert the supplied path into an absolute, normalized form. /// /// Original path provided by the caller (may be relative). /// Absolute normalized file path. /// /// Uses to resolve relative paths against the current /// working directory. No IO is performed by this helper. /// private static string ResolvePath(string logFilePath) { return Path.GetFullPath(logFilePath); } /// /// Ensure the directory for the given file path exists, creating it if necessary. /// /// Absolute file path whose directory should be present. /// /// - Extracts the directory portion of and creates it when missing. /// - Any IO exceptions (for example insufficient permissions) will bubble to the caller. /// private static void EnsureDirectoryExists(string fullPath) { var directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } } /// /// Probe whether the process can open the target file for appending. /// /// Absolute path to the candidate log file. /// /// This method attempts to open the file with and immediately /// closes it. If the file cannot be opened for write access (permission denied, path locked, /// invalid path) the method wraps and throws with a helpful message. /// Use this probe to fail early during host startup rather than letting log4net fail silently /// or produce confusing errors later. /// private static void ProbeWriteAccess(string fullPath) { try { using (var fs = new FileStream(fullPath, FileMode.Append, FileAccess.Write, FileShare.Read)) { // just testing write access } } catch (Exception ex) { throw new IOException($"Cannot write to log file path '{fullPath}'. Check permissions and that the path is valid.", ex); } } /// /// Create a configured that writes UTF-8 encoded logs. /// /// Absolute path to the target log file. /// Initialized suitable for registration with log4net. /// /// The returned appender: /// - Uses a human-friendly pattern layout that includes timestamps, level, logger and message. /// - Rolls files by size with a moderate retention policy (5 backups, 10MB each). /// - Uses a minimal locking model to reduce contention while allowing other readers to tail the file. /// - Is activated (options applied) before being returned. /// private static RollingFileAppender CreateRollingAppender(string fullPath) { var layout = new PatternLayout { ConversionPattern = "%date %-5level %logger - %message%newline%exception" }; layout.ActivateOptions(); var roller = new RollingFileAppender { AppendToFile = true, File = fullPath, Layout = layout, MaxSizeRollBackups = 5, MaximumFileSize = "10MB", RollingStyle = RollingFileAppender.RollingMode.Size, StaticLogFileName = true, LockingModel = new FileAppender.MinimalLock(), Encoding = System.Text.Encoding.UTF8 }; roller.ActivateOptions(); return roller; } /// /// Parse a textual log level token into a log4net . /// /// Log level string provided by the user (case-insensitive). /// Corresponding log4net level; falls back to All when unknown. /// /// - Recognized tokens: ALL, DEBUG, INFO, WARN, ERROR, FATAL, OFF (case-insensitive). /// - The method resolves the log4net repository for the current entry assembly and reads the level /// from the repository's level map. This allows the returned value to be consistent with the /// repository's configured levels. /// - When the repository or level cannot be resolved the method falls back to Level.All. /// private static log4net.Core.Level ParseLogLevel(string logLevel) { var levelMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ALL"] = "ALL", ["DEBUG"] = "DEBUG", ["INFO"] = "INFO", ["WARN"] = "WARN", ["ERROR"] = "ERROR", ["FATAL"] = "FATAL", ["OFF"] = "OFF" }; var normalizedLevel = (logLevel ?? "ALL").Trim().ToUpperInvariant(); var levelName = levelMap.ContainsKey(normalizedLevel) ? levelMap[normalizedLevel] : "ALL"; var entry = Assembly.GetEntryAssembly(); var repoAssembly = (Assembly)(entry ?? Assembly.GetExecutingAssembly()); var repo = LogManager.GetRepository(repoAssembly); var hierarchy = repo as log4net.Repository.Hierarchy.Hierarchy; var level = hierarchy?.LevelMap[levelName] ?? hierarchy?.LevelMap["ALL"] ?? log4net.Core.Level.All; return level; } /// /// Configure the log4net repository with the provided appender and root level. /// /// Appender to register with the repository. /// Root logging level to apply to the repository. /// /// /// This method: /// - Resolves the log4net repository for the entry assembly (or current assembly fallback). /// - Resets existing configuration to ensure deterministic behaviour for programmatic setup. /// - Registers the provided appender using . /// - Sets the root logger level and marks the hierarchy as configured. /// /// /// Note: this routine intentionally performs global repository changes. Callers should only /// invoke it during host initialization or in dedicated integration tests. It is not suitable /// for ad-hoc changes inside library helpers that expect to be reentrant. /// /// private static void ConfigureRepository(RollingFileAppender appender, log4net.Core.Level level) { var entry = Assembly.GetEntryAssembly(); var repoAssembly = (Assembly)(entry ?? Assembly.GetExecutingAssembly()); var repo = LogManager.GetRepository(repoAssembly); var hierarchy = repo as log4net.Repository.Hierarchy.Hierarchy; if (hierarchy != null) { hierarchy.ResetConfiguration(); } log4net.Config.BasicConfigurator.Configure(repo, appender); if (hierarchy != null) { hierarchy.Root.Level = level; hierarchy.Configured = true; } } /// /// Enable logging using the existing log4net configuration. /// /// /// /// Sets to a that forwards all calls to the /// configured log4net repository. This is useful when the host configures log4net externally (for /// example via XML configuration or host wiring) and the library should piggy-back on the host's /// logging setup. /// /// /// For unit tests prefer setting directly to a test logger to keep tests /// deterministic and avoid file/IO side-effects. /// /// public static void Enable() { var logger = LogManager.GetLogger(typeof(Log)); Current = new Log4NetAdapter(logger); } /// /// Disable logging and revert to a no-op logger. /// /// /// /// Sets to so subsequent library log calls are no-ops. /// This is useful in tests to suppress noisy logs or to temporarily disable file logging. /// /// /// This method does not modify the log4net repository configuration; appenders remain registered. To fully /// reset log4net one would need to interact with the repository API directly (not performed here). /// /// public static void Disable() { Current = new NullLogger(); } /// /// Log a debug message via the active logger. /// /// /// Forwarding method to avoid callers referencing directly. Implementations of /// should be non-throwing and lightweight. /// public static void Debug(string message) => _current.Debug(message); /// /// Log an informational message via the active logger. /// /// /// Intended for high-level lifecycle and milestone messages. /// public static void Info(string message) => _current.Info(message); /// /// Log a warning via the active logger. /// /// /// Warnings indicate recoverable/unexpected conditions that merit attention. /// public static void Warn(string message) => _current.Warn(message); /// /// Log an error via the active logger, optionally with an exception. /// /// /// Error logging should be used for failures that affect operation. Avoid passing large exception /// objects across process boundaries; prefer serializing minimal diagnostic info when logs are /// collected by external systems. /// public static void Error(string message, Exception? ex = null) => _current.Error(message, ex); } }