using System.Collections.Concurrent; using System.Text; namespace MeetingAssistant.Logging; public static class MeetingAssistantLogFiles { public const string CurrentLogFileName = "meeting-assistant.log"; public const int RetainedRotatedLogFiles = 4; public static string DefaultLogDirectory => Path.Combine(Path.GetTempPath(), "MeetingAssistant", "Logs"); public static string CurrentLogPath(string? directory = null) { return Path.Combine(directory ?? DefaultLogDirectory, CurrentLogFileName); } public static string GetRotatedLogFileName(int index) { return $"{CurrentLogFileName}.{index}"; } public static IReadOnlyList EnumerateLogPaths(string? directory = null) { var root = directory ?? DefaultLogDirectory; return Enumerable.Range(0, RetainedRotatedLogFiles + 1) .Select(index => index == 0 ? Path.Combine(root, CurrentLogFileName) : Path.Combine(root, GetRotatedLogFileName(index))) .Where(File.Exists) .ToArray(); } public static void Rotate(string? directory = null) { var root = directory ?? DefaultLogDirectory; Directory.CreateDirectory(root); try { var oldest = Path.Combine(root, GetRotatedLogFileName(RetainedRotatedLogFiles)); if (File.Exists(oldest)) { File.Delete(oldest); } for (var index = RetainedRotatedLogFiles - 1; index >= 1; index--) { var source = Path.Combine(root, GetRotatedLogFileName(index)); if (!File.Exists(source)) { continue; } var target = Path.Combine(root, GetRotatedLogFileName(index + 1)); File.Move(source, target, overwrite: true); } var current = Path.Combine(root, CurrentLogFileName); if (File.Exists(current)) { File.Move(current, Path.Combine(root, GetRotatedLogFileName(1)), overwrite: true); } } catch (IOException) { // Another app instance or test host may still have the log open. // Logging should keep working, so append to the existing current log. } } } public sealed class MeetingAssistantFileLoggerProvider : ILoggerProvider { private readonly ConcurrentDictionary loggers = new(StringComparer.Ordinal); private readonly object sync = new(); private readonly StreamWriter writer; private bool disposed; public MeetingAssistantFileLoggerProvider(string? directory = null) { var logDirectory = directory ?? MeetingAssistantLogFiles.DefaultLogDirectory; MeetingAssistantLogFiles.Rotate(logDirectory); var stream = new FileStream( MeetingAssistantLogFiles.CurrentLogPath(logDirectory), FileMode.Append, FileAccess.Write, FileShare.ReadWrite); writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) { AutoFlush = true }; } public ILogger CreateLogger(string categoryName) { return loggers.GetOrAdd(categoryName, name => new MeetingAssistantFileLogger(name, Write)); } public void Dispose() { lock (sync) { if (disposed) { return; } disposed = true; writer.Dispose(); } } private void Write( string categoryName, LogLevel logLevel, EventId eventId, string message, Exception? exception) { if (disposed) { return; } lock (sync) { if (disposed) { return; } writer.Write(DateTimeOffset.Now.ToString("O")); writer.Write(' '); writer.Write(logLevel); writer.Write(" ["); writer.Write(categoryName); writer.Write(']'); if (eventId.Id != 0 || !string.IsNullOrWhiteSpace(eventId.Name)) { writer.Write(" ("); writer.Write(eventId.Id); if (!string.IsNullOrWhiteSpace(eventId.Name)) { writer.Write(':'); writer.Write(eventId.Name); } writer.Write(')'); } writer.Write(' '); writer.WriteLine(message); if (exception is not null) { writer.WriteLine(exception); } } } private sealed class MeetingAssistantFileLogger( string categoryName, Action write) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull { return null; } public bool IsEnabled(LogLevel logLevel) { return logLevel != LogLevel.None; } public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (!IsEnabled(logLevel)) { return; } write(categoryName, logLevel, eventId, formatter(state, exception), exception); } } }